* Stop two web E2E tests from racing on main Replies read themselves aloud by default since the voice update, so in the group chat test the last bot's automatic read-aloud could beat the manual Speak click. The test turns automatic reading off for its two bots. Opening the browser panel on a running computer now only fetches the screen, as it did before the panel change, instead of also reloading the whole thread on every computer state change. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TnT7eVHuaCzZKY5CnYzpr1 * Wait for the speaking switch to settle in the voice test The app turns the local voice on by itself on first start and keeps the switch disabled meanwhile. The helper read "off" during that window and clicked a disabled switch; it now retries until the switch is on, clicking only when it is enabled and off. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TnT7eVHuaCzZKY5CnYzpr1 * Fix main after the git-clone push: lint, i18n and abort tests - Lint: formatting in two files and no assignment inside an expression in the steering poller. - i18n: German for the new "Resume" and "Stopped" labels on web, and de/ru/zh on mobile; catalogs re-extracted. - Tests follow the intended change that a caller abort ends a turn as "stopped" instead of failing it as a provider error. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TnT7eVHuaCzZKY5CnYzpr1 * Record runtime failures again instead of leaving the run running A runtime error closes the stream through withRuntimeCleanup, which aborts the run's own controller. The catch treated that as an outside stop and returned without finalizing, so a failed run stayed running forever and its delegated task never reported back. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TnT7eVHuaCzZKY5CnYzpr1 --------- Co-authored-by: Claude <noreply@anthropic.com>
294 lines
9.8 KiB
TypeScript
294 lines
9.8 KiB
TypeScript
import { readFile } from "node:fs/promises";
|
|
import type { PortableFile } from "@rakazo/adapter-kit";
|
|
import { FakeSandboxProvider } from "@rakazo/adapters";
|
|
import { describe, expect, it } from "vitest";
|
|
import {
|
|
assertContactsExport,
|
|
type ContactsRecordedStep,
|
|
type ContactsRecording,
|
|
contactsReplaySteps,
|
|
createContactsRecorder,
|
|
createContactsReplayBrowser,
|
|
executeContactsJourney,
|
|
parseContactsRecording,
|
|
replayContactsRecording,
|
|
} from "./computer-recording.js";
|
|
import { computerReplayContext } from "./computer-replay.js";
|
|
import {
|
|
CONTACTS_PATH,
|
|
ContactsBrowserFixture,
|
|
EXPORT_FIXTURE_URL,
|
|
EXPORT_RECEIPT_PATH,
|
|
} from "./computer-replay-fixture.js";
|
|
import { startModelEmulator } from "./model-emulator.js";
|
|
|
|
const openDialog: ContactsRecordedStep[] = [
|
|
{ op: "navigate", outcome: "ok" },
|
|
{ op: "snapshot", outcome: "ok" },
|
|
{ op: "click", target: "Export contacts", outcome: "ok" },
|
|
{ op: "snapshot", outcome: "ok" },
|
|
];
|
|
const recorded = (steps: ContactsRecordedStep[]): ContactsRecording => ({
|
|
version: 1,
|
|
scenario: "contacts-export",
|
|
source: "authored",
|
|
steps,
|
|
});
|
|
|
|
async function setup() {
|
|
const sandbox = new FakeSandboxProvider();
|
|
const context = computerReplayContext();
|
|
const computer = await sandbox.provision({ botId: "fixture-bot", homePath: "/fixture" }, context);
|
|
const browser = new ContactsBrowserFixture(sandbox);
|
|
return {
|
|
sandbox,
|
|
context,
|
|
computer,
|
|
browser,
|
|
async close() {
|
|
browser.close();
|
|
await sandbox.destroy(computer, context);
|
|
},
|
|
};
|
|
}
|
|
|
|
describe("sanitized real-model computer recordings", () => {
|
|
it("replays the captured Luna/OpenRouter + Docker export with an independent artifact oracle", async () => {
|
|
const recording = parseContactsRecording(
|
|
JSON.parse(
|
|
await readFile(new URL("./fixtures/contacts-luna-docker.json", import.meta.url), "utf8"),
|
|
),
|
|
);
|
|
expect(recording.source).toBe("luna-openrouter-docker");
|
|
const fixture = await setup();
|
|
try {
|
|
await replayContactsRecording(
|
|
recording,
|
|
fixture.sandbox,
|
|
fixture.browser,
|
|
fixture.computer,
|
|
fixture.context,
|
|
);
|
|
await assertContactsExport(fixture.sandbox, fixture.computer, fixture.context);
|
|
} finally {
|
|
await fixture.close();
|
|
}
|
|
});
|
|
|
|
it("rejects raw metadata and unexpected operations instead of relying on secret-pattern matching", () => {
|
|
const safe = recorded(openDialog);
|
|
for (const extra of [
|
|
"apiKey",
|
|
"privateUrl",
|
|
"screenshot",
|
|
"modelText",
|
|
"userId",
|
|
"timestamp",
|
|
]) {
|
|
expect(() =>
|
|
parseContactsRecording({ ...safe, [extra]: "private fixture canary" }),
|
|
).toThrow();
|
|
expect(() =>
|
|
parseContactsRecording({
|
|
...safe,
|
|
steps: [{ ...openDialog[0], [extra]: "private fixture canary" }],
|
|
}),
|
|
).toThrow();
|
|
}
|
|
expect(() =>
|
|
parseContactsRecording(
|
|
recorded([{ op: "click", target: "private fixture canary", outcome: "ok" } as never]),
|
|
),
|
|
).toThrow();
|
|
expect(() => parseContactsRecording({ ...safe, source: "private fixture canary" })).toThrow();
|
|
});
|
|
|
|
it("projects transient refs to fixed button names and refuses uncontrolled tool arguments before effects", async () => {
|
|
const fixture = await setup();
|
|
const recorder = createContactsRecorder(
|
|
fixture.sandbox,
|
|
fixture.browser,
|
|
fixture.computer,
|
|
fixture.context,
|
|
);
|
|
try {
|
|
await expect(
|
|
recorder.executeTool("browser_navigate", { url: "https://private.example.test/" }),
|
|
).rejects.toThrow();
|
|
await expect(recorder.executeTool("read_file", { path: "secrets.txt" })).rejects.toThrow();
|
|
await expect(recorder.executeTool("shell", { command: "echo private" })).rejects.toThrow();
|
|
expect(recorder.decisions()).toEqual([]);
|
|
await recorder.executeTool("browser_navigate", { url: EXPORT_FIXTURE_URL });
|
|
const snapshot = (await recorder.executeTool("browser_snapshot", {})) as {
|
|
elements: Array<{ name: string; ref: string }>;
|
|
};
|
|
const ref = snapshot.elements.find((node) => node.name === "Export contacts")!.ref;
|
|
await recorder.executeTool("browser_act", { actions: [{ kind: "click", ref }] });
|
|
const recording = recorder.recording("authored");
|
|
expect(recording.steps).toEqual(openDialog.slice(0, 3));
|
|
expect(JSON.stringify(recording)).not.toContain(ref);
|
|
await expect(
|
|
fixture.sandbox.readFile(fixture.computer, CONTACTS_PATH, fixture.context),
|
|
).rejects.toThrow();
|
|
} finally {
|
|
await fixture.close();
|
|
}
|
|
});
|
|
|
|
it("reproduces captured download failures during promotion and exports exactly once", async () => {
|
|
const fixture = await setup();
|
|
const recording = recorded([
|
|
...openDialog,
|
|
{ op: "click", target: "Download CSV", outcome: "error" },
|
|
{ op: "snapshot", outcome: "ok" },
|
|
{ op: "click", target: "Download CSV", outcome: "ok" },
|
|
{ op: "read", outcome: "ok" },
|
|
]);
|
|
const browser = createContactsReplayBrowser(fixture.sandbox, recording);
|
|
try {
|
|
await replayContactsRecording(
|
|
recording,
|
|
fixture.sandbox,
|
|
browser,
|
|
fixture.computer,
|
|
fixture.context,
|
|
);
|
|
await assertContactsExport(fixture.sandbox, fixture.computer, fixture.context);
|
|
} finally {
|
|
browser.close();
|
|
await fixture.close();
|
|
}
|
|
});
|
|
|
|
it("cancels at the export dialog without producing a download or receipt", async () => {
|
|
const fixture = await setup();
|
|
try {
|
|
await replayContactsRecording(
|
|
recorded([
|
|
...openDialog,
|
|
{ op: "click", target: "Cancel", outcome: "ok" },
|
|
{ op: "snapshot", outcome: "ok" },
|
|
]),
|
|
fixture.sandbox,
|
|
fixture.browser,
|
|
fixture.computer,
|
|
fixture.context,
|
|
);
|
|
for (const path of [CONTACTS_PATH, EXPORT_RECEIPT_PATH]) {
|
|
await expect(
|
|
fixture.sandbox.readFile(fixture.computer, path, fixture.context),
|
|
).rejects.toThrow();
|
|
}
|
|
} finally {
|
|
await fixture.close();
|
|
}
|
|
});
|
|
|
|
it("aborts a real Pi HTTP stream after the download and resumes without repeating the side effect", async () => {
|
|
const fixture = await setup();
|
|
const controller = new AbortController();
|
|
const interruptedContext = { ...fixture.context, signal: controller.signal };
|
|
const prefix = recorded([
|
|
...openDialog,
|
|
{ op: "click", target: "Download CSV", outcome: "ok" },
|
|
]);
|
|
const steps = contactsReplaySteps(prefix);
|
|
let held = false;
|
|
steps[steps.length - 1]!.response = {
|
|
type: "hold",
|
|
onOpen() {
|
|
held = true;
|
|
controller.abort();
|
|
},
|
|
};
|
|
const emulator = await startModelEmulator({ steps });
|
|
try {
|
|
const recorder = createContactsRecorder(
|
|
fixture.sandbox,
|
|
fixture.browser,
|
|
fixture.computer,
|
|
interruptedContext,
|
|
);
|
|
// A caller abort ends the turn as stopped, not as a provider error.
|
|
await executeContactsJourney(emulator.model, recorder, interruptedContext);
|
|
emulator.assertComplete();
|
|
expect(held).toBe(true);
|
|
expect(controller.signal.aborted).toBe(true);
|
|
expect(recorder.recording("authored")).toEqual(prefix);
|
|
await assertContactsExport(fixture.sandbox, fixture.computer, fixture.context);
|
|
await replayContactsRecording(
|
|
recorded([
|
|
{ op: "snapshot", outcome: "ok" },
|
|
{ op: "observe", outcome: "ok" },
|
|
{ op: "read", outcome: "ok" },
|
|
]),
|
|
fixture.sandbox,
|
|
fixture.browser,
|
|
fixture.computer,
|
|
{ ...fixture.context, operationId: "resumed-run" },
|
|
);
|
|
await assertContactsExport(fixture.sandbox, fixture.computer, fixture.context);
|
|
} finally {
|
|
await emulator.close();
|
|
await fixture.close();
|
|
}
|
|
});
|
|
|
|
it("restores a downloaded workspace into a replacement computer and verifies it without another export", async () => {
|
|
const fixture = await setup();
|
|
try {
|
|
await replayContactsRecording(
|
|
recorded([...openDialog, { op: "click", target: "Download CSV", outcome: "ok" }]),
|
|
fixture.sandbox,
|
|
fixture.browser,
|
|
fixture.computer,
|
|
fixture.context,
|
|
);
|
|
const files: PortableFile[] = [];
|
|
for await (const file of fixture.sandbox.exportWorkspace(fixture.computer, fixture.context))
|
|
files.push(file);
|
|
await fixture.sandbox.destroy(fixture.computer, fixture.context);
|
|
fixture.browser.close();
|
|
const replacement = await fixture.sandbox.provision(
|
|
{ botId: "replacement-bot", homePath: "/replacement" },
|
|
fixture.context,
|
|
);
|
|
const browser = new ContactsBrowserFixture(fixture.sandbox);
|
|
try {
|
|
expect(replacement.id).not.toBe(fixture.computer.id);
|
|
await fixture.sandbox.importWorkspace(
|
|
replacement,
|
|
(async function* () {
|
|
yield* files;
|
|
})(),
|
|
fixture.context,
|
|
);
|
|
await replayContactsRecording(
|
|
recorded([{ op: "read", outcome: "ok" }]),
|
|
fixture.sandbox,
|
|
browser,
|
|
replacement,
|
|
fixture.context,
|
|
);
|
|
await assertContactsExport(fixture.sandbox, replacement, fixture.context);
|
|
// A duplicate export after recovery must still be visible to the independent oracle.
|
|
await replayContactsRecording(
|
|
recorded([...openDialog, { op: "click", target: "Download CSV", outcome: "ok" }]),
|
|
fixture.sandbox,
|
|
browser,
|
|
replacement,
|
|
fixture.context,
|
|
);
|
|
await expect(
|
|
assertContactsExport(fixture.sandbox, replacement, fixture.context),
|
|
).rejects.toThrow(/exactly once/);
|
|
} finally {
|
|
browser.close();
|
|
await fixture.sandbox.destroy(replacement, fixture.context);
|
|
}
|
|
} finally {
|
|
await fixture.close();
|
|
}
|
|
});
|
|
});
|