192 lines
7.7 KiB
JavaScript
192 lines
7.7 KiB
JavaScript
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);
|
||
|
|
});
|
||
|
|
});
|