Files

192 lines
7.7 KiB
JavaScript
Raw Permalink Normal View History

import { describe, expect, test } from 'bun:test';
import { BrowserControlError, createBrowserControlBroker } from './broker.js';
const createBroker = (options = {}) => {
const emitted = [];
let sequence = 0;
const broker = createBrowserControlBroker({
emitRequest: (payload) => {
emitted.push(payload);
return options.listeners ?? 1;
},
createId: () => {
sequence += 1;
return `req-${sequence}`;
},
...options.overrides,
});
return { broker, emitted };
};
describe('browser control broker', () => {
test('resolves with the data the client posted back', async () => {
const { broker, emitted } = createBroker();
const inflight = broker.request('browser.snapshot', {});
expect(emitted[0]?.action).toBe('browser.snapshot');
broker.resolve(emitted[0].requestId, { ok: true, data: { url: 'http://localhost:5173/' } });
expect(await inflight).toEqual({ url: 'http://localhost:5173/' });
});
test('fails fast when no client is connected instead of blocking', async () => {
const { broker } = createBroker({ listeners: 0 });
await expect(broker.request('browser.open', { url: 'http://a/' })).rejects.toThrow(BrowserControlError);
});
test('describes the environment rather than telling the agent what to do', async () => {
const { broker } = createBroker({ listeners: 0 });
try {
await broker.request('browser.snapshot', {});
throw new Error('expected rejection');
} catch (error) {
expect(error.status).toBe(503);
// The agent reads this, not the user: it must state the limitation and
// where the capability exists, without issuing an instruction the agent
// cannot carry out.
expect(error.message).toContain('desktop application');
expect(error.message).toContain('Nothing was changed');
expect(error.message).not.toContain('Ask the user to open');
}
});
test('surfaces a client-reported failure with its message', async () => {
const { broker, emitted } = createBroker();
const inflight = broker.request('browser.click', { selector: '#missing' });
broker.resolve(emitted[0].requestId, { ok: false, error: 'No element matches #missing' });
await expect(inflight).rejects.toThrow('No element matches #missing');
});
test('times out when the client accepted the request and never answered', async () => {
let fire = null;
const { broker } = createBroker({
overrides: {
setTimer: (callback) => { fire = callback; return 1; },
clearTimer: () => {},
},
});
const inflight = broker.request('browser.snapshot', {}, { timeoutMs: 5_000 });
fire();
await expect(inflight).rejects.toThrow('did not respond within 5s');
});
test('ignores a late response that lost the race with the timeout', async () => {
let fire = null;
const { broker, emitted } = createBroker({
overrides: {
setTimer: (callback) => { fire = callback; return 1; },
clearTimer: () => {},
},
});
const inflight = broker.request('browser.snapshot', {});
fire();
await expect(inflight).rejects.toThrow();
expect(broker.resolve(emitted[0].requestId, { ok: true, data: {} })).toBe(false);
});
test('rejects an unknown request id without throwing', () => {
const { broker } = createBroker();
expect(broker.resolve('nope', { ok: true })).toBe(false);
expect(broker.resolve('', { ok: true })).toBe(false);
});
test('clears pending state once a request settles', async () => {
const { broker, emitted } = createBroker();
const inflight = broker.request('browser.snapshot', {});
expect(broker.pendingCount).toBe(1);
broker.resolve(emitted[0].requestId, { ok: true, data: null });
await inflight;
expect(broker.pendingCount).toBe(0);
});
test('fails everything in flight when the owning client disconnects', async () => {
const { broker } = createBroker();
const inflight = broker.request('browser.snapshot', {});
broker.rejectAll('The OpenChamber client disconnected');
await expect(inflight).rejects.toThrow('disconnected');
expect(broker.pendingCount).toBe(0);
});
test('propagates cancellation from the caller', async () => {
const { broker } = createBroker();
const controller = new AbortController();
const inflight = broker.request('browser.snapshot', {}, { signal: controller.signal });
controller.abort();
await expect(inflight).rejects.toThrow('cancelled');
});
test('rejects immediately when the caller is already cancelled', async () => {
const { broker } = createBroker();
const controller = new AbortController();
controller.abort();
await expect(broker.request('browser.snapshot', {}, { signal: controller.signal })).rejects.toThrow('cancelled');
});
});
/**
* Whether a page can be driven depends on which client is connected, not on the
* server: a desktop shell and a browser tab can be attached to one server at
* once, and either may arrive or leave at any moment. The broker is told how
* many clients could actually perform each action.
*/
describe('client capability', () => {
const createCapabilityBroker = (capableFor) => {
const emitted = [];
let sequence = 0;
const broker = createBrowserControlBroker({
emitRequest: (payload) => {
emitted.push(payload);
return capableFor(payload.action);
},
createId: () => { sequence += 1; return `req-${sequence}`; },
});
return { broker, emitted };
};
test('opening a page works with a client that cannot drive one', async () => {
// A browser tab can display a page even though it cannot be controlled.
const { broker, emitted } = createCapabilityBroker((action) => (action === 'browser.open' ? 1 : 0));
const inflight = broker.request('browser.open', { url: 'http://localhost:3000/' });
broker.resolve(emitted[0].requestId, { ok: true, data: { opened: true } });
expect(await inflight).toEqual({ opened: true });
});
test('driving a page fails immediately when no client can', async () => {
const { broker } = createCapabilityBroker((action) => (action === 'browser.open' ? 1 : 0));
await expect(broker.request('browser.click', { selector: '#a' })).rejects.toThrow('desktop application');
});
test('driving a page works as soon as a capable client is connected', async () => {
// No restart, no setting: a desktop client attaching is enough.
const { broker, emitted } = createCapabilityBroker(() => 1);
const inflight = broker.request('browser.snapshot', {});
broker.resolve(emitted[0].requestId, { ok: true, data: { url: 'http://localhost:3000/' } });
expect(await inflight).toEqual({ url: 'http://localhost:3000/' });
});
});
describe('one request, one performer', () => {
test('grants the request to the first claimant and refuses the rest', async () => {
const broker = createBrowserControlBroker({ emitRequest: () => 2, createId: () => 'req-1' });
const pending = broker.request('browser.click', { selector: 'button' });
expect(broker.claim('req-1')).toBe(true);
// A second desktop client is told no, so it never clicks.
expect(broker.claim('req-1')).toBe(false);
broker.resolve('req-1', { ok: true, data: { clicked: true } });
await expect(pending).resolves.toEqual({ clicked: true });
});
test('refuses a claim for a request that is already over', () => {
const broker = createBrowserControlBroker({ emitRequest: () => 1, createId: () => 'req-1' });
const pending = broker.request('browser.click', {});
broker.resolve('req-1', { ok: true, data: null });
void pending.catch(() => undefined);
// Acting now would change a page nobody is waiting on.
expect(broker.claim('req-1')).toBe(false);
expect(broker.claim('unknown')).toBe(false);
});
});