From 0111d86fc82db36cb5724166767d9c1e62d40853 Mon Sep 17 00:00:00 2001 From: Adin Schmahmann Date: Thu, 28 May 2026 06:59:15 -0400 Subject: [PATCH 1/6] test: add e2e tests for downloading over WS/WSS/WebTransport --- package.json | 1 + test-e2e/bitswap.test.ts | 187 ++++++++++++++++++++++++++++++++ test-e2e/fixtures/serve/kubo.ts | 28 ++++- 3 files changed, 212 insertions(+), 4 deletions(-) create mode 100644 test-e2e/bitswap.test.ts diff --git a/package.json b/package.json index 5976db48..d2d48a49 100644 --- a/package.json +++ b/package.json @@ -125,6 +125,7 @@ "rimraf": "^6.0.1", "sinon": "^21.0.2", "tar": "^7.5.2", + "undici": "^8.3.0", "wait-on": "^9.0.1" }, "sideEffects": [ diff --git a/test-e2e/bitswap.test.ts b/test-e2e/bitswap.test.ts new file mode 100644 index 00000000..7a808008 --- /dev/null +++ b/test-e2e/bitswap.test.ts @@ -0,0 +1,187 @@ +import { create as createKuboRpcClient } from 'kubo-rpc-client' +import { fromString as uint8ArrayFromString } from 'uint8arrays/from-string' +import { WebSocket as NodeWebSocket } from 'undici' +import { test, expect } from './fixtures/config-test-fixtures.ts' +import { loadBypassingMediaViewer } from './fixtures/media-viewer.ts' +import type { Route } from '@playwright/test' +import type { MessageEvent } from 'undici' + +/** + * The main test Kubo (http://127.0.0.1:8088) serves as both the HTTP + * trustless gateway and the bitswap peer (via its WS and WT listeners). + * Per-test: the HTTP gateway path is blocked (page.route → 500) so the SW + * falls back to bitswap, and the delegated-routing endpoint is mocked to + * return only the transport address(es) under test (WS, WSS, WT, or a mix). + * + * Playwright intercepts service-worker fetch() calls via page.route(), and + * routes registered inside a test body take LIFO priority over the catch-all + * in config-test-fixtures.ts. + */ +const MAIN_KUBO = 'http://127.0.0.1:8088' + +test.describe('bitswap block retrieval', () => { + let testCidStr: string + let kuboPeerId: string + let wsAddr: string // /ip4/127.0.0.1/tcp/PORT/ws + let wsPort: number // TCP port extracted from wsAddr + let wssAddr: string // wsAddr with /ws → /wss; Playwright proxies wss:// to ws:// + let wtAddr: string // /ip4/127.0.0.1/udp/PORT/quic-v1/webtransport/certhash/… + + test.beforeAll(async () => { + // Connect to the already-running main test Kubo via its RPC endpoint. + // global-setup.ts starts the node and sets process.env.KUBO_RPC. + const kuboRpc = createKuboRpcClient(process.env.KUBO_RPC) + + const { cid } = await kuboRpc.add( + uint8ArrayFromString('hello from bitswap'), + { cidVersion: 1 } + ) + testCidStr = cid.toString() + + const id = await kuboRpc.id() + kuboPeerId = id.id.toString() + + const addrStrings = (await kuboRpc.swarm.localAddrs()).map(ma => ma.toString()) + + const found = { + ws: addrStrings.find(s => s.endsWith('/ws')), + wt: addrStrings.find(s => s.includes('/quic-v1/webtransport')) + } + if (found.ws == null || found.wt == null) { + throw new Error( + `Expected WS and WebTransport addrs in localAddrs(), got:\n ${addrStrings.join('\n ')}` + ) + } + wsAddr = found.ws + wsPort = parseInt(wsAddr.split('/tcp/')[1].split('/')[0], 10) + wssAddr = wsAddr.replace(/\/ws$/, '/wss') + wtAddr = found.wt + }) + + /** + * Block the HTTP trustless-gateway path so bitswap is the only option. + * The main Kubo has the test block, so without this the SW would succeed + * over HTTP and never exercise the bitswap path. + */ + async function mockGateway500 (page: any): Promise { + await page.route(`${MAIN_KUBO}/ipfs/**`, async (route: Route) => { + await route.fulfill({ status: 500 }) + }) + } + + /** + * Mock the delegated-routing endpoint so the SW discovers the main Kubo as + * a bitswap provider reachable at the given multiaddrs. Registered after + * the page fixture's catch-all route, so it takes LIFO priority. + */ + async function mockRouting (page: any, peerAddrs: string[]): Promise { + await page.route( + `${MAIN_KUBO}/routing/v1/providers/**`, + async (route: Route) => { + await route.fulfill({ + status: 200, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + Providers: [{ + Schema: 'peer', + ID: kuboPeerId, + Addrs: peerAddrs, + Protocols: ['transport-bitswap'] + }] + }) + }) + } + ) + } + + /** + * Intercept wss:// WebSocket connections from the service worker and proxy + * them to the plain ws:// Kubo node on the same port. + * + * Kubo does not auto-generate TLS certs for WSS listeners, so we advertise + * the plain WS port with a /wss suffix and let Playwright's routeWebSocket + * (CDP-based) terminate the "TLS" before it even reaches the network. + */ + async function mockWss (page: any, port: number): Promise { + await page.routeWebSocket( + (url: URL) => url.protocol === 'wss:' && url.hostname === '127.0.0.1' && url.port === String(port), + (wsRoute: any) => { + const pending: (string | Buffer)[] = [] + const server = new NodeWebSocket(`ws://127.0.0.1:${port}`) + server.binaryType = 'arraybuffer' + + wsRoute.onMessage((msg: string | Buffer) => { + if (server.readyState === NodeWebSocket.OPEN) { + server.send(msg) + } else { + pending.push(msg) + } + }) + + server.addEventListener('open', () => { + for (const msg of pending) { server.send(msg) } + pending.length = 0 + server.addEventListener('message', (evt: MessageEvent) => { + const data = typeof evt.data === 'string' ? evt.data : Buffer.from(evt.data) + void wsRoute.send(data) + }) + }) + + wsRoute.onClose(() => { server.close() }) + server.addEventListener('close', () => { void wsRoute.close() }) + server.addEventListener('error', () => { void wsRoute.close() }) + } + ) + } + + test('retrieves content via bitswap over WS', async ({ page, baseURL }) => { + await mockGateway500(page) + await mockRouting(page, [wsAddr]) + + const response = await loadBypassingMediaViewer(page, `${baseURL}/ipfs/${testCidStr}`) + expect(response.status()).toBe(200) + expect(await response.text()).toBe('hello from bitswap') + }) + + test('retrieves content via bitswap over WebTransport', async ({ page, baseURL }) => { + test.skip(test.info().project.name === 'safari', 'serverCertificateHashes only landed in Safari 26.4; WebKit in Playwright may be older') + await mockGateway500(page) + await mockRouting(page, [wtAddr]) + + const response = await loadBypassingMediaViewer(page, `${baseURL}/ipfs/${testCidStr}`) + expect(response.status()).toBe(200) + expect(await response.text()).toBe('hello from bitswap') + }) + + /** + * Additional transport coverage: WSS. + * + * Kubo listens on plain WS (/ws). We advertise the same port as /wss in the + * routing response. page.routeWebSocket() (CDP-based, Chromium) intercepts + * the wss:// connection from the service worker and proxies it to ws:// on + * the same port — no TLS cert infrastructure required. + * + * Skipped on non-Chromium: page.routeWebSocket() currently requires + * Chromium's CDP. WSS itself works in all browsers. + */ + test('retrieves content via bitswap over WSS (Playwright WS proxy)', async ({ page, baseURL }) => { + test.skip(test.info().project.name !== 'chromium', 'page.routeWebSocket() requires Chromium CDP') + await mockGateway500(page) + await mockWss(page, wsPort) + await mockRouting(page, [wssAddr]) + + const response = await loadBypassingMediaViewer(page, `${baseURL}/ipfs/${testCidStr}`) + expect(response.status()).toBe(200) + expect(await response.text()).toBe('hello from bitswap') + }) + + test('retrieves content via bitswap when routing returns both WS and WT addresses', async ({ page, baseURL }) => { + test.skip(test.info().project.name === 'safari', 'serverCertificateHashes only landed in Safari 26.4; WebKit in Playwright may be older') + await mockGateway500(page) + await mockRouting(page, [wsAddr, wtAddr]) + + const response = await loadBypassingMediaViewer(page, `${baseURL}/ipfs/${testCidStr}`) + expect(response.status()).toBe(200) + expect(await response.text()).toBe('hello from bitswap') + }) +}) diff --git a/test-e2e/fixtures/serve/kubo.ts b/test-e2e/fixtures/serve/kubo.ts index 67eff2ce..8a4cd049 100644 --- a/test-e2e/fixtures/serve/kubo.ts +++ b/test-e2e/fixtures/serve/kubo.ts @@ -5,6 +5,21 @@ import type { KuboNode } from 'ipfsd-ctl' const KUBO_GATEWAY_PORT = 8088 +/** + * Creates the main test Kubo node. + * + * The swarm is started so that inbound WebSocket and WebTransport connections + * are accepted — these are exercised by the bitswap e2e tests. + * Network isolation is maintained via: + * - Bootstrap: [] — no bootstrap peers to connect to on startup + * - Routing.Type: 'dht' — DHT support for IPNS + * - MDNS disabled — no local peer discovery + * - Gateway.NoFetch: true — gateway never fetches missing blocks from peers + * + * We use 'dht' (rather than 'none') because IPNS tests publish records via + * the node's key and then resolve them through the gateway; with Routing.Type + * 'none' Kubo refuses to start the IPNS subsystem and resolution always fails. + */ export async function createKuboNode (gatewayPort = KUBO_GATEWAY_PORT, nsMap: string): Promise { return createNode({ type: 'kubo', @@ -12,9 +27,6 @@ export async function createKuboNode (gatewayPort = KUBO_GATEWAY_PORT, nsMap: st rpc: create, test: true, disposable: true, - start: { - args: ['--offline'] - }, env: { IPFS_NS_MAP: nsMap }, @@ -22,9 +34,17 @@ export async function createKuboNode (gatewayPort = KUBO_GATEWAY_PORT, nsMap: st config: { Addresses: { Gateway: `/ip4/127.0.0.1/tcp/${gatewayPort}`, - API: '/ip4/127.0.0.1/tcp/0' + API: '/ip4/127.0.0.1/tcp/0', + Swarm: [ + '/ip4/127.0.0.1/tcp/0', + '/ip4/127.0.0.1/tcp/0/ws', + '/ip4/127.0.0.1/udp/0/quic-v1/webtransport' + ] }, Bootstrap: [], + Routing: { + Type: 'dht' + }, Swarm: { DisableNatPortMap: true }, From 55bdbe7061b39fb7a0359a085fe9d75db4ab7abb Mon Sep 17 00:00:00 2001 From: Adin Schmahmann Date: Thu, 28 May 2026 07:03:34 -0400 Subject: [PATCH 2/6] fix: start verifiedFetch on initialization --- src/sw/lib/verified-fetch.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/sw/lib/verified-fetch.ts b/src/sw/lib/verified-fetch.ts index 1246641f..147a5a5a 100644 --- a/src/sw/lib/verified-fetch.ts +++ b/src/sw/lib/verified-fetch.ts @@ -126,6 +126,7 @@ export async function updateVerifiedFetch (): Promise { verifiedFetch = await createVerifiedFetchWithHelia(helia, { withServerTiming: true }) + await verifiedFetch.start() } export async function getVerifiedFetch (): Promise { From 0e98978f233d40d2a728ec48b7d268704877126e Mon Sep 17 00:00:00 2001 From: Adin Schmahmann Date: Thu, 28 May 2026 08:59:35 -0400 Subject: [PATCH 3/6] todo: remove me, illustrative fixes/bugs --- patches/@helia+block-brokers+5.2.4.patch | 13 ++++++++ patches/@libp2p+webtransport+6.0.12.patch | 39 +++++++++++++++++++++++ 2 files changed, 52 insertions(+) create mode 100644 patches/@helia+block-brokers+5.2.4.patch create mode 100644 patches/@libp2p+webtransport+6.0.12.patch diff --git a/patches/@helia+block-brokers+5.2.4.patch b/patches/@helia+block-brokers+5.2.4.patch new file mode 100644 index 00000000..6af14961 --- /dev/null +++ b/patches/@helia+block-brokers+5.2.4.patch @@ -0,0 +1,13 @@ +diff --git a/node_modules/@helia/block-brokers/dist/src/trustless-gateway/utils.js b/node_modules/@helia/block-brokers/dist/src/trustless-gateway/utils.js +index 23527a0..15ed1a3 100644 +--- a/node_modules/@helia/block-brokers/dist/src/trustless-gateway/utils.js ++++ b/node_modules/@helia/block-brokers/dist/src/trustless-gateway/utils.js +@@ -5,7 +5,7 @@ import { Uint8ArrayList } from 'uint8arraylist'; + import { TrustlessGateway } from "./trustless-gateway.js"; + export function filterNonHTTPMultiaddrs(multiaddrs, allowInsecure, allowLocal) { + return multiaddrs.filter(ma => { +- if (HTTPS.matches(ma) || (allowInsecure && HTTP.matches(ma))) { ++ if (HTTPS.exactMatch(ma) || (allowInsecure && HTTP.exactMatch(ma))) { + if (allowLocal) { + return true; + } diff --git a/patches/@libp2p+webtransport+6.0.12.patch b/patches/@libp2p+webtransport+6.0.12.patch new file mode 100644 index 00000000..007541d1 --- /dev/null +++ b/patches/@libp2p+webtransport+6.0.12.patch @@ -0,0 +1,39 @@ +diff --git a/node_modules/@libp2p/webtransport/dist/src/index.js b/node_modules/@libp2p/webtransport/dist/src/index.js +index 5b52492..48f3757 100644 +--- a/node_modules/@libp2p/webtransport/dist/src/index.js ++++ b/node_modules/@libp2p/webtransport/dist/src/index.js +@@ -225,8 +225,12 @@ class WebTransportTransport { + if (!WebTransportMatcher.exactMatch(ma)) { + return false; + } +- const { url, certhashes } = parseMultiaddr(ma); +- return url != null && certhashes.length > 0; ++ try { ++ const { url, certhashes } = parseMultiaddr(ma); ++ return url != null && certhashes.length > 0; ++ } catch { ++ return false; ++ } + }); + } + } +diff --git a/node_modules/@libp2p/webtransport/src/index.ts b/node_modules/@libp2p/webtransport/src/index.ts +index 75f05ff..01eb51a 100644 +--- a/node_modules/@libp2p/webtransport/src/index.ts ++++ b/node_modules/@libp2p/webtransport/src/index.ts +@@ -306,9 +306,12 @@ class WebTransportTransport implements Transport { + return false + } + +- const { url, certhashes } = parseMultiaddr(ma) +- +- return url != null && certhashes.length > 0 ++ try { ++ const { url, certhashes } = parseMultiaddr(ma) ++ return url != null && certhashes.length > 0 ++ } catch { ++ return false ++ } + }) + } + } From 2cf462921ecd312647fa12329a9644d12370f27d Mon Sep 17 00:00:00 2001 From: Adin Schmahmann Date: Thu, 28 May 2026 15:18:48 -0400 Subject: [PATCH 4/6] fix: run WebTransport tests against Safari --- test-e2e/bitswap.test.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/test-e2e/bitswap.test.ts b/test-e2e/bitswap.test.ts index 7a808008..265ff878 100644 --- a/test-e2e/bitswap.test.ts +++ b/test-e2e/bitswap.test.ts @@ -144,7 +144,6 @@ test.describe('bitswap block retrieval', () => { }) test('retrieves content via bitswap over WebTransport', async ({ page, baseURL }) => { - test.skip(test.info().project.name === 'safari', 'serverCertificateHashes only landed in Safari 26.4; WebKit in Playwright may be older') await mockGateway500(page) await mockRouting(page, [wtAddr]) @@ -176,7 +175,6 @@ test.describe('bitswap block retrieval', () => { }) test('retrieves content via bitswap when routing returns both WS and WT addresses', async ({ page, baseURL }) => { - test.skip(test.info().project.name === 'safari', 'serverCertificateHashes only landed in Safari 26.4; WebKit in Playwright may be older') await mockGateway500(page) await mockRouting(page, [wsAddr, wtAddr]) From 58d55bb0af7a2ab02f6e95ddf21a40eba8efb9c9 Mon Sep 17 00:00:00 2001 From: Adin Schmahmann Date: Fri, 29 May 2026 09:31:31 -0400 Subject: [PATCH 5/6] fix: stricter http bitswap mocking - switch mocks from page to page.context - mock out peer routing in bitswap tests --- test-e2e/bitswap.test.ts | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/test-e2e/bitswap.test.ts b/test-e2e/bitswap.test.ts index 265ff878..4f073c44 100644 --- a/test-e2e/bitswap.test.ts +++ b/test-e2e/bitswap.test.ts @@ -64,7 +64,7 @@ test.describe('bitswap block retrieval', () => { * over HTTP and never exercise the bitswap path. */ async function mockGateway500 (page: any): Promise { - await page.route(`${MAIN_KUBO}/ipfs/**`, async (route: Route) => { + await page.context().route(`${MAIN_KUBO}/ipfs/**`, async (route: Route) => { await route.fulfill({ status: 500 }) }) } @@ -75,7 +75,19 @@ test.describe('bitswap block retrieval', () => { * the page fixture's catch-all route, so it takes LIFO priority. */ async function mockRouting (page: any, peerAddrs: string[]): Promise { - await page.route( + const addrs = peerAddrs.map(addr => addr.includes('/p2p/') ? addr : `${addr}/p2p/${kuboPeerId}`) + const routeTarget = page.context() + + // Mock out peer routing so it doesn't interfere with limitations imposed by + // the custom content routing + await routeTarget.route( + `${MAIN_KUBO}/routing/v1/peers/**`, + async (route: Route) => { + await route.fulfill({ status: 404 }) + } + ) + + await routeTarget.route( `${MAIN_KUBO}/routing/v1/providers/**`, async (route: Route) => { await route.fulfill({ @@ -85,7 +97,7 @@ test.describe('bitswap block retrieval', () => { Providers: [{ Schema: 'peer', ID: kuboPeerId, - Addrs: peerAddrs, + Addrs: addrs, Protocols: ['transport-bitswap'] }] }) From 6943c67bb37bbd01eb26ac679f59fb13f301d612 Mon Sep 17 00:00:00 2001 From: Adin Schmahmann Date: Fri, 29 May 2026 09:46:58 -0400 Subject: [PATCH 6/6] fix: add bitswap test that fails when there's only one invalid address returned --- test-e2e/bitswap.test.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/test-e2e/bitswap.test.ts b/test-e2e/bitswap.test.ts index 4f073c44..4368106f 100644 --- a/test-e2e/bitswap.test.ts +++ b/test-e2e/bitswap.test.ts @@ -194,4 +194,15 @@ test.describe('bitswap block retrieval', () => { expect(response.status()).toBe(200) expect(await response.text()).toBe('hello from bitswap') }) + + test('fails bitswap retrieval when routing returns only invalid WT address', async ({ page, baseURL }) => { + const invalidWtAddr = wtAddr.replace(/\/certhash\/[^/]+/, '/certhash/not-a-valid-certhash') + expect(invalidWtAddr).not.toBe(wtAddr) + + await mockGateway500(page) + await mockRouting(page, [invalidWtAddr]) + + const response = await loadBypassingMediaViewer(page, `${baseURL}/ipfs/${testCidStr}`) + expect(response.status()).not.toBe(200) + }) })