361 lines
17 KiB
TypeScript
361 lines
17 KiB
TypeScript
import { spawn } from "node:child_process";
|
|
import { createServer } from "node:http";
|
|
import { join, resolve } from "node:path";
|
|
import { expect, onTestFinished } from "vitest";
|
|
import { clickButton, control, createAndSelectWorkspace, evalIn, waitFor } from "@openwork/behaviors";
|
|
import { screenshot } from "@openwork/test-evidence";
|
|
import { desktop } from "@openwork/hosts";
|
|
import { needs, test } from "@openwork/testkit";
|
|
|
|
const providerId = "attachment-upload-mock";
|
|
const modelId = "attachment-upload-model";
|
|
const reply = "attachment upload loading proof";
|
|
const attachmentName = "big-photo.png";
|
|
const e2eTestsEnabled = process.env.OPENWORK_EVAL_E2E_TESTS === "1";
|
|
const title = e2eTestsEnabled
|
|
? "attaching an image shows its chip instantly and sends with a visible uploading state"
|
|
: "attachment upload loading state skipped — needs: set OPENWORK_EVAL_E2E_TESTS=1";
|
|
|
|
const repoRoot = resolve(import.meta.dirname, "../..");
|
|
|
|
/**
|
|
* Boot the standalone openwork-server (the web/gateway posture) in
|
|
* manual-approval mode with nobody answering approvals. This is the exact
|
|
* configuration that used to park chat-attachment uploads for the whole
|
|
* approval timeout and then fail them with 403 write_denied.
|
|
*/
|
|
async function startManualApprovalServer(approvalTimeoutMs: number) {
|
|
const script = `
|
|
const { mkdtempSync } = await import("node:fs");
|
|
const { tmpdir } = await import("node:os");
|
|
const { join } = await import("node:path");
|
|
const { startServer } = await import("./src/server.ts");
|
|
const root = mkdtempSync(join(tmpdir(), "openwork-attachment-spec-"));
|
|
const server = await startServer({
|
|
host: "127.0.0.1",
|
|
port: 0,
|
|
token: "owt_spec_token",
|
|
hostToken: "owt_spec_host_token",
|
|
approval: { mode: "manual", timeoutMs: ${approvalTimeoutMs} },
|
|
corsOrigins: ["*"],
|
|
workspaces: [{ id: "ws_spec", name: "Workspace", path: root, preset: "starter", workspaceType: "local" }],
|
|
authorizedRoots: [root],
|
|
readOnly: false,
|
|
startedAt: Date.now(),
|
|
tokenSource: "cli",
|
|
hostTokenSource: "cli",
|
|
logFormat: "pretty",
|
|
logRequests: false,
|
|
});
|
|
console.log("SPEC_SERVER_PORT:" + server.port);
|
|
setInterval(() => {}, 60_000);
|
|
`;
|
|
const child = spawn("bun", ["-e", script], {
|
|
cwd: join(repoRoot, "apps", "server"),
|
|
stdio: ["ignore", "pipe", "pipe"],
|
|
});
|
|
onTestFinished(() => {
|
|
child.kill("SIGKILL");
|
|
});
|
|
const port = await new Promise<number>((resolvePort, reject) => {
|
|
const timer = setTimeout(() => reject(new Error("Standalone openwork-server did not report a port within 30s.")), 30_000);
|
|
let buffered = "";
|
|
child.stdout.setEncoding("utf8");
|
|
child.stdout.on("data", (chunk: string) => {
|
|
buffered += chunk;
|
|
const match = buffered.match(/SPEC_SERVER_PORT:(\d+)/);
|
|
if (match?.[1]) {
|
|
clearTimeout(timer);
|
|
resolvePort(Number(match[1]));
|
|
}
|
|
});
|
|
child.on("exit", (code) => {
|
|
clearTimeout(timer);
|
|
reject(new Error(`Standalone openwork-server exited early (code ${code}): ${buffered.slice(0, 500)}`));
|
|
});
|
|
child.on("error", (error) => {
|
|
clearTimeout(timer);
|
|
reject(new Error(`Failed to spawn bun for the standalone server: ${error.message}`));
|
|
});
|
|
});
|
|
return { base: `http://127.0.0.1:${port}`, token: "owt_spec_token" };
|
|
}
|
|
|
|
test.skipIf(!e2eTestsEnabled)(title, async ({ evidence }) => {
|
|
needs({ optIn: ["OPENWORK_EVAL_E2E_TESTS"] });
|
|
|
|
// ---------------------------------------------------------------------
|
|
// Part 1 — gateway server contract: manual-approval mode must not park
|
|
// chat-attachment inbox uploads, while other writes stay approval-gated.
|
|
// ---------------------------------------------------------------------
|
|
const approvalTimeoutMs = 3_000;
|
|
const gateway = await startManualApprovalServer(approvalTimeoutMs);
|
|
|
|
const uploadBytes = new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10, 9, 9, 9, 9]);
|
|
const uploadForm = new FormData();
|
|
uploadForm.append("file", new File([uploadBytes], "screenshot.png", { type: "image/png" }));
|
|
const uploadStartedAt = Date.now();
|
|
const uploadResponse = await fetch(
|
|
`${gateway.base}/workspace/ws_spec/inbox?path=${encodeURIComponent("chat-attachments/s1/att-1-screenshot.png")}`,
|
|
{ method: "POST", headers: { Authorization: `Bearer ${gateway.token}` }, body: uploadForm },
|
|
);
|
|
const uploadElapsedMs = Date.now() - uploadStartedAt;
|
|
expect(uploadResponse.status).toBe(200);
|
|
expect(uploadElapsedMs).toBeLessThan(approvalTimeoutMs);
|
|
evidence.recordAssertionEvidence(
|
|
"A chat-attachment upload to a manual-approval gateway server succeeds immediately instead of parking on the approval queue",
|
|
`POST /workspace/:id/inbox with a client token returned ${uploadResponse.status} in ${uploadElapsedMs}ms (approval timeout is ${approvalTimeoutMs}ms; before the fix this waited the full timeout and returned 403 write_denied).`,
|
|
true,
|
|
);
|
|
|
|
const writeStartedAt = Date.now();
|
|
const writeResponse = await fetch(`${gateway.base}/workspace/ws_spec/files/content`, {
|
|
method: "POST",
|
|
headers: { Authorization: `Bearer ${gateway.token}`, "Content-Type": "application/json" },
|
|
body: JSON.stringify({ path: "notes/unapproved.md", content: "# should not land\n" }),
|
|
});
|
|
const writeElapsedMs = Date.now() - writeStartedAt;
|
|
expect(writeResponse.status).toBe(403);
|
|
expect(writeElapsedMs).toBeGreaterThanOrEqual(approvalTimeoutMs - 100);
|
|
evidence.recordAssertionEvidence(
|
|
"Ordinary workspace writes remain approval-gated: the inbox exemption is not a blanket approval bypass",
|
|
`POST /workspace/:id/files/content with the same client token still parked and was denied with HTTP ${writeResponse.status} after ${writeElapsedMs}ms.`,
|
|
true,
|
|
);
|
|
|
|
// ---------------------------------------------------------------------
|
|
// Part 2 — composer UX: chip appears instantly, uploading state visible.
|
|
// ---------------------------------------------------------------------
|
|
const mock = createServer((request, response) => {
|
|
const url = request.url ?? "";
|
|
if (request.method === "GET" && url.startsWith("/v1/models")) {
|
|
response.writeHead(200, { "content-type": "application/json" });
|
|
response.end(JSON.stringify({ object: "list", data: [{ id: modelId, object: "model" }] }));
|
|
return;
|
|
}
|
|
if (request.method === "POST" && (url.startsWith("/v1/chat/completions") || url.startsWith("/chat/completions"))) {
|
|
request.resume();
|
|
request.on("end", () => {
|
|
const chunks = [
|
|
{ id: "chatcmpl-attachment-upload", object: "chat.completion.chunk", choices: [{ index: 0, delta: { role: "assistant" }, finish_reason: null }] },
|
|
{ id: "chatcmpl-attachment-upload", object: "chat.completion.chunk", choices: [{ index: 0, delta: { content: reply }, finish_reason: null }] },
|
|
{ id: "chatcmpl-attachment-upload", object: "chat.completion.chunk", choices: [{ index: 0, delta: {}, finish_reason: "stop" }] },
|
|
];
|
|
response.writeHead(200, {
|
|
"content-type": "text/event-stream",
|
|
"cache-control": "no-cache",
|
|
connection: "keep-alive",
|
|
});
|
|
for (const chunk of chunks) response.write(`data: ${JSON.stringify(chunk)}\n\n`);
|
|
response.write("data: [DONE]\n\n");
|
|
response.end();
|
|
});
|
|
return;
|
|
}
|
|
response.writeHead(404, { "content-type": "application/json" });
|
|
response.end(JSON.stringify({ error: { message: "not found" } }));
|
|
});
|
|
await new Promise<void>((resolveListen, reject) => {
|
|
mock.once("error", reject);
|
|
mock.listen(0, "127.0.0.1", resolveListen);
|
|
});
|
|
onTestFinished(async () => {
|
|
await new Promise<void>((resolveClose, reject) => mock.close((error) => error ? reject(error) : resolveClose()));
|
|
});
|
|
const address = mock.address();
|
|
if (!address || typeof address === "string") throw new Error("Mock provider did not bind a TCP port.");
|
|
const baseUrl = `http://127.0.0.1:${address.port}/v1`;
|
|
|
|
await using app = await desktop({ name: "attachment-upload-loading" });
|
|
const workspace = await createAndSelectWorkspace(app, {
|
|
path: `/tmp/openwork-attachment-upload-${Date.now()}`,
|
|
});
|
|
|
|
const configured = await evalIn(app, `(async () => {
|
|
const port = localStorage.getItem("openwork.server.port");
|
|
const token = localStorage.getItem("openwork.server.token");
|
|
if (!port || !token) return "missing local server credentials";
|
|
const request = async (path, init) => {
|
|
const response = await fetch("http://127.0.0.1:" + port + path, {
|
|
...init,
|
|
headers: { Authorization: "Bearer " + token, "Content-Type": "application/json" },
|
|
});
|
|
if (!response.ok) return path + " failed: " + response.status + " " + (await response.text()).slice(0, 500);
|
|
return "ok";
|
|
};
|
|
const workspaceId = ${JSON.stringify(workspace.workspaceId)};
|
|
const patched = await request("/workspace/" + encodeURIComponent(workspaceId) + "/config", {
|
|
method: "PATCH",
|
|
body: JSON.stringify({
|
|
opencode: {
|
|
provider: {
|
|
[${JSON.stringify(providerId)}]: {
|
|
npm: "@ai-sdk/openai-compatible",
|
|
name: "Attachment upload mock",
|
|
options: { baseURL: ${JSON.stringify(baseUrl)}, apiKey: "sk-attachment-upload" },
|
|
models: {
|
|
[${JSON.stringify(modelId)}]: { name: "Attachment upload model" },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}),
|
|
});
|
|
if (patched !== "ok") return patched;
|
|
const reloaded = await request("/workspace/" + encodeURIComponent(workspaceId) + "/engine/reload", { method: "POST" });
|
|
if (reloaded !== "ok") return reloaded;
|
|
const raw = localStorage.getItem("openwork.preferences");
|
|
let preferences = {};
|
|
try { preferences = raw ? JSON.parse(raw) : {}; } catch { preferences = {}; }
|
|
if (!preferences || typeof preferences !== "object" || Array.isArray(preferences)) preferences = {};
|
|
localStorage.setItem("openwork.preferences", JSON.stringify({
|
|
...preferences,
|
|
defaultModel: { providerID: ${JSON.stringify(providerId)}, modelID: ${JSON.stringify(modelId)} },
|
|
modelVariant: null,
|
|
providerStepCompleted: true,
|
|
}));
|
|
localStorage.setItem("openwork.defaultModel", ${JSON.stringify(`${providerId}/${modelId}`)});
|
|
localStorage.removeItem("openwork.sessionModels." + workspaceId);
|
|
return "ok";
|
|
})()`, { awaitPromise: true, timeoutMs: 30_000 });
|
|
expect(configured).toBe("ok");
|
|
|
|
// Reload so the renderer re-reads the mock provider preference before the
|
|
// new session is created. Without this, the already-mounted model store can
|
|
// keep the previous default model despite the localStorage update above.
|
|
await evalIn(app, "location.reload(); true");
|
|
await waitFor(app, "Boolean(window.__openworkControl)", {
|
|
timeoutMs: 30_000,
|
|
label: "app reloaded with attachment mock provider preference",
|
|
});
|
|
|
|
await control(app, "session.create_task");
|
|
await waitFor(app, `Boolean(document.querySelector('[contenteditable="true"][data-lexical-editor="true"]'))`, {
|
|
timeoutMs: 30_000,
|
|
label: "new-task composer editor ready",
|
|
});
|
|
const focused = await evalIn(app, `(() => {
|
|
const editor = document.querySelector('[contenteditable="true"][data-lexical-editor="true"]');
|
|
if (!(editor instanceof HTMLElement)) return false;
|
|
editor.focus();
|
|
return true;
|
|
})()`);
|
|
expect(focused).toBe(true);
|
|
await app.client.send("Input.insertText", { text: "Describe the attached image." });
|
|
|
|
// Attach a >1.5MB image (random noise defeats PNG compression) through the
|
|
// composer's hidden file input, and measure dispatch -> chip visibility.
|
|
const attachResult = await evalIn(app, `(async () => {
|
|
const canvas = document.createElement("canvas");
|
|
canvas.width = 2400;
|
|
canvas.height = 2400;
|
|
const ctx = canvas.getContext("2d");
|
|
if (!ctx) return "no canvas context";
|
|
const image = ctx.createImageData(2400, 2400);
|
|
for (let index = 0; index < image.data.length; index += 1) {
|
|
image.data[index] = Math.floor(Math.random() * 256);
|
|
}
|
|
ctx.putImageData(image, 0, 0);
|
|
const blob = await new Promise((resolveBlob) => canvas.toBlob(resolveBlob, "image/png"));
|
|
if (!blob) return "no blob";
|
|
const file = new File([blob], ${JSON.stringify(attachmentName)}, { type: "image/png" });
|
|
const inputs = [...document.querySelectorAll('input[type="file"][multiple]')];
|
|
const input = inputs.at(-1);
|
|
if (!input) return "no composer file input";
|
|
const transfer = new DataTransfer();
|
|
transfer.items.add(file);
|
|
input.files = transfer.files;
|
|
const startedAt = performance.now();
|
|
input.dispatchEvent(new Event("change", { bubbles: true }));
|
|
const chip = await new Promise((resolveChip) => {
|
|
const deadline = performance.now() + 5000;
|
|
const poll = () => {
|
|
const found = document.querySelector("[data-attachment-id]");
|
|
if (found) return resolveChip(found);
|
|
if (performance.now() > deadline) return resolveChip(null);
|
|
requestAnimationFrame(poll);
|
|
};
|
|
poll();
|
|
});
|
|
if (!chip) return "chip never appeared";
|
|
return JSON.stringify({
|
|
fileBytes: file.size,
|
|
elapsedMs: Math.round(performance.now() - startedAt),
|
|
chipTitle: chip.getAttribute("title"),
|
|
chipStatus: chip.getAttribute("data-attachment-status"),
|
|
});
|
|
})()`, { awaitPromise: true, timeoutMs: 60_000 });
|
|
if (typeof attachResult !== "string" || !attachResult.startsWith("{")) {
|
|
throw new Error(`Attaching the image failed: ${String(attachResult)}`);
|
|
}
|
|
const attach = JSON.parse(attachResult) as { fileBytes: number; elapsedMs: number; chipTitle: string | null; chipStatus: string | null };
|
|
expect(attach.fileBytes).toBeGreaterThan(1_500_000);
|
|
expect(attach.elapsedMs).toBeLessThan(2_000);
|
|
// The chip keeps the original .png name: compression no longer runs before
|
|
// the chip renders (the old attach path re-encoded to .jpg first, which is
|
|
// exactly the silent dead time users saw).
|
|
expect(attach.chipTitle).toBe(attachmentName);
|
|
expect(attach.chipStatus).toBe("ready");
|
|
evidence.recordAssertionEvidence(
|
|
"Attaching a large image shows its composer chip instantly instead of blocking on compression",
|
|
`A ${attach.fileBytes}-byte PNG showed its chip ${attach.elapsedMs}ms after the file input change event, still named ${attachmentName} (compression now happens at send time).`,
|
|
true,
|
|
);
|
|
|
|
await screenshot(app);
|
|
|
|
// Watch for the uploading overlay before clicking send, so even a fast
|
|
// local upload cannot slip past the assertion.
|
|
await evalIn(app, `(() => {
|
|
window.__attachmentUploadingSeen = false;
|
|
const record = () => {
|
|
if (document.querySelector('[data-attachment-status="uploading"]')) {
|
|
window.__attachmentUploadingSeen = true;
|
|
}
|
|
};
|
|
const observer = new MutationObserver(record);
|
|
observer.observe(document.body, { subtree: true, attributes: true, attributeFilter: ["data-attachment-status"], childList: true });
|
|
record();
|
|
return "observing";
|
|
})()`);
|
|
|
|
await clickButton(app, "Run task", { timeoutMs: 30_000 });
|
|
|
|
await waitFor(app, `window.__attachmentUploadingSeen === true`, {
|
|
timeoutMs: 30_000,
|
|
label: "attachment chip showed its uploading state during send",
|
|
});
|
|
evidence.recordAssertionEvidence(
|
|
"The attachment chip shows a visible uploading state while the send is in flight",
|
|
"A MutationObserver installed before composer.send recorded a chip with data-attachment-status=\"uploading\" — the send is no longer a silent hang.",
|
|
true,
|
|
);
|
|
|
|
// The regression is the pre-model attachment handoff. Prove the submission
|
|
// was accepted without depending on provider completion timing: the app
|
|
// creates a real session route and clears the sent chip from the composer.
|
|
// (The mock may still be generating when this bounded assertion completes.)
|
|
await waitFor(app, `window.location.hash.includes("/session/ses_")`, {
|
|
timeoutMs: 30_000,
|
|
label: "attachment submission created a session",
|
|
});
|
|
const sessionId = await evalIn(app, `window.location.hash.split("/session/")[1]?.split(/[/?#]/)[0] ?? ""`);
|
|
expect(typeof sessionId === "string" && sessionId.startsWith("ses_")).toBe(true);
|
|
await waitFor(app, `!document.querySelector("[data-attachment-id]")`, {
|
|
timeoutMs: 30_000,
|
|
label: "composer cleared its attachment chips after the send completed",
|
|
});
|
|
const errorToastVisible = await evalIn(app, `Boolean(document.querySelector('[data-sonner-toast][data-type="error"]'))`);
|
|
expect(errorToastVisible).toBe(false);
|
|
evidence.recordAssertionEvidence(
|
|
"The message with the attachment is accepted: a session is created, the composer clears, and no error toast appears",
|
|
`The app created session ${sessionId}, cleared the attachment chip from the composer, and showed no error toast.`,
|
|
true,
|
|
);
|
|
|
|
await screenshot(app);
|
|
|
|
const stopEnabled = await evalIn(app, `window.__openworkControl.listActions().some((action) => action.id === "composer.stop" && !action.disabled)`);
|
|
if (stopEnabled) await control(app, "composer.stop");
|
|
});
|