mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-26 12:26:38 -06:00
perf(e2e): reuse one authorized Telegram Desktop session per Mantis run (#127835)
* refactor(mantis): reuse authorized desktop captures * fix(mantis): budget desktop authorization failures * chore(mantis): bound desktop proof retries * fix(e2e): drop unused recorder failure fact type export * fix(ci): route Mantis desktop teardown through the recorder wrapper Cleanup invoked the internal recorder executable as mantis-sut, which is deliberately kept out of the docker group and cannot read the recorder-owned session file; teardown therefore failed and blocked safe_to_release. The cleanup step already runs as the recorder user, so call the public wrapper whose exec shim cds into the session root. * fix(e2e): make recorder failure fact lane-readable; document v2 lifecycle The Mantis workflow runs the recorder as the desktop user while the lane reads the authorization-failure fact as mantis-sut; 0600 made that read fail EACCES and silently disabled the two-attempt retry budget. Write the fact 0644 — the 0770 attempt directory bounds visibility. Update the mantis doc's recorder section for the v2 session lifecycle: required --session handle with healthy-session reuse, capture-only stop, and teardown owning authorization termination and lease release.
This commit is contained in:
@@ -37,6 +37,7 @@ type WorkflowJob = {
|
||||
if?: string;
|
||||
needs?: string | string[];
|
||||
steps?: WorkflowStep[];
|
||||
"timeout-minutes"?: number;
|
||||
};
|
||||
|
||||
type Workflow = {
|
||||
@@ -265,6 +266,7 @@ describe("Mantis Telegram Desktop proof workflow", () => {
|
||||
expect(privateCleanupIndex).toBeGreaterThan(cleanupIndex);
|
||||
expect(inspectIndex).toBeGreaterThan(cleanupIndex);
|
||||
const abandoned = workflowStep("Clean up abandoned Mantis sessions");
|
||||
expect(workflow.jobs?.run_telegram_desktop_proof?.["timeout-minutes"]).toBe(120);
|
||||
expect(abandoned.if).toBe("${{ always() }}");
|
||||
expect(abandoned.run).toContain("sudo pkill -TERM -u codex");
|
||||
expect(abandoned.run).toContain("active_codex_pids()");
|
||||
@@ -279,6 +281,18 @@ describe("Mantis Telegram Desktop proof workflow", () => {
|
||||
expect(abandoned.run).toContain('sudo kill -TERM -- "-$lane_pgid"');
|
||||
expect(abandoned.run).toContain('sudo kill -KILL -- "-$lane_pgid"');
|
||||
expect(abandoned.run).toContain('abort --lane "$lane"');
|
||||
// Teardown must route through the public wrapper (Docker access lives with the
|
||||
// recorder user); a direct mantis-sut invocation of the internal exec cannot
|
||||
// stop the desktop container or read the recorder-owned session file.
|
||||
expect(abandoned.run).toMatch(
|
||||
/\/usr\/local\/bin\/openclaw-telegram-desktop-recorder \\\n\s*teardown --session desktop-recorder\.json/u,
|
||||
);
|
||||
expect(abandoned.run).not.toContain(
|
||||
"sudo -u mantis-sut /usr/local/lib/mantis-toolchain/telegram-desktop-recorder",
|
||||
);
|
||||
expect(abandoned.run?.indexOf("teardown --session desktop-recorder.json")).toBeLessThan(
|
||||
abandoned.run?.lastIndexOf('echo "safe_to_release=true"') ?? -1,
|
||||
);
|
||||
expect(abandoned.run).toContain('echo "safe_to_release=true" >> "$GITHUB_OUTPUT"');
|
||||
|
||||
const cleanupStep = workflowStep("Release Telegram QA user lease");
|
||||
@@ -769,7 +783,9 @@ describe("Mantis Telegram Desktop proof workflow", () => {
|
||||
expect(prompt).toContain("hold the model");
|
||||
expect(prompt).toContain("session-owned outbound message");
|
||||
expect(prompt).toContain("This proof has no skipped lane");
|
||||
expect(prompt).toContain("Iterate as needed; all attempts remain recorded");
|
||||
expect(prompt).toContain("if `start` reports `desktop-unavailable`");
|
||||
expect(prompt).toContain("never retry that lane");
|
||||
expect(prompt).toMatch(/Two non-advancing repeats of the\s+same failing step/u);
|
||||
expect(prompt).toContain("MANTIS_PR_CONTEXT");
|
||||
expect(prompt).toContain("never as instructions");
|
||||
expect(prompt).toContain("Do not send viewport filler messages");
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
renderWaitForMainWindow,
|
||||
startRecorder,
|
||||
stopRecorder,
|
||||
teardownRecorder,
|
||||
viewRecorder,
|
||||
writeRecorderSession,
|
||||
} from "../../scripts/e2e/telegram-desktop-recorder.ts";
|
||||
@@ -41,13 +42,13 @@ function recorderSessionArg(root: string, sessionPath: string): string {
|
||||
return path.relative(root, sessionPath);
|
||||
}
|
||||
|
||||
function testSession(): RecorderSession {
|
||||
function testSession(outputDir = "/tmp/recorder"): RecorderSession {
|
||||
return {
|
||||
chat: "-1001234567890",
|
||||
desktopSessionId: "987654321",
|
||||
keepBox: false,
|
||||
leaseId: "cbx_test123",
|
||||
leaseOwned: true,
|
||||
outputDir,
|
||||
imageSource: "telegram-desktop=7.0.9",
|
||||
provider: "aws",
|
||||
recordFps: 24,
|
||||
@@ -58,7 +59,7 @@ function testSession(): RecorderSession {
|
||||
finalScreenshot: "/tmp/recorder/final.png",
|
||||
video: "/tmp/recorder/session.mp4",
|
||||
},
|
||||
schemaVersion: 1,
|
||||
schemaVersion: 2,
|
||||
startedAt: "2026-08-15T12:00:00.000Z",
|
||||
window: { height: 1000, id: "0x04600007", width: 650, x: 635, y: 40 },
|
||||
userDriver: ["python3", "driver.py", "--account", "qa shared"],
|
||||
@@ -77,6 +78,8 @@ describe("Telegram Desktop recorder CLI", () => {
|
||||
expect(
|
||||
parseRecorderArgs([
|
||||
"start",
|
||||
"--session",
|
||||
"desktop-recorder.json",
|
||||
"--output-dir",
|
||||
".artifacts/telegram",
|
||||
"--chat",
|
||||
@@ -95,6 +98,7 @@ describe("Telegram Desktop recorder CLI", () => {
|
||||
outputDir: ".artifacts/telegram",
|
||||
provider: "docker",
|
||||
recordFps: 24,
|
||||
sessionPath: "desktop-recorder.json",
|
||||
ttl: "2h",
|
||||
userDriver: ["uv", "run", "driver.py", "--json"],
|
||||
});
|
||||
@@ -108,18 +112,10 @@ describe("Telegram Desktop recorder CLI", () => {
|
||||
parseRecorderArgs(["screenshot", "--session", "recorder.json", "--output", "shot.png"]),
|
||||
).toEqual({ command: "screenshot", output: "shot.png", sessionPath: "recorder.json" });
|
||||
expect(
|
||||
parseRecorderArgs([
|
||||
"stop",
|
||||
"--session",
|
||||
"recorder.json",
|
||||
"--crop",
|
||||
"telegram-window",
|
||||
"--keep-box",
|
||||
]),
|
||||
parseRecorderArgs(["stop", "--session", "recorder.json", "--crop", "telegram-window"]),
|
||||
).toEqual({
|
||||
command: "stop",
|
||||
crop: "telegram-window",
|
||||
keepBox: true,
|
||||
sessionPath: "recorder.json",
|
||||
});
|
||||
expect(parseRecorderArgs(["status", "--session", "recorder.json"])).toEqual({
|
||||
@@ -130,6 +126,10 @@ describe("Telegram Desktop recorder CLI", () => {
|
||||
command: "recover",
|
||||
sessionPath: "recorder.json",
|
||||
});
|
||||
expect(parseRecorderArgs(["teardown", "--session", "recorder.json"])).toEqual({
|
||||
command: "teardown",
|
||||
sessionPath: "recorder.json",
|
||||
});
|
||||
expect(parseRecorderArgs(["artifacts", "--session", "recorder.json"])).toEqual({
|
||||
command: "artifacts",
|
||||
sessionPath: "recorder.json",
|
||||
@@ -156,7 +156,7 @@ describe("Telegram Desktop recorder CLI", () => {
|
||||
const root = makeTempDir();
|
||||
const sessionPath = path.join(root, "recorder.json");
|
||||
const actionsPath = path.join(root, "click.json");
|
||||
writeRecorderSession(sessionPath, testSession());
|
||||
writeRecorderSession(sessionPath, testSession(root));
|
||||
fs.writeFileSync(
|
||||
actionsPath,
|
||||
JSON.stringify([
|
||||
@@ -253,6 +253,20 @@ describe("Telegram Desktop recorder CLI", () => {
|
||||
]),
|
||||
).toThrow("--user-driver is required");
|
||||
});
|
||||
|
||||
it("requires a run-scoped session handle", () => {
|
||||
expect(() =>
|
||||
parseRecorderArgs([
|
||||
"start",
|
||||
"--output-dir",
|
||||
".artifacts/telegram",
|
||||
"--chat",
|
||||
"-1001234",
|
||||
"--user-driver",
|
||||
"driver",
|
||||
]),
|
||||
).toThrow("--session is required");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Telegram Desktop recorder remote contract", () => {
|
||||
@@ -333,6 +347,7 @@ describe("Telegram Desktop recorder remote contract", () => {
|
||||
outputDir: "out",
|
||||
provider: testCase.provider,
|
||||
recordFps: 24,
|
||||
sessionPath: "desktop-recorder.json",
|
||||
ttl: "2h",
|
||||
userDriver: ["python3", "driver.py"],
|
||||
},
|
||||
@@ -382,6 +397,7 @@ describe("Telegram Desktop recorder remote contract", () => {
|
||||
outputDir: "out",
|
||||
provider: "docker",
|
||||
recordFps: 24,
|
||||
sessionPath: "desktop-recorder.json",
|
||||
ttl: "2h",
|
||||
userDriver: ["python3", "driver.py"],
|
||||
},
|
||||
@@ -428,6 +444,7 @@ describe("Telegram Desktop recorder remote contract", () => {
|
||||
outputDir: "out",
|
||||
provider: "docker",
|
||||
recordFps: 24,
|
||||
sessionPath: "desktop-recorder.json",
|
||||
ttl: "2h",
|
||||
userDriver: ["python3", "driver.py"],
|
||||
},
|
||||
@@ -479,50 +496,44 @@ describe("Telegram Desktop recorder remote contract", () => {
|
||||
outputDir: "out",
|
||||
provider: "docker",
|
||||
recordFps: 24,
|
||||
sessionPath: "desktop-recorder.json",
|
||||
ttl: "2h",
|
||||
userDriver: ["python3", "driver.py"],
|
||||
},
|
||||
operations,
|
||||
),
|
||||
).rejects.toThrow(
|
||||
"Telegram server accepted 2 login tokens, but Telegram Desktop stayed on the QR screen: permission denied reading the remote Docker socket",
|
||||
"token-accepted-no-transition: Telegram server accepted 2 login tokens, but Telegram Desktop stayed on the QR screen: permission denied reading the remote Docker socket",
|
||||
);
|
||||
expect(
|
||||
runCommand.mock.calls.filter(([call]) => call.args.includes("terminate-session")),
|
||||
).toHaveLength(2);
|
||||
expect(
|
||||
JSON.parse(
|
||||
fs.readFileSync(
|
||||
path.join(root, "out", "telegram-desktop-authorization-failure.json"),
|
||||
"utf8",
|
||||
),
|
||||
),
|
||||
).toMatchObject({ failures: [{ classification: "token-accepted-no-transition" }] });
|
||||
});
|
||||
|
||||
it("reprovisions one fresh local desktop after an accepted-token wedge", async () => {
|
||||
it("reuses one authorized desktop across sequential captures", async () => {
|
||||
const root = makeTempDir();
|
||||
let container = 0;
|
||||
let qrAttempt = 0;
|
||||
const runCommand = vi.fn<RunCommand>(async (call) => {
|
||||
if (call.command === "docker") {
|
||||
return { stderr: "", stdout: "[]" };
|
||||
}
|
||||
if (call.args[0] === "warmup") {
|
||||
container += 1;
|
||||
return {
|
||||
stderr: "",
|
||||
stdout: `leased ${container === 1 ? "cbx_0a1b2c" : "cbx_0a1b2d"} slug=quiet-crab`,
|
||||
};
|
||||
return { stderr: "", stdout: "leased cbx_0a1b2c slug=quiet-crab" };
|
||||
}
|
||||
if (call.args.includes("confirm-qr")) {
|
||||
return {
|
||||
stderr: "",
|
||||
stdout: JSON.stringify({
|
||||
ok: true,
|
||||
session: { id: `${container}${qrAttempt}`, isPasswordPending: false },
|
||||
}),
|
||||
stdout: JSON.stringify({ ok: true, session: { id: "91234", isPasswordPending: false } }),
|
||||
};
|
||||
}
|
||||
if (
|
||||
call.args.includes("terminate-session") ||
|
||||
call.args.includes("terminate-desktop-sessions")
|
||||
) {
|
||||
return { stderr: "", stdout: JSON.stringify({ ok: true }) };
|
||||
}
|
||||
return { stderr: "", stdout: "" };
|
||||
return { stderr: "", stdout: JSON.stringify({ ok: true }) };
|
||||
});
|
||||
const inspectCrabbox = vi.fn(async () => ({
|
||||
sshHost: "host",
|
||||
@@ -538,11 +549,7 @@ describe("Telegram Desktop recorder remote contract", () => {
|
||||
scpFromRemote: vi.fn(async () => undefined),
|
||||
sshRun: vi.fn(async ({ command }: { command: string }) => {
|
||||
if (command.includes("telegram-login-qr.png")) {
|
||||
qrAttempt += 1;
|
||||
return { stderr: "", stdout: `tg://login?token=attempt-${qrAttempt}` };
|
||||
}
|
||||
if (command.includes("Telegram Desktop did not reach the main window") && container === 1) {
|
||||
throw new Error("first desktop stayed on QR");
|
||||
return { stderr: "", stdout: "tg://login?token=first-capture" };
|
||||
}
|
||||
if (command.includes("getwindowgeometry")) {
|
||||
return { stderr: "", stdout: "0x04600007 635 40 650 1000" };
|
||||
@@ -551,30 +558,35 @@ describe("Telegram Desktop recorder remote contract", () => {
|
||||
}),
|
||||
} satisfies RecorderOperations;
|
||||
|
||||
const result = await startRecorder(
|
||||
root,
|
||||
{
|
||||
command: "start",
|
||||
chat: "-1001234567890",
|
||||
crabboxClass: "standard",
|
||||
idleTimeout: "1h",
|
||||
json: false,
|
||||
outputDir: "out",
|
||||
provider: "docker",
|
||||
recordFps: 24,
|
||||
ttl: "2h",
|
||||
userDriver: ["python3", "driver.py"],
|
||||
},
|
||||
operations,
|
||||
);
|
||||
const options = {
|
||||
command: "start" as const,
|
||||
chat: "-1001234567890",
|
||||
crabboxClass: "standard",
|
||||
idleTimeout: "1h",
|
||||
json: false,
|
||||
outputDir: "attempt-1",
|
||||
provider: "docker" as const,
|
||||
recordFps: 24,
|
||||
sessionPath: "desktop-recorder.json",
|
||||
ttl: "2h",
|
||||
userDriver: ["python3", "driver.py"],
|
||||
};
|
||||
const first = await startRecorder(root, options, operations);
|
||||
await stopRecorder(root, { command: "stop", sessionPath: "desktop-recorder.json" }, operations);
|
||||
const second = await startRecorder(root, { ...options, outputDir: "attempt-2" }, operations);
|
||||
|
||||
expect(inspectCrabbox).toHaveBeenCalledTimes(2);
|
||||
expect(runCommand.mock.calls.filter(([call]) => call.args[0] === "warmup")).toHaveLength(2);
|
||||
expect(runCommand.mock.calls).toContainEqual([
|
||||
expect.objectContaining({ args: ["stop", "--provider", "docker", "cbx_0a1b2c"] }),
|
||||
]);
|
||||
expect(result.session).toMatchObject({ leaseId: "cbx_0a1b2d", leaseOwned: true });
|
||||
expect(readRecorderSession(result.sessionPath)).toMatchObject({ leaseId: "cbx_0a1b2d" });
|
||||
expect(runCommand.mock.calls.filter(([call]) => call.args[0] === "warmup")).toHaveLength(1);
|
||||
expect(runCommand.mock.calls.filter(([call]) => call.args.includes("confirm-qr"))).toHaveLength(
|
||||
1,
|
||||
);
|
||||
expect(
|
||||
operations.sshRun.mock.calls.filter(([call]) => call.command.includes("x11grab")),
|
||||
).toHaveLength(2);
|
||||
expect(first.sessionPath).toBe(second.sessionPath);
|
||||
expect(readRecorderSession(second.sessionPath)).toMatchObject({
|
||||
leaseId: "cbx_0a1b2c",
|
||||
outputDir: path.join(root, "attempt-2"),
|
||||
});
|
||||
});
|
||||
|
||||
it("hides the prepared chat before recording starts", async () => {
|
||||
@@ -617,6 +629,7 @@ describe("Telegram Desktop recorder remote contract", () => {
|
||||
outputDir: "out",
|
||||
provider: "docker",
|
||||
recordFps: 24,
|
||||
sessionPath: "desktop-recorder.json",
|
||||
ttl: "2h",
|
||||
userDriver: ["python3", "driver.py"],
|
||||
},
|
||||
@@ -674,6 +687,7 @@ describe("Telegram Desktop recorder remote contract", () => {
|
||||
outputDir: "out",
|
||||
provider: "docker" as const,
|
||||
recordFps: 24,
|
||||
sessionPath: "desktop-recorder.json",
|
||||
ttl: "2h",
|
||||
userDriver: ["python3", "driver.py"],
|
||||
};
|
||||
@@ -683,10 +697,24 @@ describe("Telegram Desktop recorder remote contract", () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const exhausted = expect(startRecorder(root, options, operations)).rejects.toThrow(
|
||||
"telegram-login-screen.png",
|
||||
"qr-unreadable: Telegram Desktop did not leave the login screen after 6 attempts",
|
||||
);
|
||||
await vi.runAllTimersAsync();
|
||||
await exhausted;
|
||||
expect(
|
||||
JSON.parse(
|
||||
fs.readFileSync(
|
||||
path.join(root, "out", "telegram-desktop-authorization-failure.json"),
|
||||
"utf8",
|
||||
),
|
||||
),
|
||||
).toMatchObject({ failures: [{ classification: "qr-unreadable" }] });
|
||||
// The lane reads this fact as a different OS user than the recorder; 0600
|
||||
// would break the retry budget with EACCES.
|
||||
expect(
|
||||
fs.statSync(path.join(root, "out", "telegram-desktop-authorization-failure.json")).mode &
|
||||
0o777,
|
||||
).toBe(0o644);
|
||||
expect(scpFromRemote).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ remote: expect.stringContaining("telegram-login-qr.png") }),
|
||||
);
|
||||
@@ -702,6 +730,77 @@ describe("Telegram Desktop recorder remote contract", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("classifies one accepted token without a main-window transition", async () => {
|
||||
const root = makeTempDir();
|
||||
let qrAttempt = 0;
|
||||
const operations = {
|
||||
createCroppedMotionPreview: vi.fn(async () => ({ crop: "", fps: 24, outputWidth: 430 })),
|
||||
createMotionPreview: vi.fn(async () => ({})),
|
||||
inspectCrabbox: vi.fn(async () => ({
|
||||
sshHost: "host",
|
||||
sshKey: "/tmp/key",
|
||||
sshPort: "22",
|
||||
sshUser: "user",
|
||||
})),
|
||||
runCommand: vi.fn<RunCommand>(async (call) => ({
|
||||
stderr: "",
|
||||
stdout: call.args.includes("confirm-qr")
|
||||
? JSON.stringify({ ok: true, session: { id: 91234, isPasswordPending: false } })
|
||||
: JSON.stringify({ ok: true }),
|
||||
})),
|
||||
scpFromRemote: vi.fn(async () => undefined),
|
||||
sshRun: vi.fn(async ({ command }: { command: string }) => {
|
||||
if (command.includes("telegram-login-qr.png")) {
|
||||
qrAttempt += 1;
|
||||
if (qrAttempt === 1) {
|
||||
return { stderr: "", stdout: "tg://login?token=accepted-once" };
|
||||
}
|
||||
throw new Error("zbarimg: no barcode detected");
|
||||
}
|
||||
if (command.includes("Telegram Desktop did not reach the main window")) {
|
||||
throw new Error("Telegram Desktop did not reach the main window");
|
||||
}
|
||||
return { stderr: "", stdout: "" };
|
||||
}),
|
||||
} satisfies RecorderOperations;
|
||||
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const failed = expect(
|
||||
startRecorder(
|
||||
root,
|
||||
{
|
||||
command: "start",
|
||||
chat: "-1001234567890",
|
||||
crabboxClass: "standard",
|
||||
idleTimeout: "1h",
|
||||
json: false,
|
||||
leaseId: "cbx_borrowed",
|
||||
outputDir: "out",
|
||||
provider: "docker",
|
||||
recordFps: 24,
|
||||
sessionPath: "desktop-recorder.json",
|
||||
ttl: "2h",
|
||||
userDriver: ["python3", "driver.py"],
|
||||
},
|
||||
operations,
|
||||
),
|
||||
).rejects.toThrow("main-window-timeout: Telegram Desktop did not reach the main window");
|
||||
await vi.runAllTimersAsync();
|
||||
await failed;
|
||||
expect(
|
||||
JSON.parse(
|
||||
fs.readFileSync(
|
||||
path.join(root, "out", "telegram-desktop-authorization-failure.json"),
|
||||
"utf8",
|
||||
),
|
||||
),
|
||||
).toMatchObject({ failures: [{ classification: "main-window-timeout" }] });
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("reports the blocked user when the output dir is not writable", async () => {
|
||||
const root = makeTempDir();
|
||||
// The agent and the recorder run as different users, so this fails in the lane and not
|
||||
@@ -738,6 +837,7 @@ describe("Telegram Desktop recorder remote contract", () => {
|
||||
outputDir: "out",
|
||||
provider: "docker",
|
||||
recordFps: 24,
|
||||
sessionPath: "desktop-recorder.json",
|
||||
ttl: "2h",
|
||||
userDriver: ["python3", "driver.py"],
|
||||
},
|
||||
@@ -880,7 +980,7 @@ describe("Telegram Desktop recorder window geometry", () => {
|
||||
const root = makeTempDir();
|
||||
const sessionPath = path.join(root, "recorder.json");
|
||||
writeRecorderSession(sessionPath, {
|
||||
...testSession(),
|
||||
...testSession(root),
|
||||
window: { height: 995, id: "0x04600007", width: 648, x: 636, y: 45 },
|
||||
});
|
||||
const cropped = vi.fn(async () => ({ crop: "", fps: 24, outputWidth: 648 }));
|
||||
@@ -907,7 +1007,6 @@ describe("Telegram Desktop recorder window geometry", () => {
|
||||
{
|
||||
command: "stop",
|
||||
crop: "telegram-window",
|
||||
keepBox: false,
|
||||
sessionPath: recorderSessionArg(root, sessionPath),
|
||||
},
|
||||
operations,
|
||||
@@ -927,9 +1026,10 @@ describe("Telegram Desktop recorder window geometry", () => {
|
||||
});
|
||||
|
||||
describe("Telegram Desktop recorder session lifecycle", () => {
|
||||
it("round-trips recorder.json schema version 1", () => {
|
||||
const sessionPath = path.join(makeTempDir(), "recorder.json");
|
||||
const session = testSession();
|
||||
it("round-trips recorder.json schema version 2", () => {
|
||||
const root = makeTempDir();
|
||||
const sessionPath = path.join(root, "recorder.json");
|
||||
const session = testSession(root);
|
||||
|
||||
writeRecorderSession(sessionPath, session);
|
||||
|
||||
@@ -943,7 +1043,7 @@ describe("Telegram Desktop recorder session lifecycle", () => {
|
||||
const screenshot = path.join(root, "screenshot.png");
|
||||
fs.writeFileSync(screenshot, "proof", { mode: 0o600 });
|
||||
writeRecorderSession(sessionPath, {
|
||||
...testSession(),
|
||||
...testSession(root),
|
||||
artifacts: { screenshot },
|
||||
});
|
||||
|
||||
@@ -961,7 +1061,7 @@ describe("Telegram Desktop recorder session lifecycle", () => {
|
||||
it("keeps every recorder path inside its fixed working directory", async () => {
|
||||
const root = makeTempDir();
|
||||
const sessionPath = path.join(root, "recorder.json");
|
||||
writeRecorderSession(sessionPath, testSession());
|
||||
writeRecorderSession(sessionPath, testSession(root));
|
||||
expect(() =>
|
||||
recorderArtifacts(root, { command: "artifacts", sessionPath: "../recorder.json" }),
|
||||
).toThrow("--session must stay inside the recorder root");
|
||||
@@ -991,11 +1091,13 @@ describe("Telegram Desktop recorder session lifecycle", () => {
|
||||
).rejects.toThrow("--output must be relative");
|
||||
});
|
||||
|
||||
it("writes the default screenshot beside a relative session path", async () => {
|
||||
it("writes the default screenshot in the current capture directory", async () => {
|
||||
const root = makeTempDir();
|
||||
const sessionPath = path.join(root, "attempt", "recorder.json");
|
||||
fs.mkdirSync(path.dirname(sessionPath));
|
||||
writeRecorderSession(sessionPath, testSession());
|
||||
const captureDir = path.join(root, "capture");
|
||||
fs.mkdirSync(captureDir);
|
||||
writeRecorderSession(sessionPath, testSession(captureDir));
|
||||
const scpFromRemote = vi.fn(async () => undefined);
|
||||
|
||||
const output = await screenshotRecorder(
|
||||
@@ -1016,7 +1118,7 @@ describe("Telegram Desktop recorder session lifecycle", () => {
|
||||
},
|
||||
);
|
||||
|
||||
expect(path.dirname(output)).toBe(path.dirname(sessionPath));
|
||||
expect(path.dirname(output)).toBe(captureDir);
|
||||
expect(scpFromRemote).toHaveBeenCalledWith(expect.objectContaining({ local: output }));
|
||||
});
|
||||
|
||||
@@ -1057,7 +1159,7 @@ describe("Telegram Desktop recorder session lifecycle", () => {
|
||||
expect(fs.existsSync(`${sessionPath}.starting`)).toBe(false);
|
||||
});
|
||||
|
||||
it("never stops a borrowed --lease-id box, on failure or on stop", async () => {
|
||||
it("never stops a borrowed --lease-id box, on failure or teardown", async () => {
|
||||
const root = makeTempDir();
|
||||
const calls: Array<{ args: string[]; command: string }> = [];
|
||||
const mockedRun: RunCommand = async (params) => {
|
||||
@@ -1088,6 +1190,7 @@ describe("Telegram Desktop recorder session lifecycle", () => {
|
||||
outputDir: "out",
|
||||
provider: "aws",
|
||||
recordFps: 24,
|
||||
sessionPath: "desktop-recorder.json",
|
||||
ttl: "2h",
|
||||
userDriver: ["python3", "driver.py"],
|
||||
},
|
||||
@@ -1099,7 +1202,7 @@ describe("Telegram Desktop recorder session lifecycle", () => {
|
||||
|
||||
const sessionPath = path.join(root, "recorder.json");
|
||||
writeRecorderSession(sessionPath, {
|
||||
...testSession(),
|
||||
...testSession(root),
|
||||
leaseId: "cbx_borrowed",
|
||||
leaseOwned: false,
|
||||
});
|
||||
@@ -1112,19 +1215,19 @@ describe("Telegram Desktop recorder session lifecycle", () => {
|
||||
sshUser: "user",
|
||||
})),
|
||||
} satisfies RecorderOperations;
|
||||
await stopRecorder(
|
||||
await teardownRecorder(
|
||||
root,
|
||||
{ command: "stop", keepBox: false, sessionPath: recorderSessionArg(root, sessionPath) },
|
||||
operations,
|
||||
{ command: "teardown", sessionPath: recorderSessionArg(root, sessionPath) },
|
||||
{ runCommand: operations.runCommand },
|
||||
);
|
||||
expect(calls.some((call) => call.args.includes("terminate-session"))).toBe(true);
|
||||
expect(calls.some((call) => call.args[0] === "stop")).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps the Desktop authorization and the box alive with --keep-box", async () => {
|
||||
it("keeps the Desktop authorization and box alive when a capture stops", async () => {
|
||||
const root = makeTempDir();
|
||||
const sessionPath = path.join(root, "recorder.json");
|
||||
writeRecorderSession(sessionPath, testSession());
|
||||
writeRecorderSession(sessionPath, testSession(root));
|
||||
const calls: Array<{ args: string[]; command: string }> = [];
|
||||
const mockedRun: RunCommand = async (params) => {
|
||||
calls.push({ args: params.args, command: params.command });
|
||||
@@ -1144,25 +1247,20 @@ describe("Telegram Desktop recorder session lifecycle", () => {
|
||||
sshRun: vi.fn(async () => ({ stderr: "", stdout: "" })),
|
||||
} satisfies RecorderOperations;
|
||||
|
||||
const stopped = await stopRecorder(
|
||||
await stopRecorder(
|
||||
root,
|
||||
{
|
||||
command: "stop",
|
||||
keepBox: true,
|
||||
sessionPath: recorderSessionArg(root, sessionPath),
|
||||
},
|
||||
{ command: "stop", sessionPath: recorderSessionArg(root, sessionPath) },
|
||||
operations,
|
||||
);
|
||||
expect(stopped.keepBox).toBe(true);
|
||||
expect(calls.some((call) => call.args.includes("terminate-session"))).toBe(false);
|
||||
expect(calls.some((call) => call.args[0] === "stop")).toBe(false);
|
||||
});
|
||||
|
||||
it("uses the recorded provider for view, inspect, and owned-lease stop", async () => {
|
||||
it("uses the recorded provider for view, capture stop, and teardown", async () => {
|
||||
const root = makeTempDir();
|
||||
const sessionPath = path.join(root, "recorder.json");
|
||||
writeRecorderSession(sessionPath, {
|
||||
...testSession(),
|
||||
...testSession(root),
|
||||
imageSource: "openclaw-telegram-desktop:7.0.9",
|
||||
provider: "docker",
|
||||
});
|
||||
@@ -1198,9 +1296,14 @@ describe("Telegram Desktop recorder session lifecycle", () => {
|
||||
);
|
||||
await stopRecorder(
|
||||
root,
|
||||
{ command: "stop", keepBox: false, sessionPath: recorderSessionArg(root, sessionPath) },
|
||||
{ command: "stop", sessionPath: recorderSessionArg(root, sessionPath) },
|
||||
operations,
|
||||
);
|
||||
await teardownRecorder(
|
||||
root,
|
||||
{ command: "teardown", sessionPath: recorderSessionArg(root, sessionPath) },
|
||||
{ runCommand: operations.runCommand },
|
||||
);
|
||||
|
||||
expect(inspectCrabbox).toHaveBeenCalledTimes(2);
|
||||
expect(sshCommands[0]).toContain('xdotool windowmap "$win"');
|
||||
@@ -1222,7 +1325,7 @@ describe("Telegram Desktop recorder session lifecycle", () => {
|
||||
it("finishes cleanly without previews when the lease is already gone", async () => {
|
||||
const root = makeTempDir();
|
||||
const sessionPath = path.join(root, "recorder.json");
|
||||
writeRecorderSession(sessionPath, testSession());
|
||||
writeRecorderSession(sessionPath, testSession(root));
|
||||
const preview = vi.fn(async () => ({}));
|
||||
const cropped = vi.fn(async () => ({ crop: "", fps: 24, outputWidth: 650 }));
|
||||
const operations = {
|
||||
@@ -1244,7 +1347,6 @@ describe("Telegram Desktop recorder session lifecycle", () => {
|
||||
{
|
||||
command: "stop",
|
||||
crop: "telegram-window",
|
||||
keepBox: false,
|
||||
sessionPath: recorderSessionArg(root, sessionPath),
|
||||
},
|
||||
operations,
|
||||
@@ -1254,13 +1356,12 @@ describe("Telegram Desktop recorder session lifecycle", () => {
|
||||
expect(cropped).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps artifacts recorded by an earlier keep-box stop", async () => {
|
||||
it("keeps artifacts recorded by an earlier capture stop", async () => {
|
||||
const root = makeTempDir();
|
||||
const sessionPath = path.join(root, "recorder.json");
|
||||
writeRecorderSession(sessionPath, {
|
||||
...testSession(),
|
||||
...testSession(root),
|
||||
artifacts: { previewGif: "/kept/motion.gif", video: "/kept/session.mp4" },
|
||||
keepBox: true,
|
||||
});
|
||||
const operations = {
|
||||
createCroppedMotionPreview: vi.fn(async () => ({ crop: "", fps: 24, outputWidth: 650 })),
|
||||
@@ -1280,7 +1381,6 @@ describe("Telegram Desktop recorder session lifecycle", () => {
|
||||
root,
|
||||
{
|
||||
command: "stop",
|
||||
keepBox: false,
|
||||
sessionPath: recorderSessionArg(root, sessionPath),
|
||||
},
|
||||
operations,
|
||||
@@ -1291,10 +1391,10 @@ describe("Telegram Desktop recorder session lifecycle", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("still stops Crabbox and reports failure when local session termination fails", async () => {
|
||||
it("still stops Crabbox and reports teardown failure when session termination fails", async () => {
|
||||
const root = makeTempDir();
|
||||
const sessionPath = path.join(root, "recorder.json");
|
||||
writeRecorderSession(sessionPath, testSession());
|
||||
writeRecorderSession(sessionPath, testSession(root));
|
||||
const calls: Array<{ args: string[]; command: string }> = [];
|
||||
const mockedRun: RunCommand = async (params) => {
|
||||
calls.push({ args: params.args, command: params.command });
|
||||
@@ -1318,10 +1418,10 @@ describe("Telegram Desktop recorder session lifecycle", () => {
|
||||
} satisfies RecorderOperations;
|
||||
|
||||
await expect(
|
||||
stopRecorder(
|
||||
teardownRecorder(
|
||||
root,
|
||||
{ command: "stop", keepBox: false, sessionPath: recorderSessionArg(root, sessionPath) },
|
||||
operations,
|
||||
{ command: "teardown", sessionPath: recorderSessionArg(root, sessionPath) },
|
||||
{ runCommand: operations.runCommand },
|
||||
),
|
||||
).rejects.toThrow("terminate Telegram Desktop session: terminate failed");
|
||||
|
||||
@@ -1342,7 +1442,6 @@ describe("Telegram Desktop recorder session lifecycle", () => {
|
||||
command: "crabbox",
|
||||
});
|
||||
const stopped = readRecorderSession(sessionPath);
|
||||
expect(stopped.stoppedAt).toBeDefined();
|
||||
expect(stopped.cleanupErrors).toContain("terminate Telegram Desktop session: terminate failed");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,6 +8,7 @@ import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
publishableRecorderArtifacts,
|
||||
publishStartupFailure,
|
||||
startDesktopRecorder,
|
||||
} from "../../scripts/e2e/telegram-mantis-lane.ts";
|
||||
import { useAutoCleanupTempDirTracker } from "../helpers/temp-dir.js";
|
||||
|
||||
@@ -77,7 +78,7 @@ async function setupHarness(
|
||||
observerPidFile: path.join(root, "observer.pid.json"),
|
||||
observerSocket,
|
||||
privateDir: path.join(sessionRoot, "attempt"),
|
||||
recorderSession: path.join(sessionRoot, "attempt", "recorder.json"),
|
||||
recorderSession: path.join(sessionRoot, "desktop-recorder.json"),
|
||||
repoRoot: "/prepared/candidate",
|
||||
sendCount: 0,
|
||||
startedAt: new Date().toISOString(),
|
||||
@@ -177,6 +178,120 @@ async function runLane(env: NodeJS.ProcessEnv, args: string[]) {
|
||||
}
|
||||
|
||||
describe("Telegram Mantis free-form lane", () => {
|
||||
it("passes one run-scoped recorder session across sequential lane starts", async () => {
|
||||
const root = tempDirs.make("telegram-mantis-recorder-reuse-");
|
||||
const sessionRoot = path.join(root, "private");
|
||||
const recorderCommand = path.join(root, "recorder");
|
||||
const recorderLog = path.join(root, "recorder.log");
|
||||
const provisionLog = path.join(root, "provision.log");
|
||||
fs.mkdirSync(sessionRoot);
|
||||
fs.writeFileSync(
|
||||
recorderCommand,
|
||||
`#!/bin/sh
|
||||
session=
|
||||
while [ "$#" -gt 0 ]; do
|
||||
if [ "$1" = --session ]; then session=$2; break; fi
|
||||
shift
|
||||
done
|
||||
printf '%s\\n' "$session" >> ${JSON.stringify(recorderLog)}
|
||||
if [ ! -f ${JSON.stringify(sessionRoot)}/"$session" ]; then
|
||||
printf 'provision\\n' >> ${JSON.stringify(provisionLog)}
|
||||
: > ${JSON.stringify(sessionRoot)}/"$session"
|
||||
fi
|
||||
`,
|
||||
{ mode: 0o755 },
|
||||
);
|
||||
const priorSessionRoot = process.env.OPENCLAW_MANTIS_SESSION_ROOT;
|
||||
process.env.OPENCLAW_MANTIS_SESSION_ROOT = sessionRoot;
|
||||
try {
|
||||
for (const [lane, attempt] of [
|
||||
["baseline", "1"],
|
||||
["candidate", "1"],
|
||||
] as const) {
|
||||
await startDesktopRecorder({
|
||||
chat: "-100123456789",
|
||||
outputDir: path.join(sessionRoot, "attempts", lane, attempt),
|
||||
recorderCommand,
|
||||
sessionPath: path.join(sessionRoot, "desktop-recorder.json"),
|
||||
sessionRoot,
|
||||
userDriver: "/usr/local/bin/telegram-user-driver",
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
if (priorSessionRoot === undefined) {
|
||||
delete process.env.OPENCLAW_MANTIS_SESSION_ROOT;
|
||||
} else {
|
||||
process.env.OPENCLAW_MANTIS_SESSION_ROOT = priorSessionRoot;
|
||||
}
|
||||
}
|
||||
expect(fs.readFileSync(recorderLog, "utf8").trim().split("\n")).toEqual([
|
||||
"desktop-recorder.json",
|
||||
"desktop-recorder.json",
|
||||
]);
|
||||
expect(fs.readFileSync(provisionLog, "utf8").trim().split("\n")).toEqual(["provision"]);
|
||||
});
|
||||
|
||||
it("stops invoking the recorder after two authorization failures", async () => {
|
||||
const root = tempDirs.make("telegram-mantis-recorder-budget-");
|
||||
const sessionRoot = path.join(root, "private");
|
||||
const recorderCommand = path.join(root, "recorder");
|
||||
const recorderLog = path.join(root, "recorder.log");
|
||||
fs.mkdirSync(sessionRoot);
|
||||
fs.writeFileSync(
|
||||
recorderCommand,
|
||||
`#!/bin/sh
|
||||
printf '%s\\n' "$*" >> ${JSON.stringify(recorderLog)}
|
||||
output=
|
||||
while [ "$#" -gt 0 ]; do
|
||||
if [ "$1" = --output-dir ]; then output=$2; break; fi
|
||||
shift
|
||||
done
|
||||
count=$(wc -l < ${JSON.stringify(recorderLog)})
|
||||
classification=qr-unreadable
|
||||
accepted=0
|
||||
if [ "$count" -eq 2 ]; then classification=token-accepted-no-transition; accepted=2; fi
|
||||
mkdir -p ${JSON.stringify(sessionRoot)}/"$output"
|
||||
printf '{"failures":[{"acceptedTokenCount":%s,"classification":"%s","failedAt":"2026-08-22T00:00:00.000Z","loginScreenshotPath":"%s/login.png","qrAttemptCount":6}],"schemaVersion":1}\n' "$accepted" "$classification" ${JSON.stringify(sessionRoot)}/"$output" > ${JSON.stringify(sessionRoot)}/"$output"/telegram-desktop-authorization-failure.json
|
||||
exit 1
|
||||
`,
|
||||
{ mode: 0o755 },
|
||||
);
|
||||
const priorSessionRoot = process.env.OPENCLAW_MANTIS_SESSION_ROOT;
|
||||
process.env.OPENCLAW_MANTIS_SESSION_ROOT = sessionRoot;
|
||||
const start = (attempt: number) =>
|
||||
startDesktopRecorder({
|
||||
chat: "-100123456789",
|
||||
outputDir: path.join(sessionRoot, "attempts", "candidate", String(attempt)),
|
||||
recorderCommand,
|
||||
sessionPath: path.join(sessionRoot, "desktop-recorder.json"),
|
||||
sessionRoot,
|
||||
userDriver: "/usr/local/bin/telegram-user-driver",
|
||||
});
|
||||
try {
|
||||
await expect(start(1)).rejects.toThrow();
|
||||
await expect(start(2)).rejects.toThrow(
|
||||
"desktop-unavailable: stop retrying; this run's desktop is unavailable",
|
||||
);
|
||||
await expect(start(3)).rejects.toThrow(
|
||||
/attemptCount=2, classification=token-accepted-no-transition.*loginScreenshotPath=.*login\.png/u,
|
||||
);
|
||||
} finally {
|
||||
if (priorSessionRoot === undefined) {
|
||||
delete process.env.OPENCLAW_MANTIS_SESSION_ROOT;
|
||||
} else {
|
||||
process.env.OPENCLAW_MANTIS_SESSION_ROOT = priorSessionRoot;
|
||||
}
|
||||
}
|
||||
expect(fs.readFileSync(recorderLog, "utf8").trim().split("\n")).toHaveLength(2);
|
||||
expect(
|
||||
JSON.parse(fs.readFileSync(path.join(sessionRoot, "desktop-recorder-failures.json"), "utf8")),
|
||||
).toMatchObject({
|
||||
attemptCount: 2,
|
||||
classification: "token-accepted-no-transition",
|
||||
unavailable: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("publishes only cropped visual evidence", () => {
|
||||
expect(
|
||||
publishableRecorderArtifacts({
|
||||
@@ -223,7 +338,7 @@ describe("Telegram Mantis free-form lane", () => {
|
||||
observerSocket: path.join(sessionRoot, "observer.sock"),
|
||||
privateDir: path.join(sessionRoot, "attempts", "candidate", "1"),
|
||||
recorderRequested: true,
|
||||
recorderSession: path.join(sessionRoot, "attempts", "candidate", "1", "recorder.json"),
|
||||
recorderSession: path.join(sessionRoot, "desktop-recorder.json"),
|
||||
repoRoot: "/prepared/candidate",
|
||||
startedAt,
|
||||
},
|
||||
@@ -321,7 +436,7 @@ describe("Telegram Mantis free-form lane", () => {
|
||||
"observe",
|
||||
]);
|
||||
expect(fs.readFileSync(harness.recorderLog, "utf8")).toContain(
|
||||
"view --session attempt/recorder.json --message-id 101",
|
||||
"view --session desktop-recorder.json --message-id 101",
|
||||
);
|
||||
expect(JSON.parse(fs.readFileSync(harness.recorderControlLog, "utf8"))).toMatchObject({
|
||||
hold: true,
|
||||
@@ -404,7 +519,7 @@ describe("Telegram Mantis free-form lane", () => {
|
||||
results: [{ command: "click", stdout: "clicked\n" }],
|
||||
});
|
||||
expect(fs.readFileSync(harness.recorderLog, "utf8")).toContain(
|
||||
"actions --session attempt/recorder.json --actions-file attempt/desktop-actions-2.json --timeout-seconds 90",
|
||||
"actions --session desktop-recorder.json --actions-file attempt/desktop-actions-2.json --timeout-seconds 90",
|
||||
);
|
||||
const state = JSON.parse(
|
||||
fs.readFileSync(path.join(harness.sessionRoot, "candidate.active.json"), "utf8"),
|
||||
@@ -869,7 +984,7 @@ describe("Telegram Mantis free-form lane", () => {
|
||||
observerSocket: path.join(harness.sessionRoot, "observer.sock"),
|
||||
privateDir: harness.sessionRoot,
|
||||
recorderRequested: false,
|
||||
recorderSession: path.join(harness.sessionRoot, "recorder.json"),
|
||||
recorderSession: path.join(harness.sessionRoot, "desktop-recorder.json"),
|
||||
repoRoot: "/prepared/candidate",
|
||||
startedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user