feat: add Makelore Robot hardware module
This commit is contained in:
323
tests/contract/ai-hardware-works-square.contract.test.ts
Normal file
323
tests/contract/ai-hardware-works-square.contract.test.ts
Normal file
@@ -0,0 +1,323 @@
|
||||
import { EventEmitter } from 'node:events';
|
||||
import type { IncomingMessage, ServerResponse } from 'node:http';
|
||||
import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process';
|
||||
import { join } from 'node:path';
|
||||
import { createInterface } from 'node:readline';
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import { createAiHardwareRouteHandler } from '@electron/api/routes/ai-hardware';
|
||||
|
||||
type ContractServer = { process: ChildProcessWithoutNullStreams; baseUrl: string };
|
||||
|
||||
const OPERATION = {
|
||||
createDesk: '00000000-0000-4000-8000-000000000001',
|
||||
bindDevice: '00000000-0000-4000-8000-000000000002',
|
||||
updateAgent: '00000000-0000-4000-8000-000000000003',
|
||||
updateAssignment: '00000000-0000-4000-8000-000000000004',
|
||||
conflict: '00000000-0000-4000-8000-000000000005',
|
||||
recoverCredential: '00000000-0000-4000-8000-000000000006',
|
||||
recoverCredentialAgain: '00000000-0000-4000-8000-000000000007',
|
||||
inProgress: '00000000-0000-4000-8000-000000000009',
|
||||
} as const;
|
||||
|
||||
function localRequest(method: string, body?: unknown): IncomingMessage {
|
||||
const req = new EventEmitter();
|
||||
const raw = body === undefined ? undefined : JSON.stringify(body);
|
||||
Object.assign(req, {
|
||||
method,
|
||||
headers: raw === undefined ? {} : { 'content-length': String(Buffer.byteLength(raw)) },
|
||||
[Symbol.asyncIterator]: async function* () {
|
||||
if (raw !== undefined) yield Buffer.from(raw);
|
||||
},
|
||||
});
|
||||
return req as IncomingMessage;
|
||||
}
|
||||
|
||||
function localResponse() {
|
||||
const chunks: string[] = [];
|
||||
const res = new EventEmitter();
|
||||
Object.assign(res, {
|
||||
statusCode: 0,
|
||||
setHeader: vi.fn(),
|
||||
end: vi.fn((chunk?: string) => { if (chunk) chunks.push(chunk); }),
|
||||
});
|
||||
return {
|
||||
res: res as unknown as ServerResponse,
|
||||
json: () => JSON.parse(chunks.join('')) as Record<string, unknown>,
|
||||
};
|
||||
}
|
||||
|
||||
async function invoke(
|
||||
handler: ReturnType<typeof createAiHardwareRouteHandler>,
|
||||
method: string,
|
||||
path: string,
|
||||
body?: unknown,
|
||||
) {
|
||||
const target = localResponse();
|
||||
const handled = await handler(
|
||||
localRequest(method, body),
|
||||
target.res,
|
||||
new URL(`http://127.0.0.1${path}`),
|
||||
{} as never,
|
||||
);
|
||||
expect(handled).toBe(true);
|
||||
return target.json();
|
||||
}
|
||||
|
||||
async function startServer(disabled = false): Promise<ContractServer> {
|
||||
const serverDir = process.env.WORKS_SQUARE_SERVER_DIR;
|
||||
if (!serverDir) throw new Error('WORKS_SQUARE_SERVER_DIR is required');
|
||||
const script = join(serverDir, 'tests', 'contract', 'ai_hardware_contract_server.py');
|
||||
const executable = process.env.WORKS_SQUARE_PYTHON ?? 'uv';
|
||||
const args = process.env.WORKS_SQUARE_PYTHON
|
||||
? [script]
|
||||
: ['run', '--frozen', 'python', script];
|
||||
if (disabled) args.push('--disabled');
|
||||
const child = spawn(executable, args, {
|
||||
cwd: serverDir,
|
||||
env: {
|
||||
...process.env,
|
||||
PYTHONPATH: [serverDir, process.env.PYTHONPATH].filter(Boolean).join(process.platform === 'win32' ? ';' : ':'),
|
||||
PYTHONUNBUFFERED: '1',
|
||||
},
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
windowsHide: true,
|
||||
});
|
||||
let stderr = '';
|
||||
child.stderr.setEncoding('utf8');
|
||||
child.stderr.on('data', (chunk: string) => { stderr += chunk; });
|
||||
const lines = createInterface({ input: child.stdout });
|
||||
return await new Promise<ContractServer>((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
child.kill();
|
||||
reject(new Error(`contract server readiness timed out\n${stderr}`));
|
||||
}, 15_000);
|
||||
child.once('error', (error) => {
|
||||
clearTimeout(timer);
|
||||
reject(error);
|
||||
});
|
||||
child.once('exit', (code) => {
|
||||
clearTimeout(timer);
|
||||
reject(new Error(`contract server exited before ready (${code})\n${stderr}`));
|
||||
});
|
||||
lines.once('line', (line) => {
|
||||
clearTimeout(timer);
|
||||
try {
|
||||
const ready = JSON.parse(line) as { base_url?: unknown };
|
||||
if (typeof ready.base_url !== 'string') throw new Error('missing base_url');
|
||||
resolve({ process: child, baseUrl: ready.base_url });
|
||||
} catch (error) {
|
||||
child.kill();
|
||||
reject(new Error(`invalid contract server handshake: ${line}`, { cause: error }));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function stopServer(server: ContractServer): Promise<void> {
|
||||
if (server.process.exitCode !== null) return;
|
||||
const exited = new Promise<void>((resolve) => server.process.once('exit', () => resolve()));
|
||||
server.process.stdin.end();
|
||||
const forced = setTimeout(() => server.process.kill(), 10_000);
|
||||
await exited;
|
||||
clearTimeout(forced);
|
||||
}
|
||||
|
||||
describe('Makelore Main to Works Square AI hardware wire contract', () => {
|
||||
let server: ContractServer | undefined;
|
||||
let handler: ReturnType<typeof createAiHardwareRouteHandler>;
|
||||
|
||||
beforeAll(async () => {
|
||||
server = await startServer();
|
||||
handler = createAiHardwareRouteHandler({
|
||||
apiBaseUrl: server.baseUrl,
|
||||
fetchImpl: globalThis.fetch,
|
||||
getAccessToken: async () => 'contract-token',
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (server) await stopServer(server);
|
||||
});
|
||||
|
||||
it('uses the real authenticated overview and create-agent HTTP routes', async () => {
|
||||
expect(await invoke(handler, 'GET', '/api/works/ai-hardware')).toEqual({
|
||||
success: true,
|
||||
data: { status: 'unprovisioned', agents: [], devices: [] },
|
||||
});
|
||||
const createBody = { agent_name: 'Desk', client_operation_id: OPERATION.createDesk };
|
||||
const created = await invoke(handler, 'POST', '/api/works/ai-hardware/agents', createBody);
|
||||
expect(created).toEqual({
|
||||
success: true,
|
||||
data: { id: 'agent-1', name: 'Desk', config_revision: 0 },
|
||||
});
|
||||
expect(await invoke(handler, 'POST', '/api/works/ai-hardware/agents', createBody)).toEqual(created);
|
||||
expect(await invoke(handler, 'GET', '/api/works/ai-hardware')).toEqual({
|
||||
success: true,
|
||||
data: {
|
||||
status: 'active',
|
||||
agents: [{ id: 'agent-1', name: 'Desk', config_revision: 0 }],
|
||||
devices: [],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('binds a device and preserves GET/PATCH configuration ETags as numeric revisions', async () => {
|
||||
const bindBody = {
|
||||
activation_code: '123456', agent_id: 'agent-1', client_operation_id: OPERATION.bindDevice,
|
||||
};
|
||||
const bound = await invoke(handler, 'POST', '/api/works/ai-hardware/device-bindings', bindBody);
|
||||
expect(bound).toEqual({
|
||||
success: true,
|
||||
data: { id: 'device-1', agent_id: 'agent-1', assignment_revision: 0 },
|
||||
});
|
||||
expect(await invoke(handler, 'POST', '/api/works/ai-hardware/device-bindings', bindBody)).toEqual(bound);
|
||||
const read = await invoke(handler, 'GET', '/api/works/ai-hardware/agents/agent-1');
|
||||
expect(read).toMatchObject({ success: true, revision: 0, data: { id: 'agent-1', config_revision: 0 } });
|
||||
const updateBody = {
|
||||
revision: 0, agent_name: 'Workshop', system_prompt: 'Build carefully',
|
||||
client_operation_id: OPERATION.updateAgent,
|
||||
};
|
||||
const updated = await invoke(handler, 'PATCH', '/api/works/ai-hardware/agents/agent-1', updateBody);
|
||||
expect(updated).toMatchObject({
|
||||
success: true,
|
||||
revision: 1,
|
||||
data: { id: 'agent-1', name: 'Workshop', config_revision: 1, system_prompt: 'Build carefully' },
|
||||
});
|
||||
expect(await invoke(handler, 'PATCH', '/api/works/ai-hardware/agents/agent-1', updateBody)).toEqual(updated);
|
||||
});
|
||||
|
||||
it('preserves assignment revisions and safe 409 Retry-After metadata', async () => {
|
||||
expect(await invoke(handler, 'GET', '/api/works/ai-hardware/devices/device-1/agent-assignment')).toEqual({
|
||||
success: true,
|
||||
revision: 0,
|
||||
data: { id: 'device-1', agent_id: 'agent-1', assignment_revision: 0 },
|
||||
});
|
||||
expect(await invoke(handler, 'PUT', '/api/works/ai-hardware/devices/device-1/agent-assignment', {
|
||||
revision: 9, agent_id: 'agent-1', client_operation_id: OPERATION.conflict,
|
||||
})).toEqual({
|
||||
success: false,
|
||||
status: 409,
|
||||
code: 'ai_hardware_revision_conflict',
|
||||
error: 'AI hardware data changed; refresh and retry',
|
||||
retryable: true,
|
||||
retry_after_seconds: 2,
|
||||
operation_id: OPERATION.conflict,
|
||||
});
|
||||
const assignmentBody = {
|
||||
revision: 0, agent_id: 'agent-1', client_operation_id: OPERATION.updateAssignment,
|
||||
};
|
||||
const assigned = await invoke(
|
||||
handler, 'PUT', '/api/works/ai-hardware/devices/device-1/agent-assignment', assignmentBody,
|
||||
);
|
||||
expect(assigned).toEqual({
|
||||
success: true,
|
||||
revision: 1,
|
||||
data: { id: 'device-1', agent_id: 'agent-1', assignment_revision: 1 },
|
||||
});
|
||||
expect(await invoke(
|
||||
handler, 'PUT', '/api/works/ai-hardware/devices/device-1/agent-assignment', assignmentBody,
|
||||
)).toEqual(assigned);
|
||||
});
|
||||
|
||||
it('rejects reuse of one operation id with a different request body', async () => {
|
||||
expect(await invoke(handler, 'POST', '/api/works/ai-hardware/agents', {
|
||||
agent_name: 'Conflict original', client_operation_id: OPERATION.conflict,
|
||||
})).toMatchObject({ success: true, data: { id: 'agent-2', name: 'Conflict original', config_revision: 0 } });
|
||||
expect(await invoke(handler, 'POST', '/api/works/ai-hardware/agents', {
|
||||
agent_name: 'Conflict changed', client_operation_id: OPERATION.conflict,
|
||||
})).toEqual({
|
||||
success: false,
|
||||
status: 409,
|
||||
code: 'ai_hardware_idempotency_conflict',
|
||||
error: 'AI hardware request conflicts with an earlier operation',
|
||||
retryable: false,
|
||||
operation_id: OPERATION.conflict,
|
||||
});
|
||||
const overview = await invoke(handler, 'GET', '/api/works/ai-hardware');
|
||||
expect(overview).toMatchObject({
|
||||
success: true,
|
||||
data: { agents: [
|
||||
{ id: 'agent-1', name: 'Workshop', config_revision: 1 },
|
||||
{ id: 'agent-2', name: 'Conflict original', config_revision: 0 },
|
||||
] },
|
||||
});
|
||||
});
|
||||
|
||||
it('retries operation-in-progress once with the identical key and body', async () => {
|
||||
expect(await invoke(handler, 'POST', '/api/works/ai-hardware/agents', {
|
||||
agent_name: 'Retried', client_operation_id: OPERATION.inProgress,
|
||||
})).toEqual({
|
||||
success: true,
|
||||
data: { id: 'agent-3', name: 'Retried', config_revision: 0 },
|
||||
});
|
||||
const overview = await invoke(handler, 'GET', '/api/works/ai-hardware');
|
||||
expect(overview).toMatchObject({
|
||||
success: true,
|
||||
data: { agents: [
|
||||
{ id: 'agent-1' },
|
||||
{ id: 'agent-2' },
|
||||
{ id: 'agent-3', name: 'Retried', config_revision: 0 },
|
||||
] },
|
||||
});
|
||||
});
|
||||
|
||||
it('recovers credentials without an upstream body and replays safely', async () => {
|
||||
const recoveryBody = { client_operation_id: OPERATION.recoverCredential };
|
||||
const recovered = await invoke(
|
||||
handler, 'POST', '/api/works/ai-hardware/credential-recovery', recoveryBody,
|
||||
);
|
||||
expect(recovered).toEqual({
|
||||
success: true,
|
||||
data: {
|
||||
status: 'active',
|
||||
agents: [
|
||||
{ id: 'agent-1', name: 'Workshop', config_revision: 1 },
|
||||
{ id: 'agent-2', name: 'Conflict original', config_revision: 0 },
|
||||
{ id: 'agent-3', name: 'Retried', config_revision: 0 },
|
||||
],
|
||||
devices: [{ id: 'device-1', agent_id: 'agent-1', assignment_revision: 1 }],
|
||||
},
|
||||
});
|
||||
expect(await invoke(
|
||||
handler, 'POST', '/api/works/ai-hardware/credential-recovery', recoveryBody,
|
||||
)).toEqual(recovered);
|
||||
expect(await invoke(handler, 'POST', '/api/works/ai-hardware/credential-recovery', {
|
||||
client_operation_id: OPERATION.recoverCredentialAgain,
|
||||
})).toEqual(recovered);
|
||||
});
|
||||
|
||||
it('maps a real FastAPI Bearer rejection to a safe local auth envelope', async () => {
|
||||
const unauthorized = createAiHardwareRouteHandler({
|
||||
apiBaseUrl: server!.baseUrl,
|
||||
fetchImpl: globalThis.fetch,
|
||||
getAccessToken: async () => 'wrong-token',
|
||||
});
|
||||
expect(await invoke(unauthorized, 'GET', '/api/works/ai-hardware')).toEqual({
|
||||
success: false,
|
||||
status: 401,
|
||||
code: 'AI_HARDWARE_AUTH_REQUIRED',
|
||||
error: 'Works Square sign-in is required',
|
||||
retryable: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('maps a real disabled FastAPI route to the product disabled state', async () => {
|
||||
const disabled = await startServer(true);
|
||||
try {
|
||||
const disabledHandler = createAiHardwareRouteHandler({
|
||||
apiBaseUrl: disabled.baseUrl,
|
||||
fetchImpl: globalThis.fetch,
|
||||
getAccessToken: async () => 'contract-token',
|
||||
});
|
||||
expect(await invoke(disabledHandler, 'GET', '/api/works/ai-hardware')).toEqual({
|
||||
success: false,
|
||||
status: 404,
|
||||
code: 'AI_HARDWARE_DISABLED',
|
||||
error: 'AI hardware module is not enabled',
|
||||
retryable: false,
|
||||
});
|
||||
} finally {
|
||||
await stopServer(disabled);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user