524 lines
15 KiB
JavaScript
524 lines
15 KiB
JavaScript
import { validateSchema, extractDeterministic } from '../../lib/extract.js';
|
|
|
|
function makeRefs(entries) {
|
|
return new Map(entries);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Simulate the POST /tabs/:tabId/extract handler logic without a real server.
|
|
// This mirrors the exact decision tree in server.js so we can unit-test every
|
|
// HTTP-level status code path.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Minimal replica of the extract route handler.
|
|
* Accepts the same {userId, schema} body plus a sessions Map and tabId.
|
|
* Returns { status, body } matching what the endpoint would send.
|
|
*/
|
|
function simulateExtractHandler({ tabId, body, sessions }) {
|
|
const { userId, schema } = body || {};
|
|
if (!userId) return { status: 400, body: { error: 'userId is required' } };
|
|
if (!schema) return { status: 400, body: { error: 'schema is required' } };
|
|
|
|
const check = validateSchema(schema);
|
|
if (!check.ok) return { status: 400, body: { error: check.error } };
|
|
|
|
const normalizedId = String(userId);
|
|
const session = sessions.get(normalizedId);
|
|
// findTab replica: search tabGroups for matching tabId
|
|
let tabState = null;
|
|
if (session) {
|
|
for (const [, group] of session.tabGroups) {
|
|
const ts = group.get(tabId);
|
|
if (ts) { tabState = ts; break; }
|
|
}
|
|
}
|
|
if (!tabState) return { status: 404, body: { error: 'Tab not found' } };
|
|
|
|
session.lastAccess = Date.now();
|
|
tabState.toolCalls++;
|
|
tabState.consecutiveTimeouts = 0;
|
|
|
|
if (!tabState.refs || tabState.refs.size === 0) {
|
|
return {
|
|
status: 409,
|
|
body: {
|
|
error: 'no refs available -- call GET /tabs/:tabId/snapshot first to build the ref table',
|
|
snapshot: tabState.lastSnapshot || null,
|
|
},
|
|
};
|
|
}
|
|
|
|
try {
|
|
const data = extractDeterministic({ schema, refs: tabState.refs });
|
|
return { status: 200, body: { ok: true, data } };
|
|
} catch (extractErr) {
|
|
return {
|
|
status: 422,
|
|
body: { ok: false, error: extractErr.message, snapshot: tabState.lastSnapshot || null },
|
|
};
|
|
}
|
|
}
|
|
|
|
/** Build a sessions Map containing one user + one tab for testing. */
|
|
function buildSessions({ userId = 'u1', tabId = 't1', refs = null, lastSnapshot = null } = {}) {
|
|
const tabState = {
|
|
refs,
|
|
lastSnapshot,
|
|
toolCalls: 0,
|
|
consecutiveTimeouts: 0,
|
|
};
|
|
const tabGroup = new Map([[tabId, tabState]]);
|
|
const session = {
|
|
tabGroups: new Map([['default', tabGroup]]),
|
|
lastAccess: 0,
|
|
};
|
|
return { sessions: new Map([[userId, session]]), tabState };
|
|
}
|
|
|
|
describe('validateSchema', () => {
|
|
test('rejects missing schema', () => {
|
|
expect(validateSchema(null).ok).toBe(false);
|
|
expect(validateSchema(undefined).ok).toBe(false);
|
|
expect(validateSchema('nope').ok).toBe(false);
|
|
});
|
|
|
|
test('rejects non-object root', () => {
|
|
expect(validateSchema({ type: 'string' }).ok).toBe(false);
|
|
expect(validateSchema({ type: 'array' }).ok).toBe(false);
|
|
});
|
|
|
|
test('rejects missing properties', () => {
|
|
expect(validateSchema({ type: 'object' }).ok).toBe(false);
|
|
expect(validateSchema({ type: 'object', properties: null }).ok).toBe(false);
|
|
});
|
|
|
|
test('rejects unsupported property types', () => {
|
|
const r = validateSchema({ type: 'object', properties: { x: { type: 'nope' } } });
|
|
expect(r.ok).toBe(false);
|
|
expect(r.error).toMatch(/nope/);
|
|
});
|
|
|
|
test('accepts well-formed schema', () => {
|
|
expect(validateSchema({
|
|
type: 'object',
|
|
properties: {
|
|
title: { type: 'string', 'x-ref': 'e1' },
|
|
count: { type: 'integer', 'x-ref': 'e2' },
|
|
},
|
|
required: ['title'],
|
|
}).ok).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('extractDeterministic', () => {
|
|
const refs = makeRefs([
|
|
['e1', { role: 'heading', name: 'Example Domain', nth: 0 }],
|
|
['e2', { role: 'link', name: 'Learn more', nth: 0 }],
|
|
['e3', { role: 'button', name: 'Submit', nth: 0 }],
|
|
['e4', { role: 'text', name: ' 42 ', nth: 0 }],
|
|
]);
|
|
|
|
test('pulls name from refs by x-ref', () => {
|
|
const data = extractDeterministic({
|
|
schema: {
|
|
type: 'object',
|
|
properties: { title: { type: 'string', 'x-ref': 'e1' } },
|
|
},
|
|
refs,
|
|
});
|
|
expect(data).toEqual({ title: 'Example Domain' });
|
|
});
|
|
|
|
test('coerces strings to integers', () => {
|
|
const data = extractDeterministic({
|
|
schema: {
|
|
type: 'object',
|
|
properties: { count: { type: 'integer', 'x-ref': 'e4' } },
|
|
},
|
|
refs,
|
|
});
|
|
expect(data).toEqual({ count: 42 });
|
|
});
|
|
|
|
test('coerces strings to numbers', () => {
|
|
const numRefs = makeRefs([['e1', { role: 'text', name: '$19.99', nth: 0 }]]);
|
|
const data = extractDeterministic({
|
|
schema: {
|
|
type: 'object',
|
|
properties: { price: { type: 'number', 'x-ref': 'e1' } },
|
|
},
|
|
refs: numRefs,
|
|
});
|
|
expect(data).toEqual({ price: 19.99 });
|
|
});
|
|
|
|
test('throws on missing required ref', () => {
|
|
expect(() => extractDeterministic({
|
|
schema: {
|
|
type: 'object',
|
|
properties: { missing: { type: 'string', 'x-ref': 'e999' } },
|
|
required: ['missing'],
|
|
},
|
|
refs,
|
|
})).toThrow(/required/);
|
|
});
|
|
|
|
test('returns null for unresolved optional property', () => {
|
|
const data = extractDeterministic({
|
|
schema: {
|
|
type: 'object',
|
|
properties: { maybe: { type: 'string', 'x-ref': 'e999' } },
|
|
},
|
|
refs,
|
|
});
|
|
expect(data).toEqual({ maybe: null });
|
|
});
|
|
|
|
test('throws on invalid schema before extraction', () => {
|
|
expect(() => extractDeterministic({
|
|
schema: { type: 'array' },
|
|
refs,
|
|
})).toThrow(/type: object/);
|
|
});
|
|
|
|
test('handles empty refs map', () => {
|
|
const data = extractDeterministic({
|
|
schema: {
|
|
type: 'object',
|
|
properties: { x: { type: 'string', 'x-ref': 'e1' } },
|
|
},
|
|
refs: new Map(),
|
|
});
|
|
expect(data).toEqual({ x: null });
|
|
});
|
|
|
|
test('boolean coercion handles common representations', () => {
|
|
const booleanRefs = makeRefs([
|
|
['e1', { role: 'text', name: 'true', nth: 0 }],
|
|
['e2', { role: 'text', name: 'FALSE', nth: 0 }],
|
|
['e3', { role: 'text', name: 'yes', nth: 0 }],
|
|
['e4', { role: 'text', name: 'maybe', nth: 0 }],
|
|
]);
|
|
const data = extractDeterministic({
|
|
schema: {
|
|
type: 'object',
|
|
properties: {
|
|
a: { type: 'boolean', 'x-ref': 'e1' },
|
|
b: { type: 'boolean', 'x-ref': 'e2' },
|
|
c: { type: 'boolean', 'x-ref': 'e3' },
|
|
d: { type: 'boolean', 'x-ref': 'e4' },
|
|
},
|
|
},
|
|
refs: booleanRefs,
|
|
});
|
|
expect(data).toEqual({ a: true, b: false, c: true, d: null });
|
|
});
|
|
|
|
test('returns multiple refs in one call', () => {
|
|
const data = extractDeterministic({
|
|
schema: {
|
|
type: 'object',
|
|
properties: {
|
|
title: { type: 'string', 'x-ref': 'e1' },
|
|
linkText: { type: 'string', 'x-ref': 'e2' },
|
|
buttonLabel: { type: 'string', 'x-ref': 'e3' },
|
|
},
|
|
required: ['title'],
|
|
},
|
|
refs,
|
|
});
|
|
expect(data).toEqual({
|
|
title: 'Example Domain',
|
|
linkText: 'Learn more',
|
|
buttonLabel: 'Submit',
|
|
});
|
|
});
|
|
});
|
|
|
|
// ===========================================================================
|
|
// Endpoint handler simulation tests
|
|
// These exercise the same logic as POST /tabs/:tabId/extract without a server.
|
|
// ===========================================================================
|
|
|
|
describe('POST /tabs/:tabId/extract (handler logic)', () => {
|
|
const PAGE_REFS = makeRefs([
|
|
['e1', { role: 'heading', name: 'Example Domain', nth: 0 }],
|
|
['e2', { role: 'link', name: 'Learn more', nth: 0 }],
|
|
['e3', { role: 'button', name: 'Submit', nth: 0 }],
|
|
['e4', { role: 'text', name: ' 42 ', nth: 0 }],
|
|
['e5', { role: 'text', name: '$19.99', nth: 0 }],
|
|
['e6', { role: 'text', name: 'true', nth: 0 }],
|
|
]);
|
|
|
|
// --- 200: successful extraction -------------------------------------------
|
|
|
|
test('200: extracts multiple properties with mixed types', () => {
|
|
const { sessions } = buildSessions({ refs: PAGE_REFS });
|
|
const { status, body } = simulateExtractHandler({
|
|
tabId: 't1',
|
|
body: {
|
|
userId: 'u1',
|
|
schema: {
|
|
type: 'object',
|
|
properties: {
|
|
title: { type: 'string', 'x-ref': 'e1' },
|
|
link: { type: 'string', 'x-ref': 'e2' },
|
|
count: { type: 'integer', 'x-ref': 'e4' },
|
|
price: { type: 'number', 'x-ref': 'e5' },
|
|
flag: { type: 'boolean', 'x-ref': 'e6' },
|
|
},
|
|
required: ['title'],
|
|
},
|
|
},
|
|
sessions,
|
|
});
|
|
|
|
expect(status).toBe(200);
|
|
expect(body.ok).toBe(true);
|
|
expect(body.data).toEqual({
|
|
title: 'Example Domain',
|
|
link: 'Learn more',
|
|
count: 42,
|
|
price: 19.99,
|
|
flag: true,
|
|
});
|
|
});
|
|
|
|
test('200: optional missing ref resolves to null', () => {
|
|
const { sessions } = buildSessions({ refs: PAGE_REFS });
|
|
const { status, body } = simulateExtractHandler({
|
|
tabId: 't1',
|
|
body: {
|
|
userId: 'u1',
|
|
schema: {
|
|
type: 'object',
|
|
properties: {
|
|
present: { type: 'string', 'x-ref': 'e1' },
|
|
absent: { type: 'string', 'x-ref': 'e9999' },
|
|
},
|
|
},
|
|
},
|
|
sessions,
|
|
});
|
|
|
|
expect(status).toBe(200);
|
|
expect(body.data.present).toBe('Example Domain');
|
|
expect(body.data.absent).toBeNull();
|
|
});
|
|
|
|
test('200: increments toolCalls on success', () => {
|
|
const { sessions, tabState } = buildSessions({ refs: PAGE_REFS });
|
|
expect(tabState.toolCalls).toBe(0);
|
|
|
|
simulateExtractHandler({
|
|
tabId: 't1',
|
|
body: {
|
|
userId: 'u1',
|
|
schema: {
|
|
type: 'object',
|
|
properties: { x: { type: 'string', 'x-ref': 'e1' } },
|
|
},
|
|
},
|
|
sessions,
|
|
});
|
|
|
|
expect(tabState.toolCalls).toBe(1);
|
|
});
|
|
|
|
// --- 400: bad requests ----------------------------------------------------
|
|
|
|
test('400: missing userId', () => {
|
|
const { sessions } = buildSessions({ refs: PAGE_REFS });
|
|
const { status, body } = simulateExtractHandler({
|
|
tabId: 't1',
|
|
body: { schema: { type: 'object', properties: {} } },
|
|
sessions,
|
|
});
|
|
|
|
expect(status).toBe(400);
|
|
expect(body.error).toMatch(/userId/i);
|
|
});
|
|
|
|
test('400: missing schema', () => {
|
|
const { sessions } = buildSessions({ refs: PAGE_REFS });
|
|
const { status, body } = simulateExtractHandler({
|
|
tabId: 't1',
|
|
body: { userId: 'u1' },
|
|
sessions,
|
|
});
|
|
|
|
expect(status).toBe(400);
|
|
expect(body.error).toMatch(/schema/i);
|
|
});
|
|
|
|
test('400: schema with unsupported type', () => {
|
|
const { sessions } = buildSessions({ refs: PAGE_REFS });
|
|
const { status, body } = simulateExtractHandler({
|
|
tabId: 't1',
|
|
body: {
|
|
userId: 'u1',
|
|
schema: { type: 'object', properties: { x: { type: 'foobar' } } },
|
|
},
|
|
sessions,
|
|
});
|
|
|
|
expect(status).toBe(400);
|
|
expect(body.error).toMatch(/foobar/);
|
|
});
|
|
|
|
test('400: schema root type is not object', () => {
|
|
const { sessions } = buildSessions({ refs: PAGE_REFS });
|
|
const { status, body } = simulateExtractHandler({
|
|
tabId: 't1',
|
|
body: {
|
|
userId: 'u1',
|
|
schema: { type: 'array', items: { type: 'string' } },
|
|
},
|
|
sessions,
|
|
});
|
|
|
|
expect(status).toBe(400);
|
|
expect(body.error).toMatch(/object/i);
|
|
});
|
|
|
|
test('400: schema missing properties key', () => {
|
|
const { sessions } = buildSessions({ refs: PAGE_REFS });
|
|
const { status, body } = simulateExtractHandler({
|
|
tabId: 't1',
|
|
body: {
|
|
userId: 'u1',
|
|
schema: { type: 'object' },
|
|
},
|
|
sessions,
|
|
});
|
|
|
|
expect(status).toBe(400);
|
|
expect(body.error).toMatch(/properties/i);
|
|
});
|
|
|
|
test('400: empty body', () => {
|
|
const { sessions } = buildSessions({ refs: PAGE_REFS });
|
|
const { status, body } = simulateExtractHandler({
|
|
tabId: 't1',
|
|
body: {},
|
|
sessions,
|
|
});
|
|
|
|
expect(status).toBe(400);
|
|
expect(body.error).toMatch(/userId/i);
|
|
});
|
|
|
|
// --- 404: tab not found ---------------------------------------------------
|
|
|
|
test('404: nonexistent tab', () => {
|
|
const { sessions } = buildSessions({ refs: PAGE_REFS });
|
|
const { status, body } = simulateExtractHandler({
|
|
tabId: 'does-not-exist',
|
|
body: {
|
|
userId: 'u1',
|
|
schema: { type: 'object', properties: { x: { type: 'string', 'x-ref': 'e1' } } },
|
|
},
|
|
sessions,
|
|
});
|
|
|
|
expect(status).toBe(404);
|
|
expect(body.error).toMatch(/not found/i);
|
|
});
|
|
|
|
test('404: valid tab but wrong userId', () => {
|
|
const { sessions } = buildSessions({ refs: PAGE_REFS });
|
|
const { status, body } = simulateExtractHandler({
|
|
tabId: 't1',
|
|
body: {
|
|
userId: 'wrong-user',
|
|
schema: { type: 'object', properties: { x: { type: 'string', 'x-ref': 'e1' } } },
|
|
},
|
|
sessions,
|
|
});
|
|
|
|
expect(status).toBe(404);
|
|
expect(body.error).toMatch(/not found/i);
|
|
});
|
|
|
|
// --- 409: no refs (snapshot not called) ------------------------------------
|
|
|
|
test('409: refs is null (snapshot never called)', () => {
|
|
const { sessions } = buildSessions({ refs: null, lastSnapshot: null });
|
|
const { status, body } = simulateExtractHandler({
|
|
tabId: 't1',
|
|
body: {
|
|
userId: 'u1',
|
|
schema: { type: 'object', properties: { x: { type: 'string', 'x-ref': 'e1' } } },
|
|
},
|
|
sessions,
|
|
});
|
|
|
|
expect(status).toBe(409);
|
|
expect(body.error).toMatch(/refs/i);
|
|
expect(body.snapshot).toBeNull();
|
|
});
|
|
|
|
test('409: refs is empty Map', () => {
|
|
const { sessions } = buildSessions({ refs: new Map(), lastSnapshot: 'some old snapshot' });
|
|
const { status, body } = simulateExtractHandler({
|
|
tabId: 't1',
|
|
body: {
|
|
userId: 'u1',
|
|
schema: { type: 'object', properties: { x: { type: 'string', 'x-ref': 'e1' } } },
|
|
},
|
|
sessions,
|
|
});
|
|
|
|
expect(status).toBe(409);
|
|
expect(body.error).toMatch(/refs/i);
|
|
expect(body.snapshot).toBe('some old snapshot');
|
|
});
|
|
|
|
// --- 422: extraction failure (required ref missing) -----------------------
|
|
|
|
test('422: required property ref not in ref table', () => {
|
|
const { sessions } = buildSessions({ refs: PAGE_REFS, lastSnapshot: '[heading e1] Example' });
|
|
const { status, body } = simulateExtractHandler({
|
|
tabId: 't1',
|
|
body: {
|
|
userId: 'u1',
|
|
schema: {
|
|
type: 'object',
|
|
properties: { missing: { type: 'string', 'x-ref': 'e9999' } },
|
|
required: ['missing'],
|
|
},
|
|
},
|
|
sessions,
|
|
});
|
|
|
|
expect(status).toBe(422);
|
|
expect(body.ok).toBe(false);
|
|
expect(body.error).toMatch(/required/);
|
|
expect(body.snapshot).toBe('[heading e1] Example');
|
|
});
|
|
|
|
test('422: multiple required, second one missing', () => {
|
|
const { sessions } = buildSessions({ refs: PAGE_REFS });
|
|
const { status, body } = simulateExtractHandler({
|
|
tabId: 't1',
|
|
body: {
|
|
userId: 'u1',
|
|
schema: {
|
|
type: 'object',
|
|
properties: {
|
|
title: { type: 'string', 'x-ref': 'e1' },
|
|
phantom: { type: 'string', 'x-ref': 'e8888' },
|
|
},
|
|
required: ['title', 'phantom'],
|
|
},
|
|
},
|
|
sessions,
|
|
});
|
|
|
|
expect(status).toBe(422);
|
|
expect(body.ok).toBe(false);
|
|
expect(body.error).toMatch(/phantom/);
|
|
});
|
|
});
|