@moxxy/plugin-computer-control 0.41.2 → 0.42.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/win32-x64/moxxy-computer.exe +0 -0
- package/bin/win32-x64/moxxy-computer.exe.json +1 -1
- package/dist/backend/access.d.ts +129 -0
- package/dist/backend/access.d.ts.map +1 -0
- package/dist/backend/access.js +158 -0
- package/dist/backend/access.js.map +1 -0
- package/dist/backend/app-hints.d.ts +14 -0
- package/dist/backend/app-hints.d.ts.map +1 -0
- package/dist/backend/app-hints.js +30 -0
- package/dist/backend/app-hints.js.map +1 -0
- package/dist/backend/backend.d.ts +70 -0
- package/dist/backend/backend.d.ts.map +1 -0
- package/dist/backend/backend.js +466 -0
- package/dist/backend/backend.js.map +1 -0
- package/dist/backend/rpc.d.ts +1438 -0
- package/dist/backend/rpc.d.ts.map +1 -0
- package/dist/backend/rpc.js +75 -0
- package/dist/backend/rpc.js.map +1 -0
- package/dist/backend/turn-controls.d.ts +20 -0
- package/dist/backend/turn-controls.d.ts.map +1 -0
- package/dist/backend/turn-controls.js +115 -0
- package/dist/backend/turn-controls.js.map +1 -0
- package/dist/contract/guidance.d.ts +12 -0
- package/dist/contract/guidance.d.ts.map +1 -0
- package/dist/contract/guidance.js +42 -0
- package/dist/contract/guidance.js.map +1 -0
- package/dist/contract/image.d.ts +29 -0
- package/dist/contract/image.d.ts.map +1 -0
- package/dist/contract/image.js +46 -0
- package/dist/contract/image.js.map +1 -0
- package/dist/contract/keys.d.ts +15 -0
- package/dist/contract/keys.d.ts.map +1 -0
- package/dist/contract/keys.js +118 -0
- package/dist/contract/keys.js.map +1 -0
- package/dist/contract/outcome.d.ts +61 -0
- package/dist/contract/outcome.d.ts.map +1 -0
- package/dist/contract/outcome.js +63 -0
- package/dist/contract/outcome.js.map +1 -0
- package/dist/contract/progress.d.ts +27 -0
- package/dist/contract/progress.d.ts.map +1 -0
- package/dist/contract/progress.js +36 -0
- package/dist/contract/progress.js.map +1 -0
- package/dist/contract/tools.d.ts +338 -0
- package/dist/contract/tools.d.ts.map +1 -0
- package/dist/contract/tools.js +195 -0
- package/dist/contract/tools.js.map +1 -0
- package/dist/contract/untrusted.d.ts +3 -0
- package/dist/contract/untrusted.d.ts.map +1 -0
- package/dist/contract/untrusted.js +8 -0
- package/dist/contract/untrusted.js.map +1 -0
- package/dist/helper/artifact.d.ts +41 -0
- package/dist/helper/artifact.d.ts.map +1 -0
- package/dist/helper/artifact.js +189 -0
- package/dist/helper/artifact.js.map +1 -0
- package/dist/helper/protocol.d.ts +80 -0
- package/dist/helper/protocol.d.ts.map +1 -0
- package/dist/helper/protocol.js +49 -0
- package/dist/helper/protocol.js.map +1 -0
- package/dist/helper/transport.d.ts +43 -0
- package/dist/helper/transport.d.ts.map +1 -0
- package/dist/{windows → helper}/transport.js +45 -19
- package/dist/helper/transport.js.map +1 -0
- package/dist/index.d.ts +9 -22
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +35 -42
- package/dist/index.js.map +1 -1
- package/dist/jev/ladder.d.ts +32 -0
- package/dist/jev/ladder.d.ts.map +1 -0
- package/dist/jev/ladder.js +66 -0
- package/dist/jev/ladder.js.map +1 -0
- package/dist/jev/memory.d.ts +11 -0
- package/dist/jev/memory.d.ts.map +1 -0
- package/dist/jev/memory.js +16 -0
- package/dist/jev/memory.js.map +1 -0
- package/dist/jev/run.d.ts +93 -0
- package/dist/jev/run.d.ts.map +1 -0
- package/dist/jev/run.js +403 -0
- package/dist/jev/run.js.map +1 -0
- package/dist/jev/train.d.ts +97 -0
- package/dist/jev/train.d.ts.map +1 -0
- package/dist/jev/train.js +28 -0
- package/dist/jev/train.js.map +1 -0
- package/dist/linux/profile.d.ts +6 -0
- package/dist/linux/profile.d.ts.map +1 -0
- package/dist/linux/profile.js +21 -0
- package/dist/linux/profile.js.map +1 -0
- package/dist/macos/profile.d.ts +5 -0
- package/dist/macos/profile.d.ts.map +1 -0
- package/dist/macos/profile.js +17 -0
- package/dist/macos/profile.js.map +1 -0
- package/dist/preview/controller.d.ts +127 -0
- package/dist/preview/controller.d.ts.map +1 -0
- package/dist/preview/controller.js +203 -0
- package/dist/preview/controller.js.map +1 -0
- package/dist/preview/surface.d.ts +9 -0
- package/dist/preview/surface.d.ts.map +1 -0
- package/dist/preview/surface.js +50 -0
- package/dist/preview/surface.js.map +1 -0
- package/dist/windows/maintenance.d.ts +1 -1
- package/dist/windows/maintenance.d.ts.map +1 -1
- package/dist/windows/maintenance.js +6 -5
- package/dist/windows/maintenance.js.map +1 -1
- package/dist/windows/profile.d.ts +5 -0
- package/dist/windows/profile.d.ts.map +1 -0
- package/dist/windows/profile.js +17 -0
- package/dist/windows/profile.js.map +1 -0
- package/learned/README.md +26 -0
- package/learned/cases/com.apple.calculator.json +395 -0
- package/learned/cases/com.apple.finder.json +115 -0
- package/learned/cases/com.apple.safari.json +112 -0
- package/learned/cases/com.apple.systempreferences.json +255 -0
- package/learned/com.apple.calculator-eace95fc.json +334 -0
- package/learned/com.apple.finder-27cf6ce8.json +260 -0
- package/learned/com.apple.safari-7cd9df4f.json +143 -0
- package/learned/com.apple.systempreferences-02cf0b8b.json +535 -0
- package/package.json +13 -7
- package/scripts/promote-learned.mjs +8 -0
- package/scripts/summarize-trial.mjs +68 -0
- package/scripts/train-learned.mjs +64 -0
- package/skills/computer-apps/blender.md +29 -0
- package/skills/computer-apps/browsers.md +50 -0
- package/skills/computer-apps/design-tools.md +37 -0
- package/skills/computer-apps/finder.md +23 -0
- package/skills/computer-apps/office.md +46 -0
- package/skills/computer-apps/video-editors.md +44 -0
- package/skills/computer-control.md +154 -191
- package/src/backend/access.test.ts +158 -0
- package/src/backend/access.ts +168 -0
- package/src/backend/app-hints.test.ts +59 -0
- package/src/backend/app-hints.ts +39 -0
- package/src/backend/backend.test.ts +725 -0
- package/src/backend/backend.ts +482 -0
- package/src/backend/contract-helper.fixture.mjs +138 -0
- package/src/backend/helper.fixture.ts +38 -0
- package/src/backend/rpc.ts +90 -0
- package/src/backend/turn-controls.test.ts +178 -0
- package/src/backend/turn-controls.ts +120 -0
- package/src/contract/guidance.test.ts +75 -0
- package/src/contract/guidance.ts +48 -0
- package/src/contract/image.test.ts +74 -0
- package/src/contract/image.ts +54 -0
- package/src/contract/keys.test.ts +103 -0
- package/src/contract/keys.ts +117 -0
- package/src/contract/outcome.test.ts +71 -0
- package/src/contract/outcome.ts +72 -0
- package/src/contract/progress.test.ts +56 -0
- package/src/contract/progress.ts +43 -0
- package/src/contract/tools.test.ts +210 -0
- package/src/contract/tools.ts +212 -0
- package/src/contract/untrusted.test.ts +20 -0
- package/src/contract/untrusted.ts +8 -0
- package/src/helper/artifact.test.ts +131 -0
- package/src/helper/artifact.ts +182 -0
- package/src/helper/protocol.test.ts +29 -0
- package/src/helper/protocol.ts +50 -0
- package/src/{windows → helper}/transport.test.ts +76 -19
- package/src/{windows → helper}/transport.ts +54 -16
- package/src/index.test.ts +112 -0
- package/src/index.ts +36 -53
- package/src/jev/ladder.test.ts +113 -0
- package/src/jev/ladder.ts +88 -0
- package/src/jev/memory.ts +20 -0
- package/src/jev/run.test.ts +700 -0
- package/src/jev/run.ts +443 -0
- package/src/jev/train.test.ts +40 -0
- package/src/jev/train.ts +41 -0
- package/src/linux/helper.test.ts +481 -0
- package/src/linux/profile.ts +25 -0
- package/src/macos/helper.test.ts +988 -0
- package/src/macos/profile.ts +19 -0
- package/src/preview/controller.test.ts +308 -0
- package/src/preview/controller.ts +262 -0
- package/src/preview/surface.test.ts +68 -0
- package/src/preview/surface.ts +45 -0
- package/src/skill.test.ts +34 -0
- package/src/windows/maintenance.ts +8 -7
- package/src/windows/profile.ts +19 -0
- package/dist/shell.d.ts +0 -56
- package/dist/shell.d.ts.map +0 -1
- package/dist/shell.js +0 -189
- package/dist/shell.js.map +0 -1
- package/dist/temporary-files.d.ts +0 -2
- package/dist/temporary-files.d.ts.map +0 -1
- package/dist/temporary-files.js +0 -10
- package/dist/temporary-files.js.map +0 -1
- package/dist/tools/applescript.d.ts +0 -2
- package/dist/tools/applescript.d.ts.map +0 -1
- package/dist/tools/applescript.js +0 -50
- package/dist/tools/applescript.js.map +0 -1
- package/dist/tools/click.d.ts +0 -2
- package/dist/tools/click.d.ts.map +0 -1
- package/dist/tools/click.js +0 -58
- package/dist/tools/click.js.map +0 -1
- package/dist/tools/clipboard.d.ts +0 -2
- package/dist/tools/clipboard.d.ts.map +0 -1
- package/dist/tools/clipboard.js +0 -71
- package/dist/tools/clipboard.js.map +0 -1
- package/dist/tools/key.d.ts +0 -11
- package/dist/tools/key.d.ts.map +0 -1
- package/dist/tools/key.js +0 -143
- package/dist/tools/key.js.map +0 -1
- package/dist/tools/open.d.ts +0 -2
- package/dist/tools/open.d.ts.map +0 -1
- package/dist/tools/open.js +0 -91
- package/dist/tools/open.js.map +0 -1
- package/dist/tools/screenshot.d.ts +0 -2
- package/dist/tools/screenshot.d.ts.map +0 -1
- package/dist/tools/screenshot.js +0 -162
- package/dist/tools/screenshot.js.map +0 -1
- package/dist/tools/type.d.ts +0 -8
- package/dist/tools/type.d.ts.map +0 -1
- package/dist/tools/type.js +0 -65
- package/dist/tools/type.js.map +0 -1
- package/dist/windows/artifact.d.ts +0 -3
- package/dist/windows/artifact.d.ts.map +0 -1
- package/dist/windows/artifact.js +0 -57
- package/dist/windows/artifact.js.map +0 -1
- package/dist/windows/backend.d.ts +0 -11
- package/dist/windows/backend.d.ts.map +0 -1
- package/dist/windows/backend.js +0 -122
- package/dist/windows/backend.js.map +0 -1
- package/dist/windows/contracts.d.ts +0 -1627
- package/dist/windows/contracts.d.ts.map +0 -1
- package/dist/windows/contracts.js +0 -137
- package/dist/windows/contracts.js.map +0 -1
- package/dist/windows/control-service.d.ts +0 -12
- package/dist/windows/control-service.d.ts.map +0 -1
- package/dist/windows/control-service.js +0 -65
- package/dist/windows/control-service.js.map +0 -1
- package/dist/windows/guidance.d.ts +0 -3
- package/dist/windows/guidance.d.ts.map +0 -1
- package/dist/windows/guidance.js +0 -20
- package/dist/windows/guidance.js.map +0 -1
- package/dist/windows/protocol.d.ts +0 -9
- package/dist/windows/protocol.d.ts.map +0 -1
- package/dist/windows/protocol.js +0 -32
- package/dist/windows/protocol.js.map +0 -1
- package/dist/windows/transport.d.ts +0 -23
- package/dist/windows/transport.d.ts.map +0 -1
- package/dist/windows/transport.js.map +0 -1
- package/src/shell.test.ts +0 -186
- package/src/shell.ts +0 -213
- package/src/temporary-files.test.ts +0 -18
- package/src/temporary-files.ts +0 -9
- package/src/tools/applescript-serialize.test.ts +0 -74
- package/src/tools/applescript.ts +0 -53
- package/src/tools/click.ts +0 -60
- package/src/tools/clipboard.ts +0 -72
- package/src/tools/key.ts +0 -155
- package/src/tools/open.ts +0 -96
- package/src/tools/screenshot.test.ts +0 -137
- package/src/tools/screenshot.ts +0 -180
- package/src/tools/type.ts +0 -68
- package/src/tools.test.ts +0 -94
- package/src/windows/action-contracts.test.ts +0 -13
- package/src/windows/artifact.test.ts +0 -16
- package/src/windows/artifact.ts +0 -57
- package/src/windows/backend.test.ts +0 -41
- package/src/windows/backend.ts +0 -122
- package/src/windows/contracts.test.ts +0 -81
- package/src/windows/contracts.ts +0 -143
- package/src/windows/control-service.test.ts +0 -58
- package/src/windows/control-service.ts +0 -68
- package/src/windows/guidance.test.ts +0 -29
- package/src/windows/guidance.ts +0 -21
- package/src/windows/model-contract.test.ts +0 -37
- package/src/windows/protocol.ts +0 -27
- package/src/windows/text-contracts.test.ts +0 -14
- package/src/windows/window-typing.test.ts +0 -14
|
@@ -0,0 +1,481 @@
|
|
|
1
|
+
import { spawn, spawnSync, type ChildProcess } from 'node:child_process';
|
|
2
|
+
import { existsSync, mkdirSync, mkdtempSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { once } from 'node:events';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
|
8
|
+
import { imageBudget } from '../contract/image.js';
|
|
9
|
+
import { verifyHelperArtifact } from '../helper/artifact.js';
|
|
10
|
+
import { HelperTransport, type HelperEvent } from '../helper/transport.js';
|
|
11
|
+
import { CONTRACT_PROTOCOL_VERSION, actResultSchema, appStateSchema, contractEventsFor, imageSchema, listAppsResultSchema, resolveAppsResultSchema, statusResultSchema, type AppState } from '../backend/rpc.js';
|
|
12
|
+
import { REQUEST_ACCESS_TOOL } from '../backend/access.js';
|
|
13
|
+
import { ComputerBackend } from '../backend/backend.js';
|
|
14
|
+
import { memoryLog, toolContext } from '../backend/helper.fixture.js';
|
|
15
|
+
import type { MoxxyEvent, ToolImageResult } from '@moxxy/sdk';
|
|
16
|
+
import { computerTools } from '../contract/tools.js';
|
|
17
|
+
import { createComputerControlPlugin } from '../index.js';
|
|
18
|
+
import { linuxHelperPath, linuxProfile, type LinuxArchitecture } from './profile.js';
|
|
19
|
+
|
|
20
|
+
// Drives the real helper built by native/linux/build.sh inside an X11 desktop (native/linux/desktop.sh);
|
|
21
|
+
// other hosts, unbuilt trees and sessions without a display skip.
|
|
22
|
+
const arch = process.arch as LinuxArchitecture;
|
|
23
|
+
const helper = process.platform === 'linux' && (arch === 'x64' || arch === 'arm64') ? linuxHelperPath(arch) : '';
|
|
24
|
+
const built = helper !== '' && existsSync(helper) && Boolean(process.env.DISPLAY);
|
|
25
|
+
const fixtureBinary = fileURLToPath(new URL(`../../native/linux/build/${arch}/moxxy-computer-fixture`, import.meta.url));
|
|
26
|
+
const fixtureBuilt = built && existsSync(fixtureBinary);
|
|
27
|
+
const FIXTURE = 'ai.moxxy.computer-fixture';
|
|
28
|
+
|
|
29
|
+
const signal = () => new AbortController().signal;
|
|
30
|
+
const start = (onEvent?: (event: HelperEvent) => void) => new HelperTransport(helper, ['--parent', String(process.pid)], {
|
|
31
|
+
protocolVersion: CONTRACT_PROTOCOL_VERSION, events: contractEventsFor(CONTRACT_PROTOCOL_VERSION), timeoutMs: 30_000, ...(onEvent ? { onEvent } : {}),
|
|
32
|
+
});
|
|
33
|
+
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
34
|
+
async function until(done: () => boolean, ms: number): Promise<boolean> {
|
|
35
|
+
const deadline = Date.now() + ms;
|
|
36
|
+
while (!done() && Date.now() < deadline) await sleep(50);
|
|
37
|
+
return done();
|
|
38
|
+
}
|
|
39
|
+
function timeout(ms: number): Promise<never> {
|
|
40
|
+
return new Promise((_, reject) => setTimeout(() => reject(new Error(`no exit within ${ms} ms`)), ms).unref());
|
|
41
|
+
}
|
|
42
|
+
function stop(child: ChildProcess): void {
|
|
43
|
+
if (child.exitCode === null && child.signalCode === null) child.kill('SIGKILL');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
describe.skipIf(!built)('Linux native helper', () => {
|
|
47
|
+
it('is a verified ELF artifact for the shared protocol', async () => {
|
|
48
|
+
await expect(verifyHelperArtifact(helper, CONTRACT_PROTOCOL_VERSION)).resolves.toBeUndefined();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('reports a ready X11 desktop with the accessibility bus', async () => {
|
|
52
|
+
const transport = start();
|
|
53
|
+
try {
|
|
54
|
+
const status = statusResultSchema.parse(await transport.request('status', {}, signal()));
|
|
55
|
+
expect(status).toEqual({ ready: true, permissions: { accessibility: true, screenRecording: true }, limitations: [] });
|
|
56
|
+
} finally { await transport.close(); }
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('says why it cannot work in a Wayland session', async () => {
|
|
60
|
+
process.env.WAYLAND_DISPLAY = 'wayland-0';
|
|
61
|
+
const transport = start();
|
|
62
|
+
delete process.env.WAYLAND_DISPLAY;
|
|
63
|
+
try {
|
|
64
|
+
const status = statusResultSchema.parse(await transport.request('status', {}, signal()));
|
|
65
|
+
expect(status.ready).toBe(false);
|
|
66
|
+
expect(status.limitations.join(' ')).toContain('Wayland');
|
|
67
|
+
} finally { await transport.close(); }
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('is what the plugin runs on Linux: the shared tools, with a status answered by the helper', async () => {
|
|
71
|
+
const plugin = createComputerControlPlugin();
|
|
72
|
+
expect((plugin.tools ?? []).map((tool) => tool.name).sort()).toEqual(Object.keys(computerTools).sort());
|
|
73
|
+
const status = plugin.tools?.find((tool) => tool.name === 'computer_status');
|
|
74
|
+
try {
|
|
75
|
+
const report = await status?.handler({}, toolContext(memoryLog([]), { sessionId: 'plugin-status' })) as { platform: string; ready: boolean };
|
|
76
|
+
expect(report).toMatchObject({ platform: 'linux', ready: true });
|
|
77
|
+
} finally {
|
|
78
|
+
await plugin.hooks?.onShutdown?.({ sessionId: 'plugin-status' } as never);
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('refuses an unknown method with a code and keeps serving', async () => {
|
|
83
|
+
const transport = start();
|
|
84
|
+
try {
|
|
85
|
+
await expect(transport.request('teleport', {}, signal())).rejects.toMatchObject({ code: 'unsupported_action' });
|
|
86
|
+
await expect(transport.request('status', {}, signal())).resolves.toBeTypeOf('object');
|
|
87
|
+
} finally { await transport.close(); }
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('exits as soon as its input closes', async () => {
|
|
91
|
+
const transport = start();
|
|
92
|
+
await transport.request('status', {}, signal());
|
|
93
|
+
const started = performance.now();
|
|
94
|
+
await transport.close();
|
|
95
|
+
expect(performance.now() - started).toBeLessThan(450);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('exits when the process it serves dies', async () => {
|
|
99
|
+
const parent = spawn('/bin/sleep', ['30']);
|
|
100
|
+
const child = spawn(helper, ['--parent', String(parent.pid)], { stdio: ['pipe', 'ignore', 'ignore'] });
|
|
101
|
+
try {
|
|
102
|
+
const exited = once(child, 'exit');
|
|
103
|
+
await sleep(300);
|
|
104
|
+
parent.kill('SIGKILL');
|
|
105
|
+
const [code] = await Promise.race([exited, timeout(2000)]);
|
|
106
|
+
expect(code).toBe(0);
|
|
107
|
+
} finally { stop(child); stop(parent); }
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('refuses to start without the process it serves', async () => {
|
|
111
|
+
const child = spawn(helper, [], { stdio: ['pipe', 'ignore', 'ignore'] });
|
|
112
|
+
try {
|
|
113
|
+
const [code] = await Promise.race([once(child, 'exit'), timeout(2000)]);
|
|
114
|
+
expect(code).toBe(64);
|
|
115
|
+
} finally { stop(child); }
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
const quitFixture = async () => {
|
|
120
|
+
spawnSync('pkill', ['-f', 'moxxy-computer-fixture']);
|
|
121
|
+
for (let attempt = 0; attempt < 50 && spawnSync('pgrep', ['-f', 'moxxy-computer-fixture']).status === 0; attempt += 1) await sleep(100);
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
type Element = AppState['tree']['elements'][number];
|
|
125
|
+
const named = (state: AppState, role: string, title: string): Element => {
|
|
126
|
+
const element = state.tree.elements.find((candidate) => candidate.role === role && candidate.title === title);
|
|
127
|
+
if (!element) throw new Error(`no ${role} "${title}" in:\n${state.tree.elements.map((candidate) => `${candidate.role} ${candidate.title ?? ''} ${candidate.value ?? ''}`).join('\n')}`);
|
|
128
|
+
return element;
|
|
129
|
+
};
|
|
130
|
+
const says = (state: AppState, text: string) => state.tree.elements.some((element) => element.title === text);
|
|
131
|
+
const centre = (element: Element) => {
|
|
132
|
+
if (!element.frame) throw new Error(`${element.role} has no frame`);
|
|
133
|
+
return { x: Math.round(element.frame.x + element.frame.width / 2), y: Math.round(element.frame.y + element.frame.height / 2) };
|
|
134
|
+
};
|
|
135
|
+
const observe = async (transport: HelperTransport) =>
|
|
136
|
+
appStateSchema.parse(await transport.request('get_app_state', { app: FIXTURE, screenshot: true }, signal()));
|
|
137
|
+
const act = async (transport: HelperTransport, action: Record<string, unknown>) => {
|
|
138
|
+
const answer = actResultSchema.parse(await transport.request('act', { app: FIXTURE, action, allowed: [FIXTURE] }, signal()));
|
|
139
|
+
if (!answer.state) throw new Error('an action returns the fresh state');
|
|
140
|
+
return { result: answer.result, state: answer.state };
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
describe.skipIf(!fixtureBuilt)('Linux Computer Use on the fixture app', () => {
|
|
144
|
+
beforeAll(async () => {
|
|
145
|
+
// The fixture is installed for this run only, the way a desktop file installs any app.
|
|
146
|
+
const data = mkdtempSync(join(tmpdir(), 'moxxy-computer-'));
|
|
147
|
+
mkdirSync(join(data, 'applications'));
|
|
148
|
+
writeFileSync(join(data, 'applications', `${FIXTURE}.desktop`),
|
|
149
|
+
`[Desktop Entry]\nType=Application\nName=Moxxy Computer Fixture\nExec=${fixtureBinary}\n`);
|
|
150
|
+
process.env.XDG_DATA_HOME = data;
|
|
151
|
+
await quitFixture();
|
|
152
|
+
});
|
|
153
|
+
afterAll(quitFixture);
|
|
154
|
+
|
|
155
|
+
it('lists and resolves installed apps in the contract shape', async () => {
|
|
156
|
+
const transport = start();
|
|
157
|
+
try {
|
|
158
|
+
const listed = listAppsResultSchema.parse(await transport.request('list_apps', { query: 'moxxy computer', limit: 5 }, signal()));
|
|
159
|
+
expect(listed.apps).toEqual([{ id: FIXTURE, name: 'Moxxy Computer Fixture', running: false }]);
|
|
160
|
+
const resolved = resolveAppsResultSchema.parse(await transport.request('resolve_apps', { names: ['Moxxy Computer Fixture', 'No Such App 1234'] }, signal()));
|
|
161
|
+
expect(resolved.apps).toEqual([
|
|
162
|
+
{ request: 'Moxxy Computer Fixture', status: 'resolved', id: FIXTURE, name: 'Moxxy Computer Fixture' },
|
|
163
|
+
{ request: 'No Such App 1234', status: 'not_found' },
|
|
164
|
+
]);
|
|
165
|
+
await expect(transport.request('get_app_state', { app: 'no.such.app', screenshot: false }, signal())).rejects.toMatchObject({ code: 'app_not_found' });
|
|
166
|
+
} finally { await transport.close(); }
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('launches the app, waits for it to load and returns its indexed accessibility tree', async () => {
|
|
170
|
+
const transport = start();
|
|
171
|
+
try {
|
|
172
|
+
const state = appStateSchema.parse(await transport.request('get_app_state', { app: FIXTURE, screenshot: false }, signal()));
|
|
173
|
+
expect(state.tree).toMatchObject({ app: 'Moxxy Computer Fixture', window: 'Moxxy Fixture' });
|
|
174
|
+
expect(named(state, 'text', 'Name').value).toBe('hello');
|
|
175
|
+
expect(named(state, 'push button', 'Press')).toBeDefined();
|
|
176
|
+
expect(named(state, 'check box', 'Remember').states).toContain('checked');
|
|
177
|
+
expect(named(state, 'push button', 'Disabled').states).toContain('disabled');
|
|
178
|
+
expect(named(state, 'spin button', 'Amount').value).toBe('3');
|
|
179
|
+
const secret = named(state, 'password text', 'Secret');
|
|
180
|
+
expect(secret).toMatchObject({ secure: true });
|
|
181
|
+
expect(secret.value).toBeUndefined();
|
|
182
|
+
expect(JSON.stringify(state)).not.toContain('hunter2');
|
|
183
|
+
expect(says(state, 'Loaded')).toBe(true);
|
|
184
|
+
const listed = listAppsResultSchema.parse(await transport.request('list_apps', { query: 'moxxy computer', limit: 5 }, signal()));
|
|
185
|
+
expect(listed.apps[0]).toMatchObject({ id: FIXTURE, running: true });
|
|
186
|
+
} finally { await transport.close(); }
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it('keeps every element index across observations', async () => {
|
|
190
|
+
const transport = start();
|
|
191
|
+
try {
|
|
192
|
+
const first = await observe(transport);
|
|
193
|
+
const second = await observe(transport);
|
|
194
|
+
const indices = (state: AppState) => Object.fromEntries(state.tree.elements.map((element) => [element.key, element.index]));
|
|
195
|
+
expect(indices(second)).toEqual(indices(first));
|
|
196
|
+
} finally { await transport.close(); }
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it('captures the window within the image budget and places elements in its pixels', async () => {
|
|
200
|
+
const events: HelperEvent[] = [];
|
|
201
|
+
const transport = start((event) => events.push(event));
|
|
202
|
+
try {
|
|
203
|
+
const state = await observe(transport);
|
|
204
|
+
const shot = state.screenshot;
|
|
205
|
+
expect(shot?.mediaType).toBe('image/jpeg');
|
|
206
|
+
expect(Buffer.from(shot?.base64 ?? '', 'base64').subarray(0, 3)).toEqual(Buffer.from([0xff, 0xd8, 0xff]));
|
|
207
|
+
const width = shot?.width ?? 0;
|
|
208
|
+
const height = shot?.height ?? 0;
|
|
209
|
+
expect(imageBudget(width, height)).toEqual([width, height]);
|
|
210
|
+
const frame = named(state, 'push button', 'Press').frame ?? { x: -1, y: -1, width: 0, height: 0 };
|
|
211
|
+
expect(frame.x).toBeGreaterThanOrEqual(0);
|
|
212
|
+
expect(frame.x + frame.width).toBeLessThanOrEqual(width);
|
|
213
|
+
expect(frame.y + frame.height).toBeLessThanOrEqual(height);
|
|
214
|
+
expect(frame.width).toBeGreaterThan(10);
|
|
215
|
+
expect(events).toContainEqual({ version: CONTRACT_PROTOCOL_VERSION, event: 'cursor', cursor: { phase: 'idle', x: 0.5, y: 0.5 } });
|
|
216
|
+
} finally { await transport.close(); }
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it('refuses to act on an app that is not granted or was not observed', async () => {
|
|
220
|
+
const transport = start();
|
|
221
|
+
try {
|
|
222
|
+
const click = { action: 'click', element_index: 1, mouse_button: 'left', click_count: 1 };
|
|
223
|
+
await expect(transport.request('act', { app: FIXTURE, action: click, allowed: [] }, signal())).rejects.toMatchObject({ code: 'app_not_allowed' });
|
|
224
|
+
const answer = actResultSchema.parse(await transport.request('act', { app: FIXTURE, action: click, allowed: [FIXTURE] }, signal()));
|
|
225
|
+
expect(answer.result).toEqual({ outcome: 'blocked', code: 'no_state' });
|
|
226
|
+
} finally { await transport.close(); }
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it('presses a button through accessibility, shows the cursor at work and returns the new state', async () => {
|
|
230
|
+
const events: HelperEvent[] = [];
|
|
231
|
+
const transport = start((event) => { if (event.event === 'cursor') events.push(event); });
|
|
232
|
+
try {
|
|
233
|
+
const state = await observe(transport);
|
|
234
|
+
const pressed = await act(transport, { action: 'click', element_index: named(state, 'push button', 'Press').index, mouse_button: 'left', click_count: 1 });
|
|
235
|
+
expect(pressed.result).toEqual({ outcome: 'delivered', method: 'ax' });
|
|
236
|
+
expect(pressed.state.tree.elements.some((element) => element.title?.startsWith('Pressed '))).toBe(true);
|
|
237
|
+
const phases = events.map((event) => (event as { cursor: { phase: string } | null }).cursor?.phase);
|
|
238
|
+
expect(phases).toEqual(expect.arrayContaining(['moving', 'executing', 'delivered']));
|
|
239
|
+
} finally { await transport.close(); }
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
it('presses a control under a screenshot point through accessibility too', async () => {
|
|
243
|
+
const transport = start();
|
|
244
|
+
try {
|
|
245
|
+
const state = await observe(transport);
|
|
246
|
+
const before = state.tree.elements.find((element) => element.title?.startsWith('Pressed '))?.title;
|
|
247
|
+
const pressed = await act(transport, { action: 'click', ...centre(named(state, 'push button', 'Press')), mouse_button: 'left', click_count: 1 });
|
|
248
|
+
expect(pressed.result).toEqual({ outcome: 'delivered', method: 'ax' });
|
|
249
|
+
expect(pressed.state.tree.elements.find((element) => element.title?.startsWith('Pressed '))?.title).not.toBe(before);
|
|
250
|
+
} finally { await transport.close(); }
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it('types into a field and sets values without touching the keyboard', async () => {
|
|
254
|
+
const transport = start();
|
|
255
|
+
try {
|
|
256
|
+
const state = await observe(transport);
|
|
257
|
+
const typed = await act(transport, { action: 'type_text', element_index: named(state, 'text', 'Name').index, text: ' wörld' });
|
|
258
|
+
expect(typed.result).toEqual({ outcome: 'delivered', method: 'ax' });
|
|
259
|
+
expect(named(typed.state, 'text', 'Name').value).toContain(' wörld');
|
|
260
|
+
const set = await act(transport, { action: 'set_value', element_index: named(typed.state, 'text', 'Name').index, value: 'replaced' });
|
|
261
|
+
expect(set.result).toEqual({ outcome: 'delivered', method: 'ax' });
|
|
262
|
+
expect(named(set.state, 'text', 'Name').value).toBe('replaced');
|
|
263
|
+
const amount = await act(transport, { action: 'set_value', element_index: named(set.state, 'spin button', 'Amount').index, value: '7' });
|
|
264
|
+
expect(named(amount.state, 'spin button', 'Amount').value).toBe('7');
|
|
265
|
+
const refused = await act(transport, { action: 'type_text', element_index: named(amount.state, 'push button', 'Press').index, text: 'x' });
|
|
266
|
+
expect(refused.result).toMatchObject({ outcome: 'unsupported', code: 'unsupported_action' });
|
|
267
|
+
} finally { await transport.close(); }
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
it('types with real keys into whatever has focus, in any script', async () => {
|
|
271
|
+
const transport = start();
|
|
272
|
+
try {
|
|
273
|
+
const state = await observe(transport);
|
|
274
|
+
await act(transport, { action: 'set_value', element_index: named(state, 'text', 'Name').index, value: '' });
|
|
275
|
+
const field = centre(named(state, 'text', 'Name'));
|
|
276
|
+
await act(transport, { action: 'click', ...field, mouse_button: 'left', click_count: 1 });
|
|
277
|
+
const secret = await act(transport, { action: 'type_text', text: 'Zażółć 1+2' });
|
|
278
|
+
expect(named(secret.state, 'text', 'Name').value).toBe('Zażółć 1+2');
|
|
279
|
+
} finally { await transport.close(); }
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
it('clicks, scrolls and drags on a canvas that has no elements, and gives the pointer back', async () => {
|
|
283
|
+
const transport = start();
|
|
284
|
+
try {
|
|
285
|
+
const state = await observe(transport);
|
|
286
|
+
const pad = named(state, 'drawing area', 'Pad');
|
|
287
|
+
const point = centre(pad);
|
|
288
|
+
spawnSync('xdotool', ['mousemove', '5', '5']);
|
|
289
|
+
await sleep(600);
|
|
290
|
+
const clicked = await act(transport, { action: 'click', ...point, mouse_button: 'left', click_count: 1 });
|
|
291
|
+
expect(clicked.result).toEqual({ outcome: 'delivered', method: 'input' });
|
|
292
|
+
expect(says(clicked.state, 'click button 1')).toBe(true);
|
|
293
|
+
expect(spawnSync('xdotool', ['getmouselocation']).stdout.toString()).toContain('x:5 y:5');
|
|
294
|
+
const right = await act(transport, { action: 'click', ...point, mouse_button: 'right', click_count: 1, held: ['shift'] });
|
|
295
|
+
expect(says(right.state, 'click button 3 shift')).toBe(true);
|
|
296
|
+
const double = await act(transport, { action: 'click', ...point, mouse_button: 'left', click_count: 2 });
|
|
297
|
+
expect(says(double.state, 'double button 1')).toBe(true);
|
|
298
|
+
const scrolled = await act(transport, { action: 'scroll', ...point, direction: 'down', pages: 1 });
|
|
299
|
+
expect(scrolled.result).toEqual({ outcome: 'delivered', method: 'input' });
|
|
300
|
+
expect(scrolled.state.tree.elements.some((element) => element.title?.startsWith('scroll down '))).toBe(true);
|
|
301
|
+
const dragged = await act(transport, { action: 'drag', path: [[point.x - 100, point.y], [point.x + 100, point.y + 40]], duration_ms: 300, mouse_button: 'left' });
|
|
302
|
+
expect(dragged.result).toEqual({ outcome: 'delivered', method: 'input' });
|
|
303
|
+
expect(says(dragged.state, 'drag right down')).toBe(true);
|
|
304
|
+
} finally { await transport.close(); }
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
it('sends key chords to the app', async () => {
|
|
308
|
+
const transport = start();
|
|
309
|
+
try {
|
|
310
|
+
await observe(transport);
|
|
311
|
+
const pressed = await act(transport, { action: 'press_key', chord: { modifiers: ['ctrl'], key: 'k' }, repeat: 1 });
|
|
312
|
+
expect(pressed.result).toEqual({ outcome: 'delivered', method: 'input' });
|
|
313
|
+
expect(says(pressed.state, 'key ctrl+k')).toBe(true);
|
|
314
|
+
} finally { await transport.close(); }
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
it('gives a control that ignores an accessibility press a real click when asked again', async () => {
|
|
318
|
+
const transport = start();
|
|
319
|
+
try {
|
|
320
|
+
const state = await observe(transport);
|
|
321
|
+
const click = { action: 'click', element_index: named(state, 'push button', 'Stubborn 0').index, mouse_button: 'left', click_count: 1 };
|
|
322
|
+
const first = await act(transport, click);
|
|
323
|
+
expect(first.result).toEqual({ outcome: 'delivered', method: 'ax' });
|
|
324
|
+
expect(says(first.state, 'Stubborn 0')).toBe(true);
|
|
325
|
+
const second = await act(transport, click);
|
|
326
|
+
expect(second.result).toEqual({ outcome: 'delivered', method: 'input' });
|
|
327
|
+
expect(says(second.state, 'Stubborn 1')).toBe(true);
|
|
328
|
+
} finally { await transport.close(); }
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
it('refuses a point outside the screenshot and an index from an older state', async () => {
|
|
332
|
+
const transport = start();
|
|
333
|
+
try {
|
|
334
|
+
const state = await observe(transport);
|
|
335
|
+
const outside = await act(transport, { action: 'click', x: (state.screenshot?.width ?? 0) + 50, y: 10, mouse_button: 'left', click_count: 1 });
|
|
336
|
+
expect(outside.result).toMatchObject({ outcome: 'blocked', code: 'point_outside_frame' });
|
|
337
|
+
const stale = await act(transport, { action: 'click', element_index: 4999, mouse_button: 'left', click_count: 1 });
|
|
338
|
+
expect(stale.result).toMatchObject({ outcome: 'blocked', code: 'stale_state' });
|
|
339
|
+
} finally { await transport.close(); }
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
it('zooms into a region of the latest screenshot', async () => {
|
|
343
|
+
const transport = start();
|
|
344
|
+
try {
|
|
345
|
+
const state = await observe(transport);
|
|
346
|
+
const frame = named(state, 'push button', 'Press').frame ?? { x: 0, y: 0, width: 10, height: 10 };
|
|
347
|
+
const region = [Math.floor(frame.x), Math.floor(frame.y), Math.ceil(frame.x + frame.width), Math.ceil(frame.y + frame.height)];
|
|
348
|
+
const image = imageSchema.parse(await transport.request('zoom', { region, app: FIXTURE, allowed: [FIXTURE] }, signal()));
|
|
349
|
+
expect(image.width).toBeGreaterThan(10);
|
|
350
|
+
await expect(transport.request('zoom', { region: [0, 0, 9000, 9000], app: FIXTURE, allowed: [FIXTURE] }, signal())).rejects.toMatchObject({ code: 'point_outside_frame' });
|
|
351
|
+
} finally { await transport.close(); }
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
it('holds an action while the user has paused, then does nothing with it', async () => {
|
|
355
|
+
const states: string[] = [];
|
|
356
|
+
const transport = start((event) => { if (event.event === 'control_state') states.push(event.state); });
|
|
357
|
+
try {
|
|
358
|
+
const state = await observe(transport);
|
|
359
|
+
transport.control('pause');
|
|
360
|
+
await sleep(100);
|
|
361
|
+
const waiting = act(transport, { action: 'click', element_index: named(state, 'push button', 'Press').index, mouse_button: 'left', click_count: 1 });
|
|
362
|
+
expect(await until(() => states.includes('paused_by_user'), 2000)).toBe(true);
|
|
363
|
+
transport.control('resume');
|
|
364
|
+
expect((await waiting).result).toMatchObject({ outcome: 'blocked', code: 'user_intervened' });
|
|
365
|
+
expect(states).toEqual(['paused_by_user', 'recovering']);
|
|
366
|
+
} finally { await transport.close(); }
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
it('takes the cursor away when the user takes over and brings it back on resume', async () => {
|
|
370
|
+
const cursors: unknown[] = [];
|
|
371
|
+
const transport = start((event) => { if (event.event === 'cursor') cursors.push(event.cursor); });
|
|
372
|
+
try {
|
|
373
|
+
await observe(transport);
|
|
374
|
+
transport.control('takeover');
|
|
375
|
+
expect(await until(() => cursors.at(-1) === null, 2000)).toBe(true);
|
|
376
|
+
await observe(transport);
|
|
377
|
+
expect(cursors.at(-1)).toBeNull();
|
|
378
|
+
transport.control('resume');
|
|
379
|
+
expect(await until(() => cursors.at(-1) !== null, 2000)).toBe(true);
|
|
380
|
+
expect(cursors.at(-1)).toMatchObject({ phase: 'idle' });
|
|
381
|
+
} finally { await transport.close(); }
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
it("stops when the user presses Escape, and not for the agent's own Escape", async () => {
|
|
385
|
+
const child = spawn(helper, ['--parent', String(process.pid)], { stdio: ['pipe', 'pipe', 'ignore'] });
|
|
386
|
+
try {
|
|
387
|
+
const send = (frame: unknown) => child.stdin.write(`${JSON.stringify(frame)}\n`);
|
|
388
|
+
const lines: string[] = [];
|
|
389
|
+
child.stdout.on('data', (chunk: Buffer) => lines.push(...chunk.toString().split('\n').filter(Boolean)));
|
|
390
|
+
const answered = (id: string) => until(() => lines.some((line) => (JSON.parse(line) as { id?: string; ok?: boolean }).id === id), 15_000);
|
|
391
|
+
send({ version: CONTRACT_PROTOCOL_VERSION, id: 'a', method: 'get_app_state', params: { app: FIXTURE, screenshot: false } });
|
|
392
|
+
expect(await answered('a')).toBe(true);
|
|
393
|
+
send({ version: CONTRACT_PROTOCOL_VERSION, id: 'b', method: 'act', params: { app: FIXTURE, allowed: [FIXTURE], action: { action: 'press_key', chord: { modifiers: [], key: 'escape' }, repeat: 1 } } });
|
|
394
|
+
expect(await answered('b')).toBe(true);
|
|
395
|
+
expect(child.exitCode).toBeNull();
|
|
396
|
+
await sleep(400);
|
|
397
|
+
spawnSync('xdotool', ['key', 'Escape']);
|
|
398
|
+
const [code] = await Promise.race([once(child, 'exit'), timeout(3000)]);
|
|
399
|
+
expect(code).toBe(20);
|
|
400
|
+
} finally { stop(child); }
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
it('streams pictures of the window in use for the human, and stops', async () => {
|
|
404
|
+
const previews: Array<{ seq: number; image?: unknown; error?: string }> = [];
|
|
405
|
+
const transport = start((event) => { if (event.event === 'preview_frame') previews.push(event); });
|
|
406
|
+
try {
|
|
407
|
+
const state = await observe(transport);
|
|
408
|
+
await transport.request('preview.start', { fps: 5 }, signal());
|
|
409
|
+
const seen = () => JSON.stringify(previews.map((preview) => preview.error ?? (preview.image ? 'picture' : 'still')));
|
|
410
|
+
expect(await until(() => previews.some((preview) => preview.image), 5000), seen()).toBe(true);
|
|
411
|
+
const quiet = previews.length;
|
|
412
|
+
// A still window is not sent again; it only says it is still watched.
|
|
413
|
+
expect(await until(() => previews.slice(quiet).some((preview) => !preview.image), 4000)).toBe(true);
|
|
414
|
+
const pictures = previews.filter((preview) => preview.image).length;
|
|
415
|
+
await act(transport, { action: 'click', element_index: named(state, 'push button', 'Press').index, mouse_button: 'left', click_count: 1 });
|
|
416
|
+
expect(await until(() => previews.filter((preview) => preview.image).length > pictures, 3000)).toBe(true);
|
|
417
|
+
expect(previews.every((preview) => preview.error === undefined), seen()).toBe(true);
|
|
418
|
+
expect(previews.map((preview) => preview.seq)).toEqual(previews.map((_, index) => index));
|
|
419
|
+
await expect(transport.request('preview.start', { codec: 'h264' }, signal())).rejects.toMatchObject({ code: 'invalid_params' });
|
|
420
|
+
await transport.request('preview.stop', {}, signal());
|
|
421
|
+
const stopped = previews.length;
|
|
422
|
+
await sleep(600);
|
|
423
|
+
expect(previews.length).toBe(stopped);
|
|
424
|
+
} finally { await transport.close(); }
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
it('shows the window again after it was out of reach, and says so once', async () => {
|
|
428
|
+
const previews: Array<{ seq: number; image?: unknown; error?: string }> = [];
|
|
429
|
+
const transport = start((event) => { if (event.event === 'preview_frame') previews.push(event); });
|
|
430
|
+
const seen = () => JSON.stringify(previews.map((preview) => preview.error ?? (preview.image ? 'picture' : 'still')));
|
|
431
|
+
let window = '';
|
|
432
|
+
try {
|
|
433
|
+
await observe(transport);
|
|
434
|
+
window = spawnSync('xdotool', ['search', '--onlyvisible', '--name', '^Moxxy Fixture$']).stdout.toString().trim().split('\n')[0] ?? '';
|
|
435
|
+
expect(window).toMatch(/^\d+$/);
|
|
436
|
+
await transport.request('preview.start', { fps: 5 }, signal());
|
|
437
|
+
expect(await until(() => previews.some((preview) => preview.image), 5000), seen()).toBe(true);
|
|
438
|
+
// A minimised window has no pixels to capture until the person brings it back.
|
|
439
|
+
spawnSync('xdotool', ['windowminimize', window]);
|
|
440
|
+
expect(await until(() => previews.some((preview) => preview.error), 4000), seen()).toBe(true);
|
|
441
|
+
const gone = previews.length;
|
|
442
|
+
spawnSync('xdotool', ['windowactivate', window]);
|
|
443
|
+
expect(await until(() => previews.slice(gone).some((preview) => preview.image), 5000), seen()).toBe(true);
|
|
444
|
+
expect(previews.filter((preview) => preview.error).length, seen()).toBe(1);
|
|
445
|
+
} finally {
|
|
446
|
+
if (window) spawnSync('xdotool', ['windowactivate', window]);
|
|
447
|
+
await transport.close();
|
|
448
|
+
}
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
it('serves the model tools end to end through the shared backend', { timeout: 60_000 }, async () => {
|
|
452
|
+
const backend = new ComputerBackend(linuxProfile(arch));
|
|
453
|
+
const tools = new Map(backend.tools().map((tool) => [tool.name, tool]));
|
|
454
|
+
const events: MoxxyEvent[] = [];
|
|
455
|
+
const run = async (name: string, input: unknown, callId = 'c') => {
|
|
456
|
+
const tool = tools.get(name);
|
|
457
|
+
if (!tool) throw new Error(name);
|
|
458
|
+
return tool.handler(tool.inputSchema.parse(input), toolContext(memoryLog(events), { callId }));
|
|
459
|
+
};
|
|
460
|
+
try {
|
|
461
|
+
const request = { apps: ['Moxxy Computer Fixture'], reason: 'Integration test' };
|
|
462
|
+
const base = { id: 'e', seq: 0, ts: 0, sessionId: 'session', turnId: 'turn', source: 'system' };
|
|
463
|
+
events.push({ ...base, type: 'tool_call_requested', callId: 'grant', name: REQUEST_ACCESS_TOOL, input: request } as MoxxyEvent);
|
|
464
|
+
events.push({ ...base, type: 'tool_call_approved', callId: 'grant', decidedBy: 'resolver', mode: 'allow' } as MoxxyEvent);
|
|
465
|
+
const grant = await run(REQUEST_ACCESS_TOOL, request, 'grant');
|
|
466
|
+
events.push({ ...base, type: 'tool_result', callId: 'grant', ok: true, output: grant } as MoxxyEvent);
|
|
467
|
+
expect(grant).toMatchObject({ granted: [{ id: FIXTURE, name: 'Moxxy Computer Fixture', tier: 'full' }] });
|
|
468
|
+
const state = await run('computer_get_app_state', { app: FIXTURE }) as ToolImageResult;
|
|
469
|
+
expect(state.mediaType).toBe('image/jpeg');
|
|
470
|
+
expect(state.forModel).toContain('<app_content app="Moxxy Computer Fixture" trust="untrusted">');
|
|
471
|
+
expect(state.forModel).not.toContain('hunter2');
|
|
472
|
+
expect((await backend.controls.forSession('session').snapshot())[0]).toMatchObject({
|
|
473
|
+
cursor: { phase: 'idle' }, target: { app: 'Moxxy Computer Fixture', window: 'Moxxy Fixture' },
|
|
474
|
+
});
|
|
475
|
+
const press = Number(/\[(\d+)\] push button "Press"/.exec(state.forModel ?? '')?.[1]);
|
|
476
|
+
const clicked = await run('computer_click', { app: FIXTURE, element_index: press }) as ToolImageResult;
|
|
477
|
+
expect(clicked.forModel).toContain('Action delivered');
|
|
478
|
+
expect(clicked.forModel).toMatch(/label "Pressed \d+"/);
|
|
479
|
+
} finally { await backend.release('session'); }
|
|
480
|
+
});
|
|
481
|
+
});
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { fileURLToPath } from 'node:url';
|
|
2
|
+
import type { PlatformProfile } from '../backend/backend.js';
|
|
3
|
+
import { CONTRACT_PROTOCOL_VERSION } from '../backend/rpc.js';
|
|
4
|
+
import { verifyHelperArtifact } from '../helper/artifact.js';
|
|
5
|
+
|
|
6
|
+
export type LinuxArchitecture = 'x64' | 'arm64';
|
|
7
|
+
|
|
8
|
+
/** The helper built by `native/linux/build.sh` for one architecture. */
|
|
9
|
+
export const linuxHelperPath = (arch: LinuxArchitecture) => fileURLToPath(new URL(`../../bin/linux-${arch}/moxxy-computer`, import.meta.url));
|
|
10
|
+
|
|
11
|
+
export function linuxProfile(arch: LinuxArchitecture): PlatformProfile {
|
|
12
|
+
const helperPath = linuxHelperPath(arch);
|
|
13
|
+
return {
|
|
14
|
+
platform: 'linux',
|
|
15
|
+
protocolVersion: CONTRACT_PROTOCOL_VERSION,
|
|
16
|
+
helperPath,
|
|
17
|
+
helperArgs: [],
|
|
18
|
+
verifyHelper: () => verifyHelperArtifact(helperPath, CONTRACT_PROTOCOL_VERSION),
|
|
19
|
+
unavailableMessage: 'The Linux Computer Use helper is missing or does not match this version. Reinstall Moxxy; chat remains available.',
|
|
20
|
+
// Launching an app and letting it settle can take several seconds on its own.
|
|
21
|
+
timeoutMs: 30_000,
|
|
22
|
+
// The helper sends pictures; it has no video encoder.
|
|
23
|
+
previewCodecs: ['jpeg'],
|
|
24
|
+
};
|
|
25
|
+
}
|