@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,988 @@
|
|
|
1
|
+
import { spawn, spawnSync, type ChildProcess } from 'node:child_process';
|
|
2
|
+
import { existsSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { once } from 'node:events';
|
|
6
|
+
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
|
7
|
+
import { imageBudget } from '../contract/image.js';
|
|
8
|
+
import { verifyHelperArtifact } from '../helper/artifact.js';
|
|
9
|
+
import { HelperTransport, type HelperEvent } from '../helper/transport.js';
|
|
10
|
+
import { CONTRACT_PROTOCOL_VERSION, actResultSchema, appStateSchema, batchResultSchema, contractEventsFor, imageSchema, listAppsResultSchema, readTextResultSchema, resolveAppsResultSchema, statusResultSchema } from '../backend/rpc.js';
|
|
11
|
+
import { macosHelperPath, macosProfile } from './profile.js';
|
|
12
|
+
import { ComputerBackend } from '../backend/backend.js';
|
|
13
|
+
import { REQUEST_ACCESS_TOOL } from '../backend/access.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
|
+
|
|
19
|
+
// Talks to the real universal helper built by native/macos/build.sh; other hosts and unbuilt trees skip.
|
|
20
|
+
const built = process.platform === 'darwin' && existsSync(macosHelperPath);
|
|
21
|
+
const signal = () => new AbortController().signal;
|
|
22
|
+
const start = (onEvent?: (event: HelperEvent) => void) => new HelperTransport(macosHelperPath, ['--parent', String(process.pid)], {
|
|
23
|
+
protocolVersion: CONTRACT_PROTOCOL_VERSION, events: contractEventsFor(CONTRACT_PROTOCOL_VERSION), ...(onEvent ? { onEvent } : {}),
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
describe.skipIf(!built)('macOS native helper', () => {
|
|
27
|
+
it('is a verified universal artifact for the shared protocol', async () => {
|
|
28
|
+
await expect(verifyHelperArtifact(macosHelperPath, CONTRACT_PROTOCOL_VERSION)).resolves.toBeUndefined();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('reports its readiness and the permissions it still needs', async () => {
|
|
32
|
+
const transport = start();
|
|
33
|
+
try {
|
|
34
|
+
const status = statusResultSchema.parse(await transport.request('status', {}, signal()));
|
|
35
|
+
expect(status.ready).toBe(status.permissions.accessibility && status.permissions.screenRecording);
|
|
36
|
+
} finally { await transport.close(); }
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('is what the plugin runs on this Mac: the shared tools, with a status answered by the helper', async () => {
|
|
40
|
+
const plugin = createComputerControlPlugin();
|
|
41
|
+
expect((plugin.tools ?? []).map((tool) => tool.name).sort()).toEqual(Object.keys(computerTools).sort());
|
|
42
|
+
const status = plugin.tools?.find((tool) => tool.name === 'computer_status');
|
|
43
|
+
try {
|
|
44
|
+
const report = await status?.handler({}, toolContext(memoryLog([]), { sessionId: 'plugin-status' })) as { platform: string; ready: boolean; permissions: { accessibility: boolean; screenRecording: boolean } };
|
|
45
|
+
expect(report.platform).toBe('darwin');
|
|
46
|
+
expect(report.ready).toBe(report.permissions.accessibility && report.permissions.screenRecording);
|
|
47
|
+
} finally {
|
|
48
|
+
await plugin.hooks?.onShutdown?.({ sessionId: 'plugin-status' } as never);
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('refuses an unknown method with a code and keeps serving', async () => {
|
|
53
|
+
const transport = start();
|
|
54
|
+
try {
|
|
55
|
+
await expect(transport.request('teleport', {}, signal())).rejects.toMatchObject({ code: 'unsupported_action' });
|
|
56
|
+
await expect(transport.request('status', {}, signal())).resolves.toBeTypeOf('object');
|
|
57
|
+
} finally { await transport.close(); }
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('lists and resolves installed and running apps in the contract shape', async () => {
|
|
61
|
+
const transport = start();
|
|
62
|
+
try {
|
|
63
|
+
const listed = listAppsResultSchema.parse(await transport.request('list_apps', { query: 'finder', limit: 5 }, signal()));
|
|
64
|
+
expect(listed.apps[0]).toEqual({ id: 'com.apple.finder', name: 'Finder', running: true });
|
|
65
|
+
const resolved = resolveAppsResultSchema.parse(await transport.request('resolve_apps', { names: ['Calculator', 'No Such App 1234'] }, signal()));
|
|
66
|
+
expect(resolved.apps[0]).toMatchObject({ request: 'Calculator', status: 'resolved', id: 'com.apple.calculator' });
|
|
67
|
+
expect(resolved.apps[1]).toEqual({ request: 'No Such App 1234', status: 'not_found' });
|
|
68
|
+
} finally { await transport.close(); }
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('exits as soon as its input closes', async () => {
|
|
72
|
+
const transport = start();
|
|
73
|
+
await transport.request('status', {}, signal());
|
|
74
|
+
const started = performance.now();
|
|
75
|
+
await transport.close();
|
|
76
|
+
expect(performance.now() - started).toBeLessThan(450);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('exits when the process it serves dies', async () => {
|
|
80
|
+
const parent = spawn('/bin/sleep', ['30']);
|
|
81
|
+
const helper = spawn(macosHelperPath, ['--parent', String(parent.pid)], { stdio: ['pipe', 'ignore', 'ignore'] });
|
|
82
|
+
try {
|
|
83
|
+
const exited = once(helper, 'exit');
|
|
84
|
+
parent.kill('SIGKILL');
|
|
85
|
+
const [code] = await Promise.race([exited, timeout(2000)]);
|
|
86
|
+
expect(code).toBe(0);
|
|
87
|
+
} finally { stop(helper); stop(parent); }
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('refuses to start without the process it serves', async () => {
|
|
91
|
+
const helper = spawn(macosHelperPath, [], { stdio: ['pipe', 'ignore', 'ignore'] });
|
|
92
|
+
try {
|
|
93
|
+
const [code] = await Promise.race([once(helper, 'exit'), timeout(2000)]);
|
|
94
|
+
expect(code).toBe(64);
|
|
95
|
+
} finally { stop(helper); }
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
function timeout(ms: number): Promise<never> {
|
|
100
|
+
return new Promise((_, reject) => setTimeout(() => reject(new Error(`no exit within ${ms} ms`)), ms).unref());
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function stop(child: ChildProcess): void {
|
|
104
|
+
if (child.exitCode === null && child.signalCode === null) child.kill('SIGKILL');
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// The test-only AppKit app from native/macos/build-fixture.sh, registered with LaunchServices.
|
|
108
|
+
const FIXTURE = 'ai.moxxy.computer-fixture';
|
|
109
|
+
const fixtureBuilt = built && existsSync(new URL('../../native/macos/.build/fixture/MoxxyComputerFixture.app', import.meta.url));
|
|
110
|
+
const quitFixture = async () => {
|
|
111
|
+
spawnSync('pkill', ['-x', 'MoxxyComputerFixture']);
|
|
112
|
+
for (let attempt = 0; attempt < 50 && spawnSync('pgrep', ['-x', 'MoxxyComputerFixture']).status === 0; attempt += 1) {
|
|
113
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
114
|
+
}
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
const frontmost = () => spawnSync('osascript', ['-e', 'tell application "System Events" to get name of first process whose frontmost is true']).stdout.toString().trim();
|
|
118
|
+
/** Another app takes the front, so a test can tell whether the fixture was brought forward. */
|
|
119
|
+
const sendBehind = async () => {
|
|
120
|
+
spawnSync('osascript', ['-e', 'tell application "Finder" to activate']);
|
|
121
|
+
for (let attempt = 0; attempt < 50 && frontmost() !== 'Finder'; attempt += 1) await new Promise((resolve) => setTimeout(resolve, 100));
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
describe.skipIf(!fixtureBuilt)('macOS app state', () => {
|
|
125
|
+
beforeAll(quitFixture);
|
|
126
|
+
afterAll(quitFixture);
|
|
127
|
+
|
|
128
|
+
it('launches the app in the background and returns its indexed accessibility tree', async () => {
|
|
129
|
+
const transport = start();
|
|
130
|
+
try {
|
|
131
|
+
const state = appStateSchema.parse(await transport.request('get_app_state', { app: FIXTURE, screenshot: false }, signal()));
|
|
132
|
+
expect(state.tree).toMatchObject({ app: 'Moxxy Computer Fixture', window: 'Moxxy Fixture' });
|
|
133
|
+
const byKey = new Map(state.tree.elements.map((element) => [element.key.split('/').at(-1), element]));
|
|
134
|
+
expect(byKey.get('text field:name')).toMatchObject({ value: 'hello', description: 'placeholder: Name' });
|
|
135
|
+
expect(byKey.get('button:press')).toMatchObject({ role: 'button', title: 'Press' });
|
|
136
|
+
expect(byKey.get('checkbox:remember')).toMatchObject({ states: ['checked'] });
|
|
137
|
+
expect(byKey.get('button:disabled')?.states).toContain('disabled');
|
|
138
|
+
expect(byKey.get('text:status')).toMatchObject({ title: 'Ready' });
|
|
139
|
+
const secret = [...byKey.values()].find((element) => element.secure);
|
|
140
|
+
expect(secret?.value).toBeUndefined();
|
|
141
|
+
expect(JSON.stringify(state)).not.toContain('hunter2');
|
|
142
|
+
// Plain stack views are flattened; only the labelled canvases stay groups.
|
|
143
|
+
expect(state.tree.elements.filter((element) => element.role === 'group').map((element) => element.key.split('/').at(-1))).toEqual(['group:painted', 'group:pad', 'group:timeline']);
|
|
144
|
+
} finally { await transport.close(); }
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('waits for a freshly launched app to finish loading', async () => {
|
|
148
|
+
await quitFixture();
|
|
149
|
+
const transport = start();
|
|
150
|
+
try {
|
|
151
|
+
const started = performance.now();
|
|
152
|
+
const state = appStateSchema.parse(await transport.request('get_app_state', { app: FIXTURE, screenshot: false }, signal()));
|
|
153
|
+
expect(performance.now() - started).toBeLessThan(6000);
|
|
154
|
+
expect(state.tree.elements.find((element) => element.key.endsWith('text:loaded'))?.title).toBe('Loaded');
|
|
155
|
+
expect(state.tree.elements.some((element) => element.role.includes('busy') || element.role.includes('progress'))).toBe(false);
|
|
156
|
+
} finally { await transport.close(); }
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
// The system lists an app for a moment after its process is gone; that one must not stand for the app.
|
|
160
|
+
it('starts the app again when it is asked for just after it quit', { timeout: 60_000 }, async () => {
|
|
161
|
+
for (let round = 0; round < 5; round += 1) {
|
|
162
|
+
await quitFixture();
|
|
163
|
+
const transport = start();
|
|
164
|
+
try {
|
|
165
|
+
const state = appStateSchema.parse(await transport.request('get_app_state', { app: FIXTURE, screenshot: false }, signal()));
|
|
166
|
+
expect(state.tree.elements.find((element) => element.key.endsWith('text:loaded'))?.title, `round ${round}: ${JSON.stringify(state).slice(0, 300)}`).toBe('Loaded');
|
|
167
|
+
} finally { await transport.close(); }
|
|
168
|
+
}
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it('keeps every element index across observations', async () => {
|
|
172
|
+
const transport = start();
|
|
173
|
+
try {
|
|
174
|
+
const first = appStateSchema.parse(await transport.request('get_app_state', { app: FIXTURE, screenshot: false }, signal()));
|
|
175
|
+
const second = appStateSchema.parse(await transport.request('get_app_state', { app: FIXTURE, screenshot: false }, signal()));
|
|
176
|
+
const indices = (state: typeof first) => Object.fromEntries(state.tree.elements.map((element) => [element.key, element.index]));
|
|
177
|
+
expect(indices(second)).toEqual(indices(first));
|
|
178
|
+
} finally { await transport.close(); }
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it('names its window by the system\'s number, the same in every observation', async () => {
|
|
182
|
+
const transport = start();
|
|
183
|
+
try {
|
|
184
|
+
const first = appStateSchema.parse(await transport.request('get_app_state', { app: FIXTURE, screenshot: false }, signal()));
|
|
185
|
+
const second = appStateSchema.parse(await transport.request('get_app_state', { app: FIXTURE, screenshot: false }, signal()));
|
|
186
|
+
expect(first.tree.windowId).toMatch(/^\d+$/);
|
|
187
|
+
expect(second.tree.windowId).toBe(first.tree.windowId);
|
|
188
|
+
} finally { await transport.close(); }
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it('captures the window within the image budget and places elements in its pixels', async () => {
|
|
192
|
+
const transport = start();
|
|
193
|
+
try {
|
|
194
|
+
const state = appStateSchema.parse(await transport.request('get_app_state', { app: FIXTURE, screenshot: true }, signal()));
|
|
195
|
+
const shot = state.screenshot;
|
|
196
|
+
expect(shot?.mediaType).toBe('image/jpeg');
|
|
197
|
+
expect(Buffer.from(shot?.base64 ?? '', 'base64').subarray(0, 3)).toEqual(Buffer.from([0xff, 0xd8, 0xff]));
|
|
198
|
+
const width = shot?.width ?? 0;
|
|
199
|
+
const height = shot?.height ?? 0;
|
|
200
|
+
expect(imageBudget(width, height)).toEqual([width, height]);
|
|
201
|
+
const press = state.tree.elements.find((element) => element.key.endsWith('button:press'));
|
|
202
|
+
expect(press?.frame).toBeDefined();
|
|
203
|
+
const frame = press?.frame ?? { x: -1, y: -1, width: 0, height: 0 };
|
|
204
|
+
expect(frame.x).toBeGreaterThanOrEqual(0);
|
|
205
|
+
expect(frame.x + frame.width).toBeLessThanOrEqual(width);
|
|
206
|
+
expect(frame.y + frame.height).toBeLessThanOrEqual(height);
|
|
207
|
+
expect(frame.width).toBeGreaterThan(10);
|
|
208
|
+
} finally { await transport.close(); }
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it('shows the agent cursor over the observed window and reports where it is', async () => {
|
|
212
|
+
const events: HelperEvent[] = [];
|
|
213
|
+
const transport = start((event) => events.push(event));
|
|
214
|
+
try {
|
|
215
|
+
await transport.request('get_app_state', { app: FIXTURE, screenshot: false }, signal());
|
|
216
|
+
expect(events).toContainEqual({ version: CONTRACT_PROTOCOL_VERSION, event: 'cursor', cursor: { phase: 'idle', x: 0.5, y: 0.5 } });
|
|
217
|
+
} finally { await transport.close(); }
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it('serves the model tools end to end through the shared backend', { timeout: 30_000 }, async () => {
|
|
221
|
+
const backend = new ComputerBackend(macosProfile);
|
|
222
|
+
const tools = new Map(backend.tools().map((tool) => [tool.name, tool]));
|
|
223
|
+
const events: MoxxyEvent[] = [];
|
|
224
|
+
const run = async (name: string, input: unknown, callId = 'c') => {
|
|
225
|
+
const tool = tools.get(name);
|
|
226
|
+
if (!tool) throw new Error(name);
|
|
227
|
+
return tool.handler(tool.inputSchema.parse(input), toolContext(memoryLog(events), { callId }));
|
|
228
|
+
};
|
|
229
|
+
try {
|
|
230
|
+
const request = { apps: [FIXTURE], reason: 'Integration test' };
|
|
231
|
+
const base = { id: 'e', seq: 0, ts: 0, sessionId: 'session', turnId: 'turn', source: 'system' };
|
|
232
|
+
events.push({ ...base, type: 'tool_call_requested', callId: 'grant', name: REQUEST_ACCESS_TOOL, input: request } as MoxxyEvent);
|
|
233
|
+
events.push({ ...base, type: 'tool_call_approved', callId: 'grant', decidedBy: 'resolver', mode: 'allow' } as MoxxyEvent);
|
|
234
|
+
const grant = await run(REQUEST_ACCESS_TOOL, request, 'grant');
|
|
235
|
+
events.push({ ...base, type: 'tool_result', callId: 'grant', ok: true, output: grant } as MoxxyEvent);
|
|
236
|
+
expect(grant).toMatchObject({ granted: [{ id: FIXTURE, name: 'Moxxy Computer Fixture', tier: 'full' }] });
|
|
237
|
+
const state = await run('computer_get_app_state', { app: FIXTURE }) as ToolImageResult;
|
|
238
|
+
expect(state.mediaType).toBe('image/jpeg');
|
|
239
|
+
expect(state.forModel).toContain('<app_content app="Moxxy Computer Fixture" trust="untrusted">');
|
|
240
|
+
expect(state.forModel).toMatch(/\[\d+\] button "Press"/);
|
|
241
|
+
expect(state.forModel).not.toContain('hunter2');
|
|
242
|
+
expect((await backend.controls.forSession('session').snapshot())[0]).toMatchObject({
|
|
243
|
+
cursor: { phase: 'idle' }, target: { app: 'Moxxy Computer Fixture', window: 'Moxxy Fixture' },
|
|
244
|
+
});
|
|
245
|
+
const press = Number(/\[(\d+)\] button "Press"/.exec(state.forModel ?? '')?.[1]);
|
|
246
|
+
const clicked = await run('computer_click', { app: FIXTURE, element_index: press }) as ToolImageResult;
|
|
247
|
+
expect(clicked.forModel).toContain('Action delivered');
|
|
248
|
+
expect(clicked.forModel).toMatch(/text "Pressed \d+"/);
|
|
249
|
+
|
|
250
|
+
// A control that does nothing: said once, called ineffective the second time, not sent a third time.
|
|
251
|
+
const full = await run('computer_get_app_state', { app: FIXTURE, disable_diff: true }) as ToolImageResult;
|
|
252
|
+
const dud = Number(/\[(\d+)\] button "Dud"/.exec(full.forModel ?? '')?.[1]);
|
|
253
|
+
const first = await run('computer_click', { app: FIXTURE, element_index: dud }) as ToolImageResult;
|
|
254
|
+
expect(first.forModel).toContain('Nothing visible changed after this action');
|
|
255
|
+
const second = await run('computer_click', { app: FIXTURE, element_index: dud }) as ToolImageResult;
|
|
256
|
+
expect(second.forModel).toMatch(/ineffective/i);
|
|
257
|
+
await expect(run('computer_click', { app: FIXTURE, element_index: dud })).rejects.toMatchObject({ code: 'no_progress' });
|
|
258
|
+
} finally { await backend.release('session'); await sendBehind(); }
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
it('clicks for real a control that accepts an accessibility press and does nothing, once asked again', { timeout: 30_000 }, async () => {
|
|
262
|
+
const backend = new ComputerBackend(macosProfile);
|
|
263
|
+
const tools = new Map(backend.tools().map((tool) => [tool.name, tool]));
|
|
264
|
+
const events: MoxxyEvent[] = [];
|
|
265
|
+
const run = async (name: string, input: unknown, callId = 'c') => {
|
|
266
|
+
const tool = tools.get(name);
|
|
267
|
+
if (!tool) throw new Error(name);
|
|
268
|
+
return tool.handler(tool.inputSchema.parse(input), toolContext(memoryLog(events), { callId }));
|
|
269
|
+
};
|
|
270
|
+
try {
|
|
271
|
+
const request = { apps: [FIXTURE], reason: 'Integration test' };
|
|
272
|
+
const base = { id: 'e', seq: 0, ts: 0, sessionId: 'session', turnId: 'turn', source: 'system' };
|
|
273
|
+
events.push({ ...base, type: 'tool_call_requested', callId: 'grant', name: REQUEST_ACCESS_TOOL, input: request } as MoxxyEvent);
|
|
274
|
+
events.push({ ...base, type: 'tool_call_approved', callId: 'grant', decidedBy: 'resolver', mode: 'allow' } as MoxxyEvent);
|
|
275
|
+
events.push({ ...base, type: 'tool_result', callId: 'grant', ok: true, output: await run(REQUEST_ACCESS_TOOL, request, 'grant') } as MoxxyEvent);
|
|
276
|
+
const state = await run('computer_get_app_state', { app: FIXTURE, disable_diff: true }) as ToolImageResult;
|
|
277
|
+
const stubborn = Number(/\[(\d+)\] button "Stubborn"/.exec(state.forModel ?? '')?.[1]);
|
|
278
|
+
const first = await run('computer_click', { app: FIXTURE, element_index: stubborn }) as ToolImageResult;
|
|
279
|
+
expect(first.forModel).toContain('Nothing visible changed after this action');
|
|
280
|
+
const second = await run('computer_click', { app: FIXTURE, element_index: stubborn }) as ToolImageResult;
|
|
281
|
+
expect(second.forModel).toContain('Action delivered');
|
|
282
|
+
expect(second.forModel).toMatch(/text "Stubborn 1"/);
|
|
283
|
+
} finally { await backend.release('session'); await sendBehind(); }
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
// Every action settles for at least a second before its fresh state comes back (as in Codex).
|
|
287
|
+
describe('actions by element index', { timeout: 30_000 }, () => {
|
|
288
|
+
const observe = async (transport: HelperTransport) =>
|
|
289
|
+
appStateSchema.parse(await transport.request('get_app_state', { app: FIXTURE, screenshot: false }, signal()));
|
|
290
|
+
type Listed = { key: string; index: number; title?: string; value?: string; description?: string; actions?: string[]; frame?: { x: number; y: number; width: number; height: number } };
|
|
291
|
+
const element = (state: { tree: { elements: Listed[] } }, suffix: string) => {
|
|
292
|
+
const found = state.tree.elements.find((candidate) => candidate.key.endsWith(suffix));
|
|
293
|
+
if (!found) throw new Error(`no ${suffix}`);
|
|
294
|
+
return found;
|
|
295
|
+
};
|
|
296
|
+
const act = async (transport: HelperTransport, action: Record<string, unknown>, allowed = [FIXTURE]) =>
|
|
297
|
+
actResultSchema.parse(await transport.request('act', { app: FIXTURE, action, allowed }, signal()));
|
|
298
|
+
|
|
299
|
+
it('presses a button through accessibility, shows the cursor at work and returns the new state', async () => {
|
|
300
|
+
const events: HelperEvent[] = [];
|
|
301
|
+
const transport = start((event) => events.push(event));
|
|
302
|
+
try {
|
|
303
|
+
const before = await observe(transport);
|
|
304
|
+
const pressed = Number(/\d+/.exec(element(before, 'text:status').title ?? '')?.[0] ?? 0);
|
|
305
|
+
events.length = 0;
|
|
306
|
+
const { result, state } = await act(transport, { action: 'click', element_index: element(before, 'button:press').index, mouse_button: 'left', click_count: 1 });
|
|
307
|
+
expect(result).toEqual({ outcome: 'delivered', method: 'ax' });
|
|
308
|
+
expect(element(state ?? before, 'text:status').title).toBe(`Pressed ${pressed + 1}`);
|
|
309
|
+
const phases = events.map((event) => (event as { cursor: { phase: string } | null }).cursor?.phase);
|
|
310
|
+
expect(phases.slice(0, 3)).toEqual(['moving', 'executing', 'delivered']);
|
|
311
|
+
} finally { await transport.close(); }
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
// The cursor glides on its own and a press that shows nothing at once is checked only when asked for again.
|
|
315
|
+
it('sends an accessibility press at once, waiting neither for the cursor nor for a redraw', async () => {
|
|
316
|
+
const timing = join(mkdtempSync(join(tmpdir(), 'moxxy-timing-')), 'timing.txt');
|
|
317
|
+
process.env.MOXXY_COMPUTER_TIMING = timing;
|
|
318
|
+
const transport = start();
|
|
319
|
+
delete process.env.MOXXY_COMPUTER_TIMING;
|
|
320
|
+
try {
|
|
321
|
+
const before = await observe(transport);
|
|
322
|
+
const { result } = await act(transport, { action: 'click', element_index: element(before, 'button:dud').index, mouse_button: 'left', click_count: 1 });
|
|
323
|
+
expect(result).toEqual({ outcome: 'delivered', method: 'ax' });
|
|
324
|
+
const pressed = /(\d+) ms {2}act: performed ax/.exec(readFileSync(timing, 'utf8'));
|
|
325
|
+
expect(Number(pressed?.[1])).toBeLessThan(150);
|
|
326
|
+
} finally { await transport.close(); }
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
it('leaves the picture out of the state after an action when asked', async () => {
|
|
330
|
+
const transport = start();
|
|
331
|
+
try {
|
|
332
|
+
const before = await observe(transport);
|
|
333
|
+
const press = { action: 'click', element_index: element(before, 'button:press').index, mouse_button: 'left', click_count: 1 };
|
|
334
|
+
const plain = actResultSchema.parse(await transport.request('act', { app: FIXTURE, action: press, allowed: [FIXTURE], screenshot: false }, signal()));
|
|
335
|
+
expect(plain.result.outcome).toBe('delivered');
|
|
336
|
+
expect(plain.state?.screenshot).toBeUndefined();
|
|
337
|
+
expect((await act(transport, press)).state?.screenshot).toBeDefined();
|
|
338
|
+
} finally { await transport.close(); }
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
it('returns from typing with what the app showed in answer to the text', async () => {
|
|
342
|
+
const transport = start();
|
|
343
|
+
try {
|
|
344
|
+
const city = element(await observe(transport), 'text field:city');
|
|
345
|
+
const { state } = actResultSchema.parse(await transport.request('act', {
|
|
346
|
+
app: FIXTURE, action: { action: 'type_text', element_index: city.index, text: 'Kra' }, allowed: [FIXTURE], screenshot: false,
|
|
347
|
+
}, signal()));
|
|
348
|
+
expect(element(state ?? { tree: { elements: [] } }, ':suggestion').title).toBe('Suggestion Kra');
|
|
349
|
+
} finally { await transport.close(); }
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
it('sets a text field and runs an action the element lists', async () => {
|
|
353
|
+
const transport = start();
|
|
354
|
+
try {
|
|
355
|
+
const before = await observe(transport);
|
|
356
|
+
const named = await act(transport, { action: 'set_value', element_index: element(before, 'text field:name').index, value: 'world' });
|
|
357
|
+
expect(named.result).toEqual({ outcome: 'delivered', method: 'ax' });
|
|
358
|
+
expect(element(named.state ?? before, 'text field:name').value).toBe('world');
|
|
359
|
+
const stepper = element(before, ':count');
|
|
360
|
+
const stepped = await act(transport, { action: 'perform_secondary_action', element_index: stepper.index, secondary_action: 'AXIncrement' });
|
|
361
|
+
expect(stepped.result).toEqual({ outcome: 'delivered', method: 'ax' });
|
|
362
|
+
expect(Number(element(stepped.state ?? before, ':count').value)).toBe(Number(stepper.value) + 1);
|
|
363
|
+
} finally { await transport.close(); }
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
it('refuses an action the element does not list, an index it never handed out and an app without a grant', async () => {
|
|
367
|
+
const transport = start();
|
|
368
|
+
try {
|
|
369
|
+
const before = await observe(transport);
|
|
370
|
+
const guessed = await act(transport, { action: 'perform_secondary_action', element_index: element(before, 'button:press').index, secondary_action: 'AXRaise' });
|
|
371
|
+
expect(guessed.result).toEqual({ outcome: 'unsupported', code: 'unsupported_action' });
|
|
372
|
+
const stale = await act(transport, { action: 'click', element_index: 9999, mouse_button: 'left', click_count: 1 });
|
|
373
|
+
expect(stale.result).toEqual({ outcome: 'blocked', code: 'stale_state' });
|
|
374
|
+
expect(stale.state?.tree.app).toBe('Moxxy Computer Fixture');
|
|
375
|
+
await expect(act(transport, { action: 'click', element_index: 0, mouse_button: 'left', click_count: 1 }, ['com.apple.TextEdit']))
|
|
376
|
+
.rejects.toMatchObject({ code: 'app_not_allowed' });
|
|
377
|
+
} finally { await transport.close(); }
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
const name = async (transport: HelperTransport) => element(await observe(transport), 'text field:name');
|
|
381
|
+
const chord = (key: string | null, modifiers: string[] = []) => ({ modifiers, key });
|
|
382
|
+
type Framed = { tree: { elements: Listed[] } };
|
|
383
|
+
const look = async (transport: HelperTransport) =>
|
|
384
|
+
appStateSchema.parse(await transport.request('get_app_state', { app: FIXTURE, screenshot: true }, signal()));
|
|
385
|
+
const centre = (state: Framed, suffix: string) => {
|
|
386
|
+
const { frame } = element(state, suffix);
|
|
387
|
+
if (!frame) throw new Error(`${suffix} has no frame`);
|
|
388
|
+
return { x: Math.round(frame.x + frame.width / 2), y: Math.round(frame.y + frame.height / 2) };
|
|
389
|
+
};
|
|
390
|
+
const pad = (state: Framed | undefined) => (state ? element(state, 'group:pad').description : undefined);
|
|
391
|
+
|
|
392
|
+
it('types with keys into a field that accepts text through accessibility and drops it', async () => {
|
|
393
|
+
const transport = start();
|
|
394
|
+
try {
|
|
395
|
+
const deaf = element(await observe(transport), 'deaf');
|
|
396
|
+
const typed = await act(transport, { action: 'type_text', element_index: deaf.index, text: 'Zażółć 1' });
|
|
397
|
+
expect(typed.result).toEqual({ outcome: 'delivered', method: 'input' });
|
|
398
|
+
expect(element(typed.state ?? await observe(transport), 'deaf').value).toBe('Zażółć 1');
|
|
399
|
+
} finally { await transport.close(); await sendBehind(); }
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
it('selects text and types at the caret through accessibility, without bringing the app forward', async () => {
|
|
403
|
+
const transport = start();
|
|
404
|
+
try {
|
|
405
|
+
const field = await name(transport);
|
|
406
|
+
await act(transport, { action: 'set_value', element_index: field.index, value: 'one two three' });
|
|
407
|
+
const selected = await act(transport, { action: 'select_text', element_index: field.index, text: 'two', selection_type: 'text' });
|
|
408
|
+
expect(selected.result).toEqual({ outcome: 'delivered', method: 'ax' });
|
|
409
|
+
const typed = await act(transport, { action: 'type_text', text: '2' });
|
|
410
|
+
expect(typed.result).toEqual({ outcome: 'delivered', method: 'ax' });
|
|
411
|
+
await act(transport, { action: 'select_text', element_index: field.index, text: 'one', selection_type: 'cursor_after' });
|
|
412
|
+
await act(transport, { action: 'type_text', element_index: field.index, text: '!' });
|
|
413
|
+
expect((await name(transport)).value).toBe('one! 2 three');
|
|
414
|
+
expect(spawnSync('osascript', ['-e', 'tell application "System Events" to get name of first process whose frontmost is true']).stdout.toString().trim())
|
|
415
|
+
.not.toBe('MoxxyComputerFixture');
|
|
416
|
+
} finally { await transport.close(); }
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
it('presses keys and chords into the app in the background', async () => {
|
|
420
|
+
const transport = start();
|
|
421
|
+
try {
|
|
422
|
+
const field = await name(transport);
|
|
423
|
+
await act(transport, { action: 'set_value', element_index: field.index, value: 'abc' });
|
|
424
|
+
await act(transport, { action: 'select_text', element_index: field.index, text: 'abc', selection_type: 'cursor_after' });
|
|
425
|
+
const erased = await act(transport, { action: 'press_key', key: 'BackSpace', repeat: 2, chord: chord('backspace') });
|
|
426
|
+
expect(erased.result).toEqual({ outcome: 'delivered', method: 'input' });
|
|
427
|
+
expect((await name(transport)).value).toBe('a');
|
|
428
|
+
// Select All has an accessibility equivalent, so it works while the app stays in the background.
|
|
429
|
+
const all = await act(transport, { action: 'press_key', key: 'super+a', repeat: 1, chord: chord('a', ['meta']) });
|
|
430
|
+
expect(all.result).toEqual({ outcome: 'delivered', method: 'ax' });
|
|
431
|
+
await act(transport, { action: 'type_text', text: 'Z' });
|
|
432
|
+
expect((await name(transport)).value).toBe('Z');
|
|
433
|
+
} finally { await transport.close(); }
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
it('holds a key for as long as asked, in the background', async () => {
|
|
437
|
+
const transport = start();
|
|
438
|
+
try {
|
|
439
|
+
await observe(transport);
|
|
440
|
+
const { result, state } = await act(transport, { action: 'hold_key', key: 'shift', duration_s: 0.5, chord: chord(null, ['shift']) });
|
|
441
|
+
expect(result).toEqual({ outcome: 'delivered', method: 'input' });
|
|
442
|
+
expect(state && element(state, 'text:keys').title).toBe('Held key 56 for 0.5 s');
|
|
443
|
+
expect(frontmost()).not.toBe('MoxxyComputerFixture');
|
|
444
|
+
} finally { await transport.close(); }
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
it('pastes plain text at the caret without touching the clipboard', async () => {
|
|
448
|
+
const clipboard = () => spawnSync('pbpaste').stdout.toString();
|
|
449
|
+
const transport = start();
|
|
450
|
+
try {
|
|
451
|
+
const before = clipboard();
|
|
452
|
+
const field = await name(transport);
|
|
453
|
+
await act(transport, { action: 'set_value', element_index: field.index, value: 'x' });
|
|
454
|
+
await act(transport, { action: 'select_text', element_index: field.index, text: 'x', selection_type: 'cursor_after' });
|
|
455
|
+
const pasted = await act(transport, { action: 'paste', text: 'PASTE', format: 'text' });
|
|
456
|
+
expect(pasted.result).toEqual({ outcome: 'delivered', method: 'ax' });
|
|
457
|
+
expect((await name(transport)).value).toBe('xPASTE');
|
|
458
|
+
expect(clipboard()).toBe(before);
|
|
459
|
+
} finally { await transport.close(); }
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
it('asks for prefix or suffix when the text to select repeats', async () => {
|
|
463
|
+
const transport = start();
|
|
464
|
+
try {
|
|
465
|
+
const field = await name(transport);
|
|
466
|
+
await act(transport, { action: 'set_value', element_index: field.index, value: 'hi there hi' });
|
|
467
|
+
const { result } = await act(transport, { action: 'select_text', element_index: field.index, text: 'hi', selection_type: 'text' });
|
|
468
|
+
expect(result).toMatchObject({ outcome: 'unsupported', code: 'unsupported_action' });
|
|
469
|
+
expect(result.hint).toMatch(/2 times.*prefix or suffix/);
|
|
470
|
+
} finally { await transport.close(); }
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
describe('by screenshot point and real pointer input', () => {
|
|
474
|
+
const pointer = () => spawnSync('osascript', ['-l', 'JavaScript', '-e', 'ObjC.import("AppKit"); const p = $.NSEvent.mouseLocation; `${p.x},${p.y}`']).stdout.toString().trim().split(',').map(Number);
|
|
475
|
+
// A trackpad leaves the pointer between points; putting it back may round to the nearest one.
|
|
476
|
+
const expectPointerAt = (home: number[]) => pointer().forEach((axis, i) => expect(Math.abs(axis - (home[i] ?? NaN))).toBeLessThanOrEqual(1));
|
|
477
|
+
|
|
478
|
+
it('presses a control under a point through accessibility, in the background', async () => {
|
|
479
|
+
const transport = start();
|
|
480
|
+
try {
|
|
481
|
+
const before = await look(transport);
|
|
482
|
+
const pressed = Number(/\d+/.exec(element(before, 'text:status').title ?? '')?.[0] ?? 0);
|
|
483
|
+
const { result, state } = await act(transport, { action: 'click', ...centre(before, 'button:press'), mouse_button: 'left', click_count: 1 });
|
|
484
|
+
expect(result).toEqual({ outcome: 'delivered', method: 'ax' });
|
|
485
|
+
expect(element(state ?? before, 'text:status').title).toBe(`Pressed ${pressed + 1}`);
|
|
486
|
+
expect(frontmost()).not.toBe('MoxxyComputerFixture');
|
|
487
|
+
} finally { await transport.close(); }
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
it('types into the text field under a point and refuses a point with no text field', async () => {
|
|
491
|
+
const transport = start();
|
|
492
|
+
try {
|
|
493
|
+
const before = await look(transport);
|
|
494
|
+
const typed = await act(transport, { action: 'type_text', ...centre(before, 'text field:name'), text: 'Q' });
|
|
495
|
+
expect(typed.result).toEqual({ outcome: 'delivered', method: 'ax' });
|
|
496
|
+
expect(element(typed.state ?? before, 'text field:name').value).toContain('Q');
|
|
497
|
+
const nowhere = await act(transport, { action: 'type_text', ...centre(typed.state ?? before, 'group:pad'), text: 'Q' });
|
|
498
|
+
expect(nowhere.result).toMatchObject({ outcome: 'unsupported', code: 'unsupported_action', hint: expect.stringContaining('click the point first') });
|
|
499
|
+
} finally { await transport.close(); }
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
it('refuses a point outside the screenshot or whose pixels changed since it', async () => {
|
|
503
|
+
const transport = start();
|
|
504
|
+
try {
|
|
505
|
+
await look(transport);
|
|
506
|
+
const outside = await act(transport, { action: 'click', x: 5000, y: 10, mouse_button: 'left', click_count: 1 });
|
|
507
|
+
expect(outside.result).toMatchObject({ outcome: 'blocked', code: 'point_outside_frame' });
|
|
508
|
+
// Another process changes the field behind the helper's back; the point is on its text, whose pixels change.
|
|
509
|
+
// System Events only reaches a window on the visible Space, so the fixture comes forward first.
|
|
510
|
+
spawnSync('open', ['-b', FIXTURE]);
|
|
511
|
+
const setField = (value: string) => spawnSync('osascript', ['-e', `tell application "System Events" to set value of text field 1 of window 1 of process "MoxxyComputerFixture" to "${value}"`]);
|
|
512
|
+
setField('MMMMMMMMMMMMMMMM');
|
|
513
|
+
const field = await look(transport);
|
|
514
|
+
setField('');
|
|
515
|
+
const text = { x: (element(field, 'text field:name').frame?.x ?? 0) + 40, y: centre(field, 'text field:name').y };
|
|
516
|
+
const stale = await act(transport, { action: 'click', ...text, mouse_button: 'left', click_count: 1 });
|
|
517
|
+
expect(stale.result).toMatchObject({ outcome: 'blocked', code: 'screen_changed' });
|
|
518
|
+
} finally { await transport.close(); }
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
it('double-clicks a canvas with modifiers in the background, leaving the pointer and the app in front alone', async () => {
|
|
522
|
+
const transport = start();
|
|
523
|
+
try {
|
|
524
|
+
await sendBehind();
|
|
525
|
+
const before = await look(transport);
|
|
526
|
+
const home = pointer();
|
|
527
|
+
const { result, state } = await act(transport, { action: 'click', ...centre(before, 'group:pad'), mouse_button: 'left', click_count: 2, modifiers: 'shift', held: ['shift'] });
|
|
528
|
+
expect(result).toEqual({ outcome: 'delivered', method: 'background' });
|
|
529
|
+
expect(pad(state)).toBe('Pad up 2 after 0 moves shift');
|
|
530
|
+
expectPointerAt(home);
|
|
531
|
+
expect(frontmost()).toBe('Finder');
|
|
532
|
+
} finally { await transport.close(); }
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
it('goes through the real pointer when the window shows no change, and for a button the background cannot carry', async () => {
|
|
536
|
+
const transport = start();
|
|
537
|
+
try {
|
|
538
|
+
await sendBehind();
|
|
539
|
+
const before = await look(transport);
|
|
540
|
+
const point = centre(before, 'group:pad');
|
|
541
|
+
const first = await act(transport, { action: 'click', ...point, mouse_button: 'left', click_count: 1 });
|
|
542
|
+
expect(first.result).toEqual({ outcome: 'delivered', method: 'background' });
|
|
543
|
+
expect(pad(first.state)).toBe('Pad up 1 after 0 moves');
|
|
544
|
+
// The same click leaves the same picture, so the helper cannot tell it arrived and sends a real one.
|
|
545
|
+
const home = pointer();
|
|
546
|
+
const again = await act(transport, { action: 'click', ...point, mouse_button: 'left', click_count: 1 });
|
|
547
|
+
expect(again.result).toEqual({ outcome: 'delivered', method: 'input' });
|
|
548
|
+
expect(frontmost()).toBe('MoxxyComputerFixture');
|
|
549
|
+
expectPointerAt(home);
|
|
550
|
+
await sendBehind();
|
|
551
|
+
const right = await act(transport, { action: 'click', ...centre(await look(transport), 'group:pad'), mouse_button: 'right', click_count: 1 });
|
|
552
|
+
expect(right.result).toEqual({ outcome: 'delivered', method: 'input' });
|
|
553
|
+
} finally { await transport.close(); }
|
|
554
|
+
});
|
|
555
|
+
|
|
556
|
+
it('drags along a path and spells out a press, move and release', async () => {
|
|
557
|
+
const transport = start();
|
|
558
|
+
try {
|
|
559
|
+
const before = await look(transport);
|
|
560
|
+
const { x, y } = centre(before, 'group:pad');
|
|
561
|
+
const home = pointer();
|
|
562
|
+
await sendBehind();
|
|
563
|
+
const dragged = await act(transport, { action: 'drag', path: [[x - 100, y], [x + 100, y]], duration_ms: 200, mouse_button: 'left' });
|
|
564
|
+
expect(dragged.result).toEqual({ outcome: 'delivered', method: 'background' });
|
|
565
|
+
expect(frontmost()).toBe('Finder');
|
|
566
|
+
expect(pad(dragged.state)).toMatch(/^Pad up 1 after ([2-9]|\d{2,}) moves$/);
|
|
567
|
+
expectPointerAt(home);
|
|
568
|
+
|
|
569
|
+
const hover = await act(transport, { action: 'mouse', event: 'move', x, y, mouse_button: 'left' });
|
|
570
|
+
expect(hover.result).toMatchObject({ outcome: 'unsupported' });
|
|
571
|
+
const down = await act(transport, { action: 'mouse', event: 'down', x: x - 50, y, mouse_button: 'left' });
|
|
572
|
+
expect(pad(down.state)).toBe('Pad down 1');
|
|
573
|
+
await act(transport, { action: 'mouse', event: 'move', x, y, mouse_button: 'left' });
|
|
574
|
+
const up = await act(transport, { action: 'mouse', event: 'up', x: x + 50, y, mouse_button: 'left' });
|
|
575
|
+
expect(up.result).toEqual({ outcome: 'delivered', method: 'input' });
|
|
576
|
+
expect(pad(up.state)).toMatch(/^Pad up 1 after [2-9] moves$/);
|
|
577
|
+
expectPointerAt(home);
|
|
578
|
+
} finally { await transport.close(); }
|
|
579
|
+
});
|
|
580
|
+
|
|
581
|
+
it('scrolls a list by pages through accessibility and a canvas with the wheel', async () => {
|
|
582
|
+
const transport = start();
|
|
583
|
+
try {
|
|
584
|
+
const before = await look(transport);
|
|
585
|
+
const list = await act(transport, { action: 'scroll', element_index: element(before, 'scroll area:list').index, direction: 'down', pages: 1 });
|
|
586
|
+
expect(list.result).toEqual({ outcome: 'delivered', method: 'ax' });
|
|
587
|
+
expect(Number(/\d+/.exec(element(list.state ?? before, 'text:offset').title ?? '')?.[0])).toBeGreaterThan(0);
|
|
588
|
+
const wheel = await act(transport, { action: 'scroll', ...centre(list.state ?? before, 'group:pad'), direction: 'down', pages: 1 });
|
|
589
|
+
expect(wheel.result).toEqual({ outcome: 'delivered', method: 'background' });
|
|
590
|
+
expect(pad(wheel.state)).toMatch(/^Pad wheel -\d+$/);
|
|
591
|
+
} finally { await transport.close(); }
|
|
592
|
+
});
|
|
593
|
+
});
|
|
594
|
+
|
|
595
|
+
// Command shortcuts and rich-text paste need the menu bar, so the app comes forward and stays there.
|
|
596
|
+
// Runs last: the tests above check the app stays in the background.
|
|
597
|
+
describe('a canvas without accessibility: a timeline with clips', () => {
|
|
598
|
+
const clips = (state: Framed | undefined) => (state ? element(state, 'group:timeline').description : undefined);
|
|
599
|
+
/** A screenshot point `points` along the 420-point-wide timeline, on its middle line. */
|
|
600
|
+
const along = (state: Framed, points: number) => {
|
|
601
|
+
const { frame } = element(state, 'group:timeline');
|
|
602
|
+
if (!frame) throw new Error('timeline has no frame');
|
|
603
|
+
return [Math.round(frame.x + points * frame.width / 420), Math.round(frame.y + frame.height / 2)] as [number, number];
|
|
604
|
+
};
|
|
605
|
+
|
|
606
|
+
it('moves a clip by its body, trims another by its edge and selects with a click, from the picture alone', async () => {
|
|
607
|
+
const transport = start();
|
|
608
|
+
try {
|
|
609
|
+
const before = await look(transport);
|
|
610
|
+
expect(clips(before)).toBe('Timeline A 20+80 B 140+60 selected none');
|
|
611
|
+
expect(element(before, 'group:timeline').actions ?? []).toEqual([]);
|
|
612
|
+
const moved = await act(transport, { action: 'drag', path: [along(before, 60), along(before, 80)], duration_ms: 300, mouse_button: 'left' });
|
|
613
|
+
expect(moved.result).toEqual({ outcome: 'delivered', method: 'background' });
|
|
614
|
+
expect(clips(moved.state)).toBe('Timeline A 40+80 B 140+60 selected A');
|
|
615
|
+
|
|
616
|
+
const state = moved.state ?? before;
|
|
617
|
+
const trimmed = await act(transport, { action: 'drag', path: [along(state, 198), along(state, 218), along(state, 238)], duration_ms: 300, mouse_button: 'left' });
|
|
618
|
+
expect(clips(trimmed.state)).toBe('Timeline A 40+80 B 140+100 selected B');
|
|
619
|
+
|
|
620
|
+
const [x, y] = along(trimmed.state ?? state, 60);
|
|
621
|
+
const picked = await act(transport, { action: 'click', x, y, mouse_button: 'left', click_count: 1 });
|
|
622
|
+
expect(clips(picked.state)).toBe('Timeline A 40+80 B 140+100 selected A');
|
|
623
|
+
} finally { await transport.close(); }
|
|
624
|
+
});
|
|
625
|
+
});
|
|
626
|
+
|
|
627
|
+
describe('a layout that moves', () => {
|
|
628
|
+
it('keeps the index of a control that moved and still presses it', async () => {
|
|
629
|
+
const transport = start();
|
|
630
|
+
try {
|
|
631
|
+
const before = await look(transport);
|
|
632
|
+
const press = element(before, 'button:press');
|
|
633
|
+
const shifted = await act(transport, { action: 'click', element_index: element(before, 'button:shift').index, mouse_button: 'left', click_count: 1 });
|
|
634
|
+
expect(shifted.result).toEqual({ outcome: 'delivered', method: 'ax' });
|
|
635
|
+
const after = await look(transport);
|
|
636
|
+
const moved = element(after, 'button:press');
|
|
637
|
+
expect(moved.index).toBe(press.index);
|
|
638
|
+
expect(moved.frame?.y).not.toBe(press.frame?.y);
|
|
639
|
+
const pressed = Number(/Pressed (\d+)/.exec(element(after, 'text:status').title ?? '')?.[1] ?? 0);
|
|
640
|
+
const clicked = await act(transport, { action: 'click', element_index: press.index, mouse_button: 'left', click_count: 1 });
|
|
641
|
+
expect(element(clicked.state ?? after, 'text:status').title).toBe(`Pressed ${pressed + 1}`);
|
|
642
|
+
// Back to the first layout for the tests that follow.
|
|
643
|
+
await act(transport, { action: 'click', element_index: element(after, 'button:shift').index, mouse_button: 'left', click_count: 1 });
|
|
644
|
+
expect(element(await look(transport), 'button:press').frame?.y).toBe(press.frame?.y);
|
|
645
|
+
} finally { await transport.close(); }
|
|
646
|
+
});
|
|
647
|
+
});
|
|
648
|
+
|
|
649
|
+
describe('bringing the app forward for the menu bar', () => {
|
|
650
|
+
it('sends a Command shortcut once the app is in front', async () => {
|
|
651
|
+
const transport = start();
|
|
652
|
+
try {
|
|
653
|
+
await sendBehind();
|
|
654
|
+
const before = await observe(transport);
|
|
655
|
+
const pressed = Number(/\d+/.exec(element(before, 'text:status').title ?? '')?.[0] ?? 0);
|
|
656
|
+
const { result, state } = await act(transport, { action: 'press_key', key: 'super+j', repeat: 1, chord: chord('j', ['meta']) });
|
|
657
|
+
expect(result).toEqual({ outcome: 'delivered', method: 'input' });
|
|
658
|
+
expect(element(state ?? before, 'text:status').title).toBe(`Pressed ${pressed + 1}`);
|
|
659
|
+
expect(frontmost()).toBe('MoxxyComputerFixture');
|
|
660
|
+
} finally { await transport.close(); }
|
|
661
|
+
});
|
|
662
|
+
|
|
663
|
+
it('sends a Command shortcut to an app that has no open window', async () => {
|
|
664
|
+
const transport = start();
|
|
665
|
+
try {
|
|
666
|
+
await observe(transport);
|
|
667
|
+
await act(transport, { action: 'press_key', key: 'super+w', repeat: 1, chord: chord('w', ['meta']) });
|
|
668
|
+
const closed = await transport.request('get_app_state', { app: FIXTURE, screenshot: false }, signal()) as { tree: { elements: unknown[] } };
|
|
669
|
+
expect(closed.tree.elements).toEqual([]);
|
|
670
|
+
const { result } = await act(transport, { action: 'press_key', key: 'super+n', repeat: 1, chord: chord('n', ['meta']) });
|
|
671
|
+
expect(result).toEqual({ outcome: 'delivered', method: 'input' });
|
|
672
|
+
expect(element(await observe(transport), 'text:status').title).toMatch(/^Pressed|^Ready/);
|
|
673
|
+
} finally {
|
|
674
|
+
spawnSync('osascript', ['-e', 'tell application "System Events" to tell process "MoxxyComputerFixture" to click menu item "New Window" of menu 1 of menu bar item 2 of menu bar 1']);
|
|
675
|
+
await transport.close();
|
|
676
|
+
}
|
|
677
|
+
});
|
|
678
|
+
|
|
679
|
+
it('pastes rich text with Command-V and gives the user their clipboard back', async () => {
|
|
680
|
+
const clipboard = () => spawnSync('pbpaste').stdout.toString();
|
|
681
|
+
const transport = start();
|
|
682
|
+
try {
|
|
683
|
+
const before = clipboard();
|
|
684
|
+
await sendBehind();
|
|
685
|
+
const field = await name(transport);
|
|
686
|
+
await act(transport, { action: 'set_value', element_index: field.index, value: 'x' });
|
|
687
|
+
await act(transport, { action: 'select_text', element_index: field.index, text: 'x', selection_type: 'cursor_after' });
|
|
688
|
+
const rich = await act(transport, { action: 'paste', text: '<b>bold</b>', format: 'html' });
|
|
689
|
+
expect(rich.result).toEqual({ outcome: 'delivered', method: 'input' });
|
|
690
|
+
expect((await name(transport)).value).toBe('xbold');
|
|
691
|
+
expect(clipboard()).toBe(before);
|
|
692
|
+
} finally { await transport.close(); }
|
|
693
|
+
});
|
|
694
|
+
});
|
|
695
|
+
|
|
696
|
+
// The user stays in charge: their pause holds actions, their Escape stops, their hands keep the pointer.
|
|
697
|
+
describe('the user stays in charge', () => {
|
|
698
|
+
it('holds an action while paused and asks for a fresh look once resumed', async () => {
|
|
699
|
+
const states: string[] = [];
|
|
700
|
+
const transport = start((event) => { if (event.event === 'control_state') states.push(String(event.state)); });
|
|
701
|
+
try {
|
|
702
|
+
const before = await observe(transport);
|
|
703
|
+
const status = element(before, 'text:status').title;
|
|
704
|
+
transport.control('pause');
|
|
705
|
+
const held = act(transport, { action: 'click', element_index: element(before, 'button:press').index, mouse_button: 'left', click_count: 1 });
|
|
706
|
+
await new Promise((resolve) => setTimeout(resolve, 400));
|
|
707
|
+
expect(states).toEqual(['paused_by_user']);
|
|
708
|
+
transport.control('resume');
|
|
709
|
+
const { result, state } = await held;
|
|
710
|
+
expect(result).toMatchObject({ outcome: 'blocked', code: 'user_intervened' });
|
|
711
|
+
expect(state && element(state, 'text:status').title).toBe(status);
|
|
712
|
+
expect(states).toEqual(['paused_by_user', 'recovering']);
|
|
713
|
+
} finally { await transport.close(); }
|
|
714
|
+
});
|
|
715
|
+
|
|
716
|
+
it('lets the user take over: the held key is let go, the cursor leaves and stays away until resumed', async () => {
|
|
717
|
+
const cursors: unknown[] = [];
|
|
718
|
+
const transport = start((event) => { if (event.event === 'cursor') cursors.push(event.cursor); });
|
|
719
|
+
try {
|
|
720
|
+
await observe(transport);
|
|
721
|
+
const started = Date.now();
|
|
722
|
+
const holding = act(transport, { action: 'hold_key', key: 'shift', duration_s: 5, chord: chord(null, ['shift']) });
|
|
723
|
+
await new Promise((resolve) => setTimeout(resolve, 600));
|
|
724
|
+
transport.control('takeover');
|
|
725
|
+
const held = await holding;
|
|
726
|
+
expect(Date.now() - started).toBeLessThan(3000);
|
|
727
|
+
expect(held.state && element(held.state, 'text:keys').title).toMatch(/^Held key 56 for [0-2]\.\d s$/);
|
|
728
|
+
// The model may look while the user has control; the cursor does not come back for it.
|
|
729
|
+
const hidden = cursors.lastIndexOf(null);
|
|
730
|
+
expect(hidden).toBeGreaterThanOrEqual(0);
|
|
731
|
+
await observe(transport);
|
|
732
|
+
expect(cursors.slice(hidden + 1)).toEqual([]);
|
|
733
|
+
transport.control('resume');
|
|
734
|
+
await observe(transport);
|
|
735
|
+
expect(cursors.at(-1)).toMatchObject({ phase: 'idle' });
|
|
736
|
+
} finally { await transport.close(); }
|
|
737
|
+
});
|
|
738
|
+
|
|
739
|
+
it('still works in the background while the user moves the mouse, and holds real pointer input back', async () => {
|
|
740
|
+
const transport = start();
|
|
741
|
+
// Unmarked moves at the pointer's own position: the user's hand, without moving anything on screen.
|
|
742
|
+
const hand = spawn('osascript', ['-l', 'JavaScript', '-e',
|
|
743
|
+
'ObjC.import("CoreGraphics"); for (let i = 0; i < 160; i++) { const at = $.CGEventGetLocation($.CGEventCreate(null)); $.CGEventPost($.kCGHIDEventTap, $.CGEventCreateMouseEvent(null, $.kCGEventMouseMoved, at, 0)); delay(0.05); }']);
|
|
744
|
+
try {
|
|
745
|
+
const before = await look(transport);
|
|
746
|
+
const point = centre(before, 'group:pad');
|
|
747
|
+
const sent = await act(transport, { action: 'click', ...point, mouse_button: 'left', click_count: 3 });
|
|
748
|
+
expect(sent.result).toEqual({ outcome: 'delivered', method: 'background' });
|
|
749
|
+
expect(pad(sent.state)).toBe('Pad up 3 after 0 moves');
|
|
750
|
+
const { result, state } = await act(transport, { action: 'click', ...point, mouse_button: 'right', click_count: 1 });
|
|
751
|
+
expect(result).toMatchObject({ outcome: 'blocked', code: 'user_intervened' });
|
|
752
|
+
expect(pad(state)).toBe(pad(sent.state));
|
|
753
|
+
} finally { stop(hand); await transport.close(); }
|
|
754
|
+
});
|
|
755
|
+
|
|
756
|
+
it('stops when the user presses Escape', async () => {
|
|
757
|
+
const transport = start();
|
|
758
|
+
try {
|
|
759
|
+
await observe(transport);
|
|
760
|
+
// Escape goes to the app in front, so the fixture takes it, not the user's work.
|
|
761
|
+
spawnSync('osascript', ['-e', `tell application id "${FIXTURE}" to activate`]);
|
|
762
|
+
spawnSync('osascript', ['-e', 'tell application "System Events" to key code 53']);
|
|
763
|
+
for (let attempt = 0; attempt < 40 && !transport.closed; attempt += 1) await new Promise((resolve) => setTimeout(resolve, 50));
|
|
764
|
+
expect(transport.stoppedByUser).toBe(true);
|
|
765
|
+
} finally { await transport.close(); }
|
|
766
|
+
});
|
|
767
|
+
});
|
|
768
|
+
|
|
769
|
+
// What a save dialog writes can run later on its own, so its destination is checked before every step.
|
|
770
|
+
describe('save dialogs', () => {
|
|
771
|
+
const sheet = (command: string) => spawnSync('osascript', ['-e', `tell application "System Events" to tell splitter group 1 of sheet 1 of window 1 of process "MoxxyComputerFixture" to ${command}`]);
|
|
772
|
+
|
|
773
|
+
it('refuses to name, type or confirm a save into a protected place, and lets the user cancel', async () => {
|
|
774
|
+
const transport = start();
|
|
775
|
+
try {
|
|
776
|
+
const opened = await act(transport, { action: 'click', element_index: element(await observe(transport), 'button:save').index, mouse_button: 'left', click_count: 1 });
|
|
777
|
+
const dialog = opened.state ?? await observe(transport);
|
|
778
|
+
const field = element(dialog, ':saveAsNameTextField');
|
|
779
|
+
const typed = await act(transport, { action: 'type_text', element_index: field.index, text: '.zshrc' });
|
|
780
|
+
expect(typed.result).toMatchObject({ outcome: 'blocked', code: 'protected_path' });
|
|
781
|
+
const named = await act(transport, { action: 'set_value', element_index: field.index, value: 'authorized_keys' });
|
|
782
|
+
expect(named.result).toMatchObject({ outcome: 'blocked', code: 'protected_path' });
|
|
783
|
+
const fine = await act(transport, { action: 'set_value', element_index: field.index, value: 'Report' });
|
|
784
|
+
expect(fine.result).toEqual({ outcome: 'delivered', method: 'ax' });
|
|
785
|
+
// A name typed by someone else is still checked when the model confirms the save.
|
|
786
|
+
sheet('set value of text field 1 to ".bashrc"');
|
|
787
|
+
const saved = await act(transport, { action: 'click', element_index: element(dialog, ':OKButton').index, mouse_button: 'left', click_count: 1 });
|
|
788
|
+
expect(saved.result).toMatchObject({ outcome: 'blocked', code: 'protected_path' });
|
|
789
|
+
const cancelled = await act(transport, { action: 'click', element_index: element(saved.state ?? dialog, ':CancelButton').index, mouse_button: 'left', click_count: 1 });
|
|
790
|
+
expect(element(cancelled.state ?? dialog, 'text:status').title).toBe('Not saved');
|
|
791
|
+
} finally {
|
|
792
|
+
sheet('click button "Cancel"');
|
|
793
|
+
await transport.close();
|
|
794
|
+
}
|
|
795
|
+
});
|
|
796
|
+
});
|
|
797
|
+
|
|
798
|
+
describe('batches, full-screen capture and zoom', () => {
|
|
799
|
+
// Brightness of one pixel of a JPEG, read through AppKit.
|
|
800
|
+
const brightness = (base64: string, x: number, y: number) => {
|
|
801
|
+
const file = join(mkdtempSync(join(tmpdir(), 'moxxy-shot-')), 'shot.jpg');
|
|
802
|
+
writeFileSync(file, Buffer.from(base64, 'base64'));
|
|
803
|
+
const script = `ObjC.import("AppKit"); const rep = $.NSBitmapImageRep.imageRepWithData($.NSData.dataWithContentsOfFile("${file}")); const c = rep.colorAtXY(${x}, ${y}); c.redComponent + c.greenComponent + c.blueComponent`;
|
|
804
|
+
return Number(spawnSync('osascript', ['-l', 'JavaScript', '-e', script]).stdout.toString().trim());
|
|
805
|
+
};
|
|
806
|
+
|
|
807
|
+
it('runs steps until the first one that is not delivered and returns the state once', async () => {
|
|
808
|
+
const transport = start();
|
|
809
|
+
try {
|
|
810
|
+
const before = await observe(transport);
|
|
811
|
+
// The fixture counts presses for its whole life; earlier tests may have left another status.
|
|
812
|
+
const pressed = /^Pressed (\d+)$/.exec(element(before, 'text:status').title ?? '');
|
|
813
|
+
const actions = [
|
|
814
|
+
{ action: 'click', element_index: element(before, 'button:press').index, mouse_button: 'left', click_count: 1 },
|
|
815
|
+
{ action: 'set_value', element_index: element(before, 'text field:name').index, value: 'batched' },
|
|
816
|
+
{ action: 'click', element_index: 9999, mouse_button: 'left', click_count: 1 },
|
|
817
|
+
{ action: 'set_value', element_index: element(before, 'text field:name').index, value: 'never' },
|
|
818
|
+
];
|
|
819
|
+
const { results, state } = batchResultSchema.parse(await transport.request('batch', { app: FIXTURE, actions, allowed: [FIXTURE] }, signal()));
|
|
820
|
+
expect(results).toEqual([{ outcome: 'delivered', method: 'ax' }, { outcome: 'delivered', method: 'ax' }, { outcome: 'blocked', code: 'stale_state' }]);
|
|
821
|
+
const status = element(state ?? before, 'text:status').title ?? '';
|
|
822
|
+
expect(status).toMatch(/^Pressed \d+$/);
|
|
823
|
+
if (pressed) expect(status).toBe(`Pressed ${Number(pressed[1]) + 1}`);
|
|
824
|
+
expect(element(state ?? before, 'text field:name').value).toBe('batched');
|
|
825
|
+
await expect(transport.request('batch', { app: FIXTURE, actions, allowed: ['com.apple.TextEdit'] }, signal())).rejects.toMatchObject({ code: 'app_not_allowed' });
|
|
826
|
+
} finally { await transport.close(); }
|
|
827
|
+
});
|
|
828
|
+
|
|
829
|
+
it('captures the display with only granted apps visible, within the image budget', async () => {
|
|
830
|
+
const transport = start();
|
|
831
|
+
try {
|
|
832
|
+
await expect(transport.request('zoom', { region: [0, 0, 10, 10], allowed: [FIXTURE] }, signal())).rejects.toMatchObject({ code: 'no_state' });
|
|
833
|
+
await observe(transport);
|
|
834
|
+
const shot = imageSchema.parse(await transport.request('screenshot', { allowed: [FIXTURE] }, signal()));
|
|
835
|
+
const [width, height] = imageBudget(shot.width, shot.height);
|
|
836
|
+
expect([shot.width, shot.height]).toEqual([width, height]);
|
|
837
|
+
// The menu bar and every other app stay out: the top edge right of the window is empty.
|
|
838
|
+
expect(brightness(shot.base64, shot.width - 5, 5)).toBeLessThan(0.05);
|
|
839
|
+
// The granted window stays where it is on screen, so the image maps to screen points.
|
|
840
|
+
const [screenWidth, x, y, w, h] = spawnSync('osascript', ['-l', 'JavaScript', '-e',
|
|
841
|
+
'ObjC.import("AppKit"); const se = Application("System Events"); const win = se.processes.byName("MoxxyComputerFixture").windows[0]; const [x, y] = win.position(); const [w, h] = win.size(); [$.NSScreen.mainScreen.frame.size.width, x, y, w, h].join(",")',
|
|
842
|
+
]).stdout.toString().trim().split(',').map(Number);
|
|
843
|
+
const ratio = shot.width / (screenWidth ?? NaN);
|
|
844
|
+
expect(brightness(shot.base64, Math.round(((x ?? 0) + (w ?? 0) / 2) * ratio), Math.round(((y ?? 0) + (h ?? 0) / 2) * ratio))).toBeGreaterThan(0.1);
|
|
845
|
+
const half = imageSchema.parse(await transport.request('screenshot', { allowed: [FIXTURE], scale: 0.5 }, signal()));
|
|
846
|
+
expect(half.width).toBe(Math.round(shot.width / 2));
|
|
847
|
+
const zoomed = imageSchema.parse(await transport.request('zoom', { region: [0, 0, 100, 50], allowed: [FIXTURE] }, signal()));
|
|
848
|
+
expect(zoomed.width / zoomed.height).toBeCloseTo(2, 1);
|
|
849
|
+
} finally { await transport.close(); }
|
|
850
|
+
});
|
|
851
|
+
|
|
852
|
+
it('reads the text the screenshot shows where accessibility names the control otherwise, at the place a click hits', async () => {
|
|
853
|
+
const transport = start();
|
|
854
|
+
try {
|
|
855
|
+
const state = await look(transport);
|
|
856
|
+
// The fixture paints "Add title" on a control accessibility calls "Title, Heading", as Canva's panel does.
|
|
857
|
+
expect(state.tree.elements.some((listed) => /add title/i.test(`${listed.title ?? ''} ${listed.description ?? ''}`))).toBe(false);
|
|
858
|
+
const { lines } = readTextResultSchema.parse(await transport.request('read_text', { app: FIXTURE, allowed: [FIXTURE] }, signal()));
|
|
859
|
+
const painted = lines.find((line) => /^add title$/i.test(line.text.trim()));
|
|
860
|
+
if (!painted) throw new Error(`no "Add title" among ${JSON.stringify(lines.map((line) => line.text))}`);
|
|
861
|
+
const point = { x: Math.round(painted.x + painted.width / 2), y: Math.round(painted.y + painted.height / 2) };
|
|
862
|
+
const { state: after } = await act(transport, { action: 'click', ...point, mouse_button: 'left', click_count: 1 });
|
|
863
|
+
expect(element(after ?? state, 'text:status').title).toBe('Painted clicked');
|
|
864
|
+
await expect(transport.request('read_text', { app: FIXTURE, allowed: [] }, signal())).rejects.toMatchObject({ code: 'app_not_allowed' });
|
|
865
|
+
} finally { await transport.close(); }
|
|
866
|
+
});
|
|
867
|
+
|
|
868
|
+
it('zooms into a region of the app screenshot at a closer look and refuses one outside it', async () => {
|
|
869
|
+
const transport = start();
|
|
870
|
+
try {
|
|
871
|
+
const state = await look(transport);
|
|
872
|
+
const { frame } = element(state, 'button:press');
|
|
873
|
+
if (!frame || !state.screenshot) throw new Error('no frame');
|
|
874
|
+
const region = [frame.x, frame.y, frame.x + frame.width, frame.y + frame.height];
|
|
875
|
+
const zoomed = imageSchema.parse(await transport.request('zoom', { region, app: FIXTURE, allowed: [FIXTURE] }, signal()));
|
|
876
|
+
// Native resolution: at least as many pixels as the region had in the screenshot.
|
|
877
|
+
expect(zoomed.width).toBeGreaterThanOrEqual(frame.width);
|
|
878
|
+
const outside = [0, 0, state.screenshot.width + 1, 10];
|
|
879
|
+
await expect(transport.request('zoom', { region: outside, app: FIXTURE, allowed: [FIXTURE] }, signal())).rejects.toMatchObject({ code: 'point_outside_frame' });
|
|
880
|
+
await expect(transport.request('zoom', { region, app: FIXTURE, allowed: [] }, signal())).rejects.toMatchObject({ code: 'app_not_allowed' });
|
|
881
|
+
} finally { await transport.close(); }
|
|
882
|
+
});
|
|
883
|
+
});
|
|
884
|
+
|
|
885
|
+
describe('live preview for the human', () => {
|
|
886
|
+
type Preview = { event: string; seq: number; image?: { mediaType: string; base64: string; width: number; height: number }; error?: string };
|
|
887
|
+
const until = async (done: () => boolean, ms: number) => {
|
|
888
|
+
const deadline = Date.now() + ms;
|
|
889
|
+
while (!done() && Date.now() < deadline) await new Promise((resolve) => setTimeout(resolve, 50));
|
|
890
|
+
return done();
|
|
891
|
+
};
|
|
892
|
+
|
|
893
|
+
it('streams the observed window as small JPEG frames, follows changes, keeps saying it is alive, and stops', async () => {
|
|
894
|
+
const previews: Preview[] = [];
|
|
895
|
+
const transport = start((event) => { if (event.event === 'preview_frame') previews.push(event as unknown as Preview); });
|
|
896
|
+
const pictures = () => previews.filter((preview) => preview.image);
|
|
897
|
+
try {
|
|
898
|
+
const before = await observe(transport);
|
|
899
|
+
await transport.request('preview.start', { fps: 5 }, signal());
|
|
900
|
+
expect(await until(() => pictures().length > 0, 5000)).toBe(true);
|
|
901
|
+
const first = pictures()[0];
|
|
902
|
+
expect(first?.image?.mediaType).toBe('image/jpeg');
|
|
903
|
+
expect(Buffer.from(first?.image?.base64 ?? '', 'base64').subarray(0, 2)).toEqual(Buffer.from([0xff, 0xd8]));
|
|
904
|
+
expect(Math.max(first?.image?.width ?? 0, first?.image?.height ?? 0)).toBeLessThanOrEqual(960);
|
|
905
|
+
expect(previews.every((preview) => preview.error === undefined)).toBe(true);
|
|
906
|
+
|
|
907
|
+
const seen = pictures().length;
|
|
908
|
+
await act(transport, { action: 'click', element_index: element(before, 'button:press').index, mouse_button: 'left', click_count: 1 });
|
|
909
|
+
expect(await until(() => pictures().length > seen, 5000)).toBe(true);
|
|
910
|
+
expect(pictures().at(-1)?.image?.base64).not.toBe(first?.image?.base64);
|
|
911
|
+
const sequence = previews.map((preview) => preview.seq);
|
|
912
|
+
expect(sequence).toEqual([...sequence].sort((a, b) => a - b));
|
|
913
|
+
|
|
914
|
+
// A window that does not change still reports that capture is running.
|
|
915
|
+
const quiet = previews.length;
|
|
916
|
+
expect(await until(() => previews.slice(quiet).some((preview) => !preview.image), 4000)).toBe(true);
|
|
917
|
+
|
|
918
|
+
await transport.request('preview.stop', {}, signal());
|
|
919
|
+
const stopped = previews.length;
|
|
920
|
+
await new Promise((resolve) => setTimeout(resolve, 1500));
|
|
921
|
+
expect(previews.length).toBe(stopped);
|
|
922
|
+
} finally { await transport.close(); }
|
|
923
|
+
});
|
|
924
|
+
|
|
925
|
+
it('streams the window as H.264 when asked: a key frame first, and a new one on request', async () => {
|
|
926
|
+
type Chunk = { event: string; seq: number; key: boolean; codec: string; data: string; width: number; height: number; timestamp: number };
|
|
927
|
+
const chunks: Chunk[] = [];
|
|
928
|
+
const pictures: Preview[] = [];
|
|
929
|
+
const transport = start((event) => {
|
|
930
|
+
if (event.event === 'preview_chunk') chunks.push(event as unknown as Chunk);
|
|
931
|
+
if (event.event === 'preview_frame') pictures.push(event as unknown as Preview);
|
|
932
|
+
});
|
|
933
|
+
try {
|
|
934
|
+
await observe(transport);
|
|
935
|
+
await transport.request('preview.start', { fps: 5, codec: 'h264' }, signal());
|
|
936
|
+
expect(await until(() => chunks.length > 0, 5000)).toBe(true);
|
|
937
|
+
const first = chunks[0];
|
|
938
|
+
expect(first?.key).toBe(true);
|
|
939
|
+
expect(first?.codec).toMatch(/^avc1\.[0-9a-f]{6}$/);
|
|
940
|
+
const bytes = Buffer.from(first?.data ?? '', 'base64');
|
|
941
|
+
expect(bytes.subarray(0, 4)).toEqual(Buffer.from([0, 0, 0, 1]));
|
|
942
|
+
// The stream starts with its sequence parameter set, so a decoder needs nothing else.
|
|
943
|
+
expect((bytes[4] ?? 0) & 0x1f).toBe(7);
|
|
944
|
+
expect(Math.max(first?.width ?? 0, first?.height ?? 0)).toBeLessThanOrEqual(960);
|
|
945
|
+
expect((first?.width ?? 1) % 2).toBe(0);
|
|
946
|
+
|
|
947
|
+
// The window is still, so nothing new is encoded until a viewer needs to start decoding.
|
|
948
|
+
const seen = chunks.length;
|
|
949
|
+
await transport.request('preview.keyframe', {}, signal());
|
|
950
|
+
expect(await until(() => chunks.slice(seen).some((chunk) => chunk.key), 5000)).toBe(true);
|
|
951
|
+
expect(chunks.map((chunk) => chunk.seq)).toEqual([...chunks.map((chunk) => chunk.seq)].sort((a, b) => a - b));
|
|
952
|
+
// Video replaces the pictures; only "still running" frames without a picture remain.
|
|
953
|
+
expect(pictures.filter((preview) => preview.image)).toEqual([]);
|
|
954
|
+
|
|
955
|
+
await expect(transport.request('preview.start', { codec: 'vp9' }, signal())).rejects.toMatchObject({ code: 'invalid_params' });
|
|
956
|
+
await transport.request('preview.stop', {}, signal());
|
|
957
|
+
} finally { await transport.close(); }
|
|
958
|
+
});
|
|
959
|
+
|
|
960
|
+
it('starts capturing once a window is observed when the preview was asked for first', async () => {
|
|
961
|
+
const previews: Preview[] = [];
|
|
962
|
+
const transport = start((event) => { if (event.event === 'preview_frame') previews.push(event as unknown as Preview); });
|
|
963
|
+
try {
|
|
964
|
+
await transport.request('preview.start', {}, signal());
|
|
965
|
+
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
966
|
+
expect(previews.filter((preview) => preview.image)).toEqual([]);
|
|
967
|
+
await observe(transport);
|
|
968
|
+
expect(await until(() => previews.some((preview) => preview.image), 5000)).toBe(true);
|
|
969
|
+
} finally { await transport.close(); }
|
|
970
|
+
});
|
|
971
|
+
});
|
|
972
|
+
|
|
973
|
+
it('asks for an observation before the first action', async () => {
|
|
974
|
+
const transport = start();
|
|
975
|
+
try {
|
|
976
|
+
const { result } = await act(transport, { action: 'click', element_index: 0, mouse_button: 'left', click_count: 1 });
|
|
977
|
+
expect(result).toEqual({ outcome: 'blocked', code: 'no_state' });
|
|
978
|
+
} finally { await transport.close(); }
|
|
979
|
+
});
|
|
980
|
+
});
|
|
981
|
+
|
|
982
|
+
it('refuses an app it cannot find', async () => {
|
|
983
|
+
const transport = start();
|
|
984
|
+
try {
|
|
985
|
+
await expect(transport.request('get_app_state', { app: 'ai.moxxy.no-such-app', screenshot: false }, signal())).rejects.toMatchObject({ code: 'app_not_found' });
|
|
986
|
+
} finally { await transport.close(); }
|
|
987
|
+
});
|
|
988
|
+
});
|