mirror of
https://github.com/lahfir/agent-desktop.git
synced 2026-07-30 10:49:22 +00:00
Settle the Playwright-grade reliability contract in agent-desktop-core before the Windows/Linux adapters are built, so they inherit it instead of redesigning it. Every command now observes, waits, verifies, and reports honestly instead of firing blindly. Highlights: capability-supertrait split of PlatformAdapter with not_supported() defaults; canonical role/state vocabulary with live `is --property visible`; display enumeration (`list-displays`) and honest `--screen` with scale factor; truthful Automation permission; `native_id` identity spine; window-id-first resolution; serializable `LocatorQuery` with live `find`; default-on auto-wait before every ref action; three-way `hit_test` occlusion gate; `scroll_into_view` in core; core accessible-name precedence; typed `ActionStep` delivery tier; `ProcessState` and `APP_UNRESPONSIVE`; `LaunchOptions`; baseline-diff desktop signals (`wait --event`); typed clipboard (`Text`/`Image`/`FileUrls`); mouse modifier chords and `mouse-wheel`. Hardened through a 35-reviewer pass with independent validation and a green live e2e gate (109/0), plus a head-vs-main performance comparison harness. BREAKING CHANGE: default-on auto-wait changes the timing of every previously-untouched ref-action call (bounded 5000 ms default; `--timeout-ms 0` restores single-shot). `ENVELOPE_VERSION` is now `2.1` (adds the `APP_UNRESPONSIVE` code and process state in error details). FFI ABI major is `3` (append-only struct evolution; `wait --event` is intentionally not exposed over FFI). The legacy string clipboard API is removed in favor of typed content. `key-down`/`key-up` fail closed until daemon-owned held input exists. `close-app` verifies termination and the osascript fallback path is removed. `--text` matching is subtree containment: `find --text X --first` returns the outermost matching container.
173 lines
5.3 KiB
JavaScript
173 lines
5.3 KiB
JavaScript
const assert = require('node:assert/strict');
|
|
const { execFileSync } = require('node:child_process');
|
|
const {
|
|
chmodSync,
|
|
existsSync,
|
|
mkdtempSync,
|
|
mkdirSync,
|
|
readFileSync,
|
|
writeFileSync,
|
|
} = require('node:fs');
|
|
const { tmpdir } = require('node:os');
|
|
const { join } = require('node:path');
|
|
const { afterEach, test } = require('node:test');
|
|
|
|
const postinstall = require('../../npm/scripts/postinstall.js');
|
|
|
|
const roots = [];
|
|
|
|
afterEach(() => {
|
|
delete process.env.AGENT_DESKTOP_MACOS_HELPER_PATH;
|
|
for (const root of roots.splice(0)) {
|
|
postinstall.trashRecoverably(root);
|
|
}
|
|
});
|
|
|
|
function temporaryDirectory() {
|
|
const root = mkdtempSync(join(tmpdir(), 'agent-desktop-npm-test-'));
|
|
roots.push(root);
|
|
return root;
|
|
}
|
|
|
|
function archive(entries) {
|
|
const root = temporaryDirectory();
|
|
const source = join(root, 'source');
|
|
mkdirSync(source);
|
|
for (const [name, contents] of Object.entries(entries)) {
|
|
const path = join(source, name);
|
|
writeFileSync(path, contents, { mode: 0o755 });
|
|
chmodSync(path, 0o755);
|
|
}
|
|
const tarball = join(root, 'release.tar.gz');
|
|
execFileSync('tar', ['-czf', tarball, '-C', source, ...Object.keys(entries)]);
|
|
return { root, tarball };
|
|
}
|
|
|
|
function executable(contents) {
|
|
const root = temporaryDirectory();
|
|
const path = join(root, 'trash');
|
|
writeFileSync(path, contents, { mode: 0o755 });
|
|
return path;
|
|
}
|
|
|
|
function captureWarnings(run) {
|
|
const warnings = [];
|
|
const write = process.stderr.write;
|
|
process.stderr.write = (chunk) => {
|
|
warnings.push(String(chunk));
|
|
return true;
|
|
};
|
|
try {
|
|
run();
|
|
return warnings.join('');
|
|
} finally {
|
|
process.stderr.write = write;
|
|
}
|
|
}
|
|
|
|
test('checksum lookup requires an exact archive name', () => {
|
|
const hash = 'a'.repeat(64);
|
|
assert.equal(postinstall.checksumFor(`${hash} release.tar.gz\n`, 'release.tar.gz'), hash);
|
|
assert.throws(
|
|
() => postinstall.checksumFor(`${hash} old-release.tar.gz\n`, 'release.tar.gz'),
|
|
/Checksum entry missing/,
|
|
);
|
|
});
|
|
|
|
test('archive validation rejects missing and additional payloads', () => {
|
|
const valid = archive({
|
|
'agent-desktop': 'cli',
|
|
'agent-desktop-macos-helper': 'helper',
|
|
});
|
|
postinstall.validateArchive(valid.tarball);
|
|
|
|
const extra = archive({
|
|
'agent-desktop': 'cli',
|
|
'agent-desktop-macos-helper': 'helper',
|
|
'unexpected': 'payload',
|
|
});
|
|
assert.throws(() => postinstall.validateArchive(extra.tarball), /unexpected entries/);
|
|
});
|
|
|
|
test('archive installation preserves the exact paired executables', () => {
|
|
const payload = archive({
|
|
'agent-desktop': 'cli-build-v1',
|
|
'agent-desktop-macos-helper': 'helper-build-v1',
|
|
});
|
|
const destination = temporaryDirectory();
|
|
const binary = join(destination, 'agent-desktop-darwin-arm64');
|
|
const helper = join(destination, 'agent-desktop-macos-helper');
|
|
|
|
postinstall.installArchive(payload.tarball, binary, helper);
|
|
|
|
assert.equal(readFileSync(binary, 'utf8'), 'cli-build-v1');
|
|
assert.equal(readFileSync(helper, 'utf8'), 'helper-build-v1');
|
|
});
|
|
|
|
test('recoverable cleanup invokes trash and removes the original path', () => {
|
|
const target = temporaryDirectory();
|
|
const recovered = `${target}.recovered`;
|
|
roots.push(recovered);
|
|
const fakeTrash = executable('#!/bin/sh\nmv "$1" "$1.recovered"\n');
|
|
|
|
postinstall.trashRecoverably(target, fakeTrash);
|
|
assert.equal(existsSync(target), false);
|
|
assert.equal(existsSync(recovered), true);
|
|
});
|
|
|
|
test('recoverable cleanup retains artifacts and warns when trash is unavailable or fails', () => {
|
|
const unavailable = temporaryDirectory();
|
|
const failing = temporaryDirectory();
|
|
const fakeTrash = executable('#!/bin/sh\nexit 9\n');
|
|
|
|
for (const [target, command, reason] of [
|
|
[
|
|
unavailable,
|
|
'/definitely-missing-agent-desktop-trash',
|
|
'trash command is unavailable: /definitely-missing-agent-desktop-trash',
|
|
],
|
|
[failing, fakeTrash, 'trash exited with status 9'],
|
|
]) {
|
|
const warnings = captureWarnings(() =>
|
|
postinstall.trashRecoverably(target, command),
|
|
);
|
|
assert.equal(existsSync(target), true);
|
|
assert.ok(warnings.includes(`retained at ${target}:`));
|
|
assert.ok(warnings.includes(reason));
|
|
}
|
|
});
|
|
|
|
test('cleanup failure does not mask a successful archive install', () => {
|
|
const payload = archive({
|
|
'agent-desktop': 'cli-build-v2',
|
|
'agent-desktop-macos-helper': 'helper-build-v2',
|
|
});
|
|
const destination = temporaryDirectory();
|
|
const binary = join(destination, 'agent-desktop-darwin-arm64');
|
|
const helper = join(destination, 'agent-desktop-macos-helper');
|
|
|
|
const warnings = captureWarnings(() =>
|
|
postinstall.installArchive(
|
|
payload.tarball,
|
|
binary,
|
|
helper,
|
|
'/definitely-missing-agent-desktop-trash',
|
|
),
|
|
);
|
|
|
|
assert.equal(readFileSync(binary, 'utf8'), 'cli-build-v2');
|
|
assert.equal(readFileSync(helper, 'utf8'), 'helper-build-v2');
|
|
assert.match(warnings, /Could not move cleanup artifact to Trash; retained at .*\.extract-/);
|
|
const retained = warnings.match(/retained at (.*\.extract-[^:]+):/)?.[1];
|
|
assert.ok(retained);
|
|
assert.equal(existsSync(retained), true);
|
|
roots.push(retained);
|
|
});
|
|
|
|
test('custom helper override must be absolute', () => {
|
|
process.env.AGENT_DESKTOP_MACOS_HELPER_PATH = 'relative-helper';
|
|
assert.throws(
|
|
() => postinstall.customHelperPath('/tmp/agent-desktop'),
|
|
/must be an absolute path/,
|
|
);
|
|
});
|