test: cover the epoxy-based networking client

Replaces the coverage lost when the wisp implementation was removed:
PSocket.test.js and PWispHandler.test.js were bound to PWispHandler,
parsers.js, and wispInfo, none of which survive the epoxy rewrite.

  PSocket.test.js  - connect/retry, inbound data, write normalisation,
                     close, and the TLS event remapping. Driven through
                     real ReadableStream/WritableStream pairs so
                     reader locking, cancel, and abort behave as they
                     do in the browser.
  index.test.js    - relay-token exchange (auth header, 401 re-auth and
                     retry, malformed responses) and the epoxy client
                     cache (keying, refresh, in-flight sharing).
  requests.test.js - pFetch delegation, cache invalidation, and the
                     api call logger's request description.
  epoxy.test.js    - the hand-packed wisp password extension payload.

The wasm runtime loader is left to integration coverage; it needs a
network fetch and a browser. createPuterPasswordBuilder is exported so
the byte layout can be tested against an injected fake runtime.

One skipped test records a pre-existing defect: on a failed write,
#readLoop's normal-termination path races #closeStreams and emits
close(false) after an error.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Toshit Chawda
2026-08-10 18:54:17 -04:00
committed by Neal Shah
co-authored by Claude Opus 5
parent 29456a24d5
commit 3fd9f68493
5 changed files with 963 additions and 1 deletions
@@ -0,0 +1,445 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
// PSocket's only collaborator is the epoxy client, so that module is stubbed:
// no wasm runtime, no relay, no websocket. Stubbing it also breaks the
// PSocket <-> index.js import cycle for these tests.
const mockGetEpoxyClient = vi.fn();
const mockClearEpoxyClientCache = vi.fn();
vi.mock('./index.js', () => ({
getEpoxyClient: (...args) => mockGetEpoxyClient(...args),
clearEpoxyClientCache: (...args) => mockClearEpoxyClientCache(...args),
}));
const { PSocket, PTLSSocket } = await import('./PSocket.js');
// Socket events land a few microtasks after the call that triggers them, so
// assertions retry rather than guess a tick count. The default 50ms poll would
// dominate the runtime of a suite this size; these waits resolve almost
// immediately.
const until = assertion => vi.waitFor(assertion, { interval: 1, timeout: 1000 });
// A duplex pair shaped like what `EpoxyClient.connect` returns: a
// ReadableStream of inbound bytes plus a WritableStream of outbound ones.
// These are the platform's real stream implementations, so reader/writer
// locking, cancel, and abort behave as they do in the browser.
function makeStream ({ failWrite } = {}) {
let readController;
const read = new ReadableStream({
start (controller) {
readController = controller;
},
});
const written = [];
const write = new WritableStream({
write (chunk) {
if ( failWrite ) {
throw failWrite;
}
written.push(chunk);
},
});
return {
read,
write,
written,
push: bytes => readController.enqueue(bytes),
endRead: () => readController.close(),
failRead: error => readController.error(error),
};
}
function makeClient (stream) {
return {
connect: vi.fn(async () => stream),
connectTls: vi.fn(async () => stream),
};
}
// A promise whose settlement the test controls, for pausing mid-connect.
function deferred () {
let resolve;
let reject;
const promise = new Promise((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
// Attaches spies for every event a caller can observe. `error` is never
// tls-prefixed, unlike open/data/close.
function listen (socket) {
const spies = {
open: vi.fn(),
data: vi.fn(),
close: vi.fn(),
error: vi.fn(),
};
for ( const event of Object.keys(spies) ) {
socket.on(event, spies[event]);
}
return spies;
}
// Opens a socket and waits until it is ready to write.
async function connected (stream = makeStream()) {
const client = makeClient(stream);
mockGetEpoxyClient.mockResolvedValue(client);
const socket = new PSocket('example.com', 80);
const events = listen(socket);
await until(() => expect(events.open).toHaveBeenCalledTimes(1));
return { socket, events, client, stream };
}
beforeEach(() => {
mockGetEpoxyClient.mockReset();
mockClearEpoxyClientCache.mockReset();
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('PSocket connect', () => {
it('opens a stream through the epoxy client', async () => {
const { client, events } = await connected();
expect(client.connect).toHaveBeenCalledWith('example.com', 80);
expect(client.connectTls).not.toHaveBeenCalled();
expect(mockGetEpoxyClient).toHaveBeenCalledWith({ refresh: false });
expect(events.error).not.toHaveBeenCalled();
});
it('coerces a string port to a number', async () => {
const client = makeClient(makeStream());
mockGetEpoxyClient.mockResolvedValue(client);
const socket = new PSocket('example.com', '443');
const events = listen(socket);
await until(() => expect(events.open).toHaveBeenCalled());
expect(client.connect).toHaveBeenCalledWith('example.com', 443);
});
it('retries with a refreshed client when the first attempt fails', async () => {
const client = makeClient(makeStream());
mockGetEpoxyClient
.mockRejectedValueOnce(new Error('stale client'))
.mockResolvedValueOnce(client);
const socket = new PSocket('example.com', 80);
const events = listen(socket);
await until(() => expect(events.open).toHaveBeenCalledTimes(1));
expect(mockGetEpoxyClient).toHaveBeenNthCalledWith(1, { refresh: false });
expect(mockGetEpoxyClient).toHaveBeenNthCalledWith(2, { refresh: true });
expect(events.error).not.toHaveBeenCalled();
});
it('emits an error and closes when both attempts fail', async () => {
mockGetEpoxyClient.mockRejectedValue(new Error('relay down'));
const socket = new PSocket('example.com', 80);
const events = listen(socket);
await until(() => expect(events.close).toHaveBeenCalled());
expect(events.open).not.toHaveBeenCalled();
expect(events.error).toHaveBeenCalledTimes(1);
expect(events.close).toHaveBeenCalledWith(true);
expect(mockClearEpoxyClientCache).toHaveBeenCalled();
});
// The types declare the handler as `(error: Error) => void` and document the
// reason as `error.message`, so a rejection has to arrive wrapped.
it('reports failures as Error instances', async () => {
mockGetEpoxyClient.mockRejectedValue(new Error('relay down'));
const socket = new PSocket('example.com', 80);
const events = listen(socket);
await until(() => expect(events.error).toHaveBeenCalled());
const [reason] = events.error.mock.calls[0];
expect(reason).toBeInstanceOf(Error);
expect(reason.message).toBe('relay down');
});
it('wraps a non-Error rejection reason', async () => {
mockGetEpoxyClient.mockRejectedValue('just a string');
const socket = new PSocket('example.com', 80);
const events = listen(socket);
await until(() => expect(events.error).toHaveBeenCalled());
const [reason] = events.error.mock.calls[0];
expect(reason).toBeInstanceOf(Error);
expect(reason.message).toBe('just a string');
});
});
describe('PSocket inbound data', () => {
it('emits each chunk the remote sends', async () => {
const { events, stream } = await connected();
stream.push(new Uint8Array([1, 2, 3]));
await until(() => expect(events.data).toHaveBeenCalledTimes(1));
expect(Array.from(events.data.mock.calls[0][0])).toEqual([1, 2, 3]);
});
it('emits chunks in order', async () => {
const { events, stream } = await connected();
stream.push(new Uint8Array([1]));
stream.push(new Uint8Array([2]));
await until(() => expect(events.data).toHaveBeenCalledTimes(2));
expect(events.data.mock.calls.map(([chunk]) => Array.from(chunk)))
.toEqual([[1], [2]]);
});
it('closes without an error flag when the remote ends the stream', async () => {
const { events, stream } = await connected();
stream.endRead();
await until(() => expect(events.close).toHaveBeenCalled());
expect(events.close).toHaveBeenCalledWith(false);
expect(events.error).not.toHaveBeenCalled();
});
it('emits an error and drops the cached client when the read fails', async () => {
const { events, stream } = await connected();
stream.failRead(new Error('connection reset'));
await until(() => expect(events.error).toHaveBeenCalled());
expect(events.error.mock.calls[0][0].message).toBe('connection reset');
expect(mockClearEpoxyClientCache).toHaveBeenCalled();
await until(() => expect(events.close).toHaveBeenCalledWith(true));
});
});
describe('PSocket write', () => {
it('writes a typed array', async () => {
const { socket, stream } = await connected();
socket.write(new Uint8Array([1, 2, 3]));
await until(() => expect(stream.written).toHaveLength(1));
expect(Array.from(stream.written[0])).toEqual([1, 2, 3]);
});
it('writes an ArrayBuffer', async () => {
const { socket, stream } = await connected();
socket.write(new Uint8Array([4, 5, 6]).buffer);
await until(() => expect(stream.written).toHaveLength(1));
expect(Array.from(stream.written[0])).toEqual([4, 5, 6]);
});
// A view can cover part of a larger buffer; only its own bytes may be sent.
it('writes only the bytes a partial view covers', async () => {
const { socket, stream } = await connected();
const backing = new Uint8Array([9, 1, 2, 3, 9]).buffer;
socket.write(new Uint8Array(backing, 1, 3));
await until(() => expect(stream.written).toHaveLength(1));
expect(Array.from(stream.written[0])).toEqual([1, 2, 3]);
});
it('encodes a string as utf-8', async () => {
const { socket, stream } = await connected();
socket.write('hé');
await until(() => expect(stream.written).toHaveLength(1));
expect(Array.from(stream.written[0])).toEqual([104, 195, 169]);
});
it('throws on an unsupported data type', async () => {
const { socket } = await connected();
expect(() => socket.write(42)).toThrow(/Invalid data type/);
});
it('invokes the callback once the write lands', async () => {
const { socket } = await connected();
const callback = vi.fn();
socket.write('hi', callback);
await until(() => expect(callback).toHaveBeenCalledTimes(1));
});
it('queues writes issued before the socket opens and flushes them in order', async () => {
const stream = makeStream();
const gate = deferred();
mockGetEpoxyClient.mockReturnValue(gate.promise);
const socket = new PSocket('example.com', 80);
const events = listen(socket);
// Still connecting, so neither write can reach the stream yet.
socket.write('first');
socket.write('second');
expect(stream.written).toHaveLength(0);
gate.resolve(makeClient(stream));
await until(() => expect(events.open).toHaveBeenCalled());
await until(() => expect(stream.written).toHaveLength(2));
const decoder = new TextDecoder();
expect(stream.written.map(chunk => decoder.decode(chunk)))
.toEqual(['first', 'second']);
});
it('throws when writing to a closed socket', async () => {
const { socket, events } = await connected();
socket.close();
await until(() => expect(events.close).toHaveBeenCalled());
expect(() => socket.write('late')).toThrow(/already closed/);
});
it('emits an error and drops the cached client when a write fails', async () => {
const stream = makeStream({ failWrite: new Error('write failed') });
const { socket, events } = await connected(stream);
socket.write('doomed');
await until(() => expect(events.error).toHaveBeenCalled());
expect(events.error.mock.calls[0][0].message).toBe('write failed');
expect(mockClearEpoxyClientCache).toHaveBeenCalled();
});
// KNOWN BUG -- unskip once #readLoop stops racing the error path.
//
// A failed write sets #closing and hands the close event to #closeStreams,
// which must await reader.cancel() and writer.close() before emitting
// close(true). That cancel resolves #readLoop's pending read() with
// {done: true}, so the loop breaks and reaches its own #emitClose(false)
// first -- #closed is already set by the time #closeStreams gets there, so
// callers are told the socket shut down cleanly after an error.
// A read failure is unaffected: that path throws into #readLoop's catch,
// which never calls #emitClose(false).
it.skip('closes with the error flag set when a write fails', async () => {
const stream = makeStream({ failWrite: new Error('write failed') });
const { socket, events } = await connected(stream);
socket.write('doomed');
await until(() => expect(events.close).toHaveBeenCalled());
expect(events.close).toHaveBeenCalledWith(true);
});
});
describe('PSocket close', () => {
it('emits close exactly once, even when called repeatedly', async () => {
const { socket, events } = await connected();
socket.close();
socket.close();
await until(() => expect(events.close).toHaveBeenCalled());
expect(events.close).toHaveBeenCalledTimes(1);
expect(events.close).toHaveBeenCalledWith(false);
expect(events.error).not.toHaveBeenCalled();
});
// Closing while the relay handshake is still in flight must not leave the
// freshly opened stream dangling, and must not surface as an open socket.
it('tears down a stream that arrives after close, without emitting open', async () => {
const stream = makeStream();
const cancelSpy = vi.spyOn(stream.read, 'cancel');
const abortSpy = vi.spyOn(stream.write, 'abort');
const gate = deferred();
mockGetEpoxyClient.mockReturnValue(gate.promise);
const socket = new PSocket('example.com', 80);
const events = listen(socket);
socket.close();
gate.resolve(makeClient(stream));
await until(() => expect(cancelSpy).toHaveBeenCalled());
expect(abortSpy).toHaveBeenCalled();
expect(events.open).not.toHaveBeenCalled();
});
it('stops emitting data after close', async () => {
const { socket, events, stream } = await connected();
socket.close();
await until(() => expect(events.close).toHaveBeenCalled());
expect(() => stream.push(new Uint8Array([1]))).toThrow();
expect(events.data).not.toHaveBeenCalled();
});
});
describe('PTLSSocket', () => {
it('opens a TLS stream and reports tls-prefixed events through on()', async () => {
const stream = makeStream();
const client = makeClient(stream);
mockGetEpoxyClient.mockResolvedValue(client);
const socket = new PTLSSocket('example.com', 443);
const events = listen(socket);
await until(() => expect(events.open).toHaveBeenCalledTimes(1));
expect(client.connectTls).toHaveBeenCalledWith('example.com', 443);
expect(client.connect).not.toHaveBeenCalled();
stream.push(new Uint8Array([7]));
await until(() => expect(events.data).toHaveBeenCalledTimes(1));
expect(Array.from(events.data.mock.calls[0][0])).toEqual([7]);
stream.endRead();
await until(() => expect(events.close).toHaveBeenCalledWith(false));
});
// `on('open')` is sugar that remaps onto the tls-prefixed name; listening
// for the prefixed name directly has to keep working too.
it('also accepts the tls-prefixed event names directly', async () => {
const stream = makeStream();
mockGetEpoxyClient.mockResolvedValue(makeClient(stream));
const socket = new PTLSSocket('example.com', 443);
const onTlsOpen = vi.fn();
const onTlsData = vi.fn();
socket.on('tlsopen', onTlsOpen);
socket.on('tlsdata', onTlsData);
await until(() => expect(onTlsOpen).toHaveBeenCalledTimes(1));
stream.push(new Uint8Array([8]));
await until(() => expect(onTlsData).toHaveBeenCalledTimes(1));
});
it('reports errors on the unprefixed error event', async () => {
mockGetEpoxyClient.mockRejectedValue(new Error('tls handshake failed'));
const socket = new PTLSSocket('example.com', 443);
const events = listen(socket);
await until(() => expect(events.error).toHaveBeenCalled());
expect(events.error.mock.calls[0][0].message).toBe('tls handshake failed');
});
it('routes addListener through the same remapping as on()', async () => {
const stream = makeStream();
mockGetEpoxyClient.mockResolvedValue(makeClient(stream));
const socket = new PTLSSocket('example.com', 443);
const onOpen = vi.fn();
socket.addListener('open', onOpen);
await until(() => expect(onOpen).toHaveBeenCalledTimes(1));
});
});
+3 -1
View File
@@ -29,7 +29,9 @@ async function getEpoxyRuntime () {
}
}
function createPuterPasswordBuilder (runtime, wispToken) {
// Exported for tests: the wisp password extension's byte layout is hand-packed,
// and `runtime` is injectable, so it can be exercised without the wasm bundle.
export function createPuterPasswordBuilder (runtime, wispToken) {
class PuterPasswordExt extends runtime.JsProtocolExtension {
constructor (required, toSend) {
super(0x02, [], []);
@@ -0,0 +1,79 @@
import { describe, expect, it } from 'vitest';
import { createPuterPasswordBuilder } from './epoxy.js';
// Stand-ins for the extension base classes the epoxy wasm bundle exports.
// Loading the real runtime needs a network fetch plus a wasm instantiation, so
// only the hand-packed byte layout of our subclasses is exercised here.
function makeRuntime () {
return {
JsProtocolExtension: class {
constructor (id, ...rest) {
this.id = id;
this.rest = rest;
}
},
JsProtocolExtensionBuilder: class {
constructor (id) {
this.id = id;
}
},
};
}
const PUTER_PASSWORD_EXT_ID = 0x02;
const build = (token = 'wisp-token') =>
createPuterPasswordBuilder(makeRuntime(), token);
describe('puter password extension', () => {
it('registers under the puter password extension id', () => {
expect(build().id).toBe(PUTER_PASSWORD_EXT_ID);
expect(build().buildToExtension().id).toBe(PUTER_PASSWORD_EXT_ID);
});
// Wire format: u8 username length, u16-LE password length, then the
// username and password bytes. Puter sends an empty username and the
// relay token as the password.
it('packs an empty username and the token as the password', () => {
const encoded = build('abc').buildToExtension().encode();
expect(Array.from(encoded)).toEqual([
0, // username length
3, 0, // password length, little-endian
97, 98, 99, // "abc"
]);
});
it('encodes the password length little-endian across the u16 boundary', () => {
const token = 'x'.repeat(300);
const encoded = build(token).buildToExtension().encode();
expect(encoded).toHaveLength(3 + 300);
expect(encoded[0]).toBe(0);
// 300 == 0x012c, so the low byte leads.
expect(encoded[1]).toBe(0x2c);
expect(encoded[2]).toBe(0x01);
});
it('encodes a multi-byte token by its utf-8 length, not its character count', () => {
// 'é' is two bytes in utf-8, so a 2-character token is 4 bytes.
const encoded = build('éé').buildToExtension().encode();
expect(encoded[1]).toBe(4);
expect(Array.from(encoded.slice(3))).toEqual([195, 169, 195, 169]);
});
it('sends nothing for an extension parsed off the wire', () => {
// buildFromBytes has no payload to send; only buildToExtension does.
const parsed = build().buildFromBytes(new Uint8Array([1]));
expect(parsed.encode()).toHaveLength(0);
});
it('marks the extension required when the peer flags it', () => {
expect(build().buildFromBytes(new Uint8Array([1])).required).toBe(true);
expect(build().buildFromBytes(new Uint8Array([2])).required).toBe(true);
expect(build().buildFromBytes(new Uint8Array([0])).required).toBe(false);
});
});
@@ -0,0 +1,272 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
// Only the relay-token exchange and the client cache are under test, so the
// wasm bundle is stubbed out: initEpoxy just hands back a marker object.
const mockInitEpoxy = vi.fn();
vi.mock('./epoxy.js', () => ({
initEpoxy: (...args) => mockInitEpoxy(...args),
}));
const {
clearEpoxyClientCache,
generateWispV1URL,
getEpoxyClient,
getWispCredentials,
netAPI,
} = await import('./index.js');
const CREDENTIALS = { token: 'wisp-token', server: 'wss://relay.test' };
function relayResponse ({ status = 200, body = CREDENTIALS } = {}) {
return {
ok: status >= 200 && status < 300,
status,
statusText: String(status),
json: async () => body,
};
}
// A promise whose settlement the test controls, for overlapping callers.
function deferred () {
let resolve;
const promise = new Promise(res => {
resolve = res;
});
return { promise, resolve };
}
const origPuter = globalThis.puter;
const origFetch = globalThis.fetch;
let mockFetch;
beforeEach(() => {
clearEpoxyClientCache();
mockInitEpoxy.mockReset()
.mockImplementation(async () => ({ client: 'epoxy' }));
mockFetch = vi.fn(async () => relayResponse());
globalThis.fetch = mockFetch;
globalThis.puter = {
APIOrigin: 'https://api.test',
authToken: 'tok',
// Production clears the stored token, which is what makes the code
// under test prompt for sign-in again on the retry.
resetAuthToken: vi.fn(() => {
globalThis.puter.authToken = null;
}),
ui: { authenticateWithPuter: vi.fn(async () => {}) },
};
});
afterEach(() => {
clearEpoxyClientCache();
globalThis.puter = origPuter;
globalThis.fetch = origFetch;
});
describe('getWispCredentials', () => {
it('posts to the relay-token endpoint with the bearer token', async () => {
const credentials = await getWispCredentials();
expect(credentials).toEqual({
wispToken: CREDENTIALS.token,
wispServer: CREDENTIALS.server,
});
expect(mockFetch).toHaveBeenCalledTimes(1);
const [url, init] = mockFetch.mock.calls[0];
expect(url).toBe('https://api.test/wisp/relay-token/create');
expect(init.method).toBe('POST');
expect(init.headers.Authorization).toBe('Bearer tok');
expect(init.headers['Content-Type']).toBe('application/json');
});
it('throws when the puter runtime is not up yet', async () => {
globalThis.puter = undefined;
await expect(getWispCredentials()).rejects.toThrow(/not initialized/);
expect(mockFetch).not.toHaveBeenCalled();
});
it('prompts for sign-in before requesting a token when there is none', async () => {
globalThis.puter.authToken = undefined;
await getWispCredentials();
expect(globalThis.puter.ui.authenticateWithPuter).toHaveBeenCalledTimes(1);
});
it('sends the token the sign-in prompt just established', async () => {
globalThis.puter.authToken = undefined;
globalThis.puter.ui.authenticateWithPuter.mockImplementation(async () => {
globalThis.puter.authToken = 'fresh-tok';
});
await getWispCredentials();
const [, init] = mockFetch.mock.calls[0];
expect(init.headers.Authorization).toBe('Bearer fresh-tok');
});
it('omits the auth header entirely when no token could be obtained', async () => {
globalThis.puter.authToken = undefined;
await getWispCredentials();
const [, init] = mockFetch.mock.calls[0];
expect(init.headers).not.toHaveProperty('Authorization');
});
it('discards a rejected token, re-authenticates, and retries once', async () => {
mockFetch
.mockImplementationOnce(async () => relayResponse({ status: 401 }))
.mockImplementationOnce(async () => relayResponse());
globalThis.puter.ui.authenticateWithPuter.mockImplementation(async () => {
globalThis.puter.authToken = 'fresh-tok';
});
const credentials = await getWispCredentials();
expect(credentials.wispToken).toBe(CREDENTIALS.token);
expect(globalThis.puter.resetAuthToken).toHaveBeenCalledTimes(1);
expect(globalThis.puter.ui.authenticateWithPuter).toHaveBeenCalledTimes(1);
expect(mockFetch).toHaveBeenCalledTimes(2);
// The retry has to carry the newly minted token, not the rejected one.
const [, retryInit] = mockFetch.mock.calls[1];
expect(retryInit.headers.Authorization).toBe('Bearer fresh-tok');
});
// The retry passes retryAuth=false, so a second 401 must surface rather
// than recurse into an endless re-auth loop.
it('gives up after a second 401 instead of looping', async () => {
mockFetch.mockImplementation(async () => relayResponse({ status: 401 }));
await expect(getWispCredentials()).rejects.toThrow(/HTTP 401/);
expect(mockFetch).toHaveBeenCalledTimes(2);
});
it('reports the status when the endpoint fails outright', async () => {
mockFetch.mockImplementation(async () => relayResponse({ status: 500 }));
await expect(getWispCredentials()).rejects.toThrow(/HTTP 500/);
// 500 is not a re-auth case, so there is no retry.
expect(mockFetch).toHaveBeenCalledTimes(1);
});
it.each([
['an empty body', {}],
['a missing server', { token: 'only-token' }],
['a missing token', { server: 'wss://relay.test' }],
])('rejects %s from the relay-token endpoint', async (_label, body) => {
mockFetch.mockImplementation(async () => relayResponse({ body }));
await expect(getWispCredentials()).rejects.toThrow(/invalid response/);
});
});
describe('generateWispV1URL', () => {
it('joins the relay server and token into a wisp url', async () => {
await expect(generateWispV1URL()).resolves.toBe('wss://relay.test/wisp-token/');
});
it('is reachable through the public net API', async () => {
await expect(netAPI.generateWispV1URL()).resolves.toBe('wss://relay.test/wisp-token/');
});
});
describe('getEpoxyClient', () => {
it('builds a client from freshly minted credentials', async () => {
const client = await getEpoxyClient();
expect(client).toEqual({ client: 'epoxy' });
expect(mockInitEpoxy).toHaveBeenCalledWith({
wispToken: CREDENTIALS.token,
wispServer: CREDENTIALS.server,
});
});
it('reuses the cached client for the same origin and token', async () => {
const first = await getEpoxyClient();
const second = await getEpoxyClient();
expect(second).toBe(first);
expect(mockInitEpoxy).toHaveBeenCalledTimes(1);
expect(mockFetch).toHaveBeenCalledTimes(1);
});
it('rebuilds the client when the auth token changes', async () => {
await getEpoxyClient();
globalThis.puter.authToken = 'different-tok';
await getEpoxyClient();
expect(mockInitEpoxy).toHaveBeenCalledTimes(2);
});
it('rebuilds the client when the API origin changes', async () => {
await getEpoxyClient();
globalThis.puter.APIOrigin = 'https://other.test';
await getEpoxyClient();
expect(mockInitEpoxy).toHaveBeenCalledTimes(2);
});
it('rebuilds the client when a refresh is requested', async () => {
await getEpoxyClient();
await getEpoxyClient({ refresh: true });
expect(mockInitEpoxy).toHaveBeenCalledTimes(2);
});
it('shares one in-flight init between concurrent callers', async () => {
const gate = deferred();
mockInitEpoxy.mockImplementation(() => gate.promise);
const both = Promise.all([getEpoxyClient(), getEpoxyClient()]);
gate.resolve({ client: 'epoxy' });
const [first, second] = await both;
expect(first).toBe(second);
expect(mockInitEpoxy).toHaveBeenCalledTimes(1);
});
it('drops the cache after clearEpoxyClientCache', async () => {
await getEpoxyClient();
clearEpoxyClientCache();
await getEpoxyClient();
expect(mockInitEpoxy).toHaveBeenCalledTimes(2);
});
// A failed init must not be cached, otherwise every later socket would keep
// resolving the same broken attempt.
it('does not cache a failed init, so the next caller retries', async () => {
mockInitEpoxy.mockRejectedValueOnce(new Error('wasm unavailable'));
await getEpoxyClient();
const retried = await getEpoxyClient();
expect(mockInitEpoxy).toHaveBeenCalledTimes(2);
expect(retried).toEqual({ client: 'epoxy' });
});
// Credential failures are swallowed the same way an init failure is.
it('does not cache a failed credential fetch', async () => {
mockFetch.mockImplementationOnce(async () => relayResponse({ status: 500 }));
await getEpoxyClient();
const retried = await getEpoxyClient();
expect(retried).toEqual({ client: 'epoxy' });
});
});
describe('netAPI surface', () => {
it('exposes the socket constructors and fetch the docs promise', () => {
expect(typeof netAPI.Socket).toBe('function');
expect(typeof netAPI.tls.TLSSocket).toBe('function');
expect(typeof netAPI.fetch).toBe('function');
expect(typeof netAPI.generateWispV1URL).toBe('function');
});
});
@@ -0,0 +1,164 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
// pFetch is a thin wrapper over the epoxy client, so the client module is
// stubbed: the tests care about delegation, cache invalidation, and logging.
const mockGetEpoxyClient = vi.fn();
const mockClearEpoxyClientCache = vi.fn();
vi.mock('./index.js', () => ({
getEpoxyClient: (...args) => mockGetEpoxyClient(...args),
clearEpoxyClientCache: (...args) => mockClearEpoxyClientCache(...args),
}));
const { pFetch } = await import('./requests.js');
const origPuter = globalThis.puter;
let mockClientFetch;
let logRequest;
// Matches what the epoxy client resolves to: a Response-like object.
const RESPONSE = { status: 204, statusText: 'No Content' };
beforeEach(() => {
mockClearEpoxyClientCache.mockReset();
mockClientFetch = vi.fn(async () => RESPONSE);
mockGetEpoxyClient.mockReset()
.mockResolvedValue({ fetch: (...args) => mockClientFetch(...args) });
logRequest = vi.fn();
globalThis.puter = {
apiCallLogger: { isEnabled: () => true, logRequest },
};
});
afterEach(() => {
globalThis.puter = origPuter;
});
describe('pFetch delegation', () => {
it('passes its arguments straight through and returns the response', async () => {
const init = { method: 'POST', body: 'payload' };
const response = await pFetch('https://example.com/api', init);
expect(response).toBe(RESPONSE);
expect(mockClientFetch).toHaveBeenCalledWith('https://example.com/api', init);
});
it('works with no init argument', async () => {
await pFetch('https://example.com/api');
expect(mockClientFetch).toHaveBeenCalledWith('https://example.com/api');
});
it('rethrows a failed request', async () => {
mockClientFetch.mockRejectedValue(new Error('socket closed'));
await expect(pFetch('https://example.com')).rejects.toThrow('socket closed');
});
it('drops the cached client when the request fails', async () => {
mockClientFetch.mockRejectedValue(new Error('socket closed'));
await expect(pFetch('https://example.com')).rejects.toThrow();
expect(mockClearEpoxyClientCache).toHaveBeenCalledTimes(1);
});
// Nothing was cached if the client itself never came up, so there is
// nothing to invalidate -- clearing here would just mask the real failure.
it('leaves the cache alone when the client could not be created', async () => {
mockGetEpoxyClient.mockRejectedValue(new Error('wasm unavailable'));
await expect(pFetch('https://example.com')).rejects.toThrow('wasm unavailable');
expect(mockClearEpoxyClientCache).not.toHaveBeenCalled();
});
});
describe('pFetch api call logging', () => {
it('logs the response status on success', async () => {
await pFetch('https://example.com/api', { method: 'PUT' });
expect(logRequest).toHaveBeenCalledTimes(1);
expect(logRequest).toHaveBeenCalledWith(expect.objectContaining({
service: 'network',
operation: 'pFetch',
params: { url: 'https://example.com/api', method: 'PUT' },
result: { status: 204, statusText: 'No Content' },
}));
});
it('logs the message and stack on failure', async () => {
const failure = new Error('socket closed');
mockClientFetch.mockRejectedValue(failure);
await expect(pFetch('https://example.com/api')).rejects.toThrow();
const [entry] = logRequest.mock.calls[0];
expect(entry.error.message).toBe('socket closed');
expect(entry.error.stack).toBe(failure.stack);
});
it('stringifies a non-Error rejection reason', async () => {
mockClientFetch.mockRejectedValue('just a string');
await expect(pFetch('https://example.com/api')).rejects.toBe('just a string');
const [entry] = logRequest.mock.calls[0];
expect(entry.error.message).toBe('just a string');
expect(entry.error.stack).toBeUndefined();
});
it('stays quiet while logging is disabled', async () => {
globalThis.puter.apiCallLogger.isEnabled = () => false;
await pFetch('https://example.com/api');
expect(logRequest).not.toHaveBeenCalled();
});
it('does not require a puter runtime to be present', async () => {
globalThis.puter = undefined;
await expect(pFetch('https://example.com/api')).resolves.toBe(RESPONSE);
});
describe('request description', () => {
it('reads a string url and defaults the method to GET', async () => {
await pFetch('https://example.com/plain');
const [entry] = logRequest.mock.calls[0];
expect(entry.params).toEqual({ url: 'https://example.com/plain', method: 'GET' });
});
it('serialises a URL instance', async () => {
await pFetch(new URL('https://example.com/from-url'));
const [entry] = logRequest.mock.calls[0];
expect(entry.params.url).toBe('https://example.com/from-url');
});
it('reads url and method off a Request-like object', async () => {
await pFetch({ url: 'https://example.com/req', method: 'DELETE' });
const [entry] = logRequest.mock.calls[0];
expect(entry.params).toEqual({ url: 'https://example.com/req', method: 'DELETE' });
});
// An explicit init overrides the method carried by the request object.
it('prefers the init method over the request object method', async () => {
await pFetch({ url: 'https://example.com/req', method: 'DELETE' }, { method: 'PATCH' });
const [entry] = logRequest.mock.calls[0];
expect(entry.params.method).toBe('PATCH');
});
it('records an undefined url for an unrecognised resource', async () => {
await pFetch(42);
const [entry] = logRequest.mock.calls[0];
expect(entry.params).toEqual({ url: undefined, method: 'GET' });
});
});
});