233 lines
10 KiB
TypeScript
233 lines
10 KiB
TypeScript
import { expect } from "vitest";
|
|
import { control, createAndSelectWorkspace, evalIn, waitFor } from "@openwork/behaviors";
|
|
import type { Surface } from "@openwork/cdp";
|
|
import { screenshot, validate } from "@openwork/test-evidence";
|
|
import { desktop } from "@openwork/hosts";
|
|
import { needs, test } from "@openwork/testkit";
|
|
|
|
const e2eTestsEnabled = process.env.OPENWORK_EVAL_E2E_TESTS === "1";
|
|
const title = e2eTestsEnabled
|
|
? "markdown artifacts autosave without Save/Discard buttons and format from the right-click menu"
|
|
: "markdown editor autosave skipped — needs: set OPENWORK_EVAL_E2E_TESTS=1";
|
|
|
|
const activeArtifactPath = "artifacts/overflow-tab-12.md";
|
|
const untouchedArtifactPath = "artifacts/overflow-tab-11.md";
|
|
const typedSentence = "Autosaved without buttons.";
|
|
|
|
const sleep = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms));
|
|
|
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
}
|
|
|
|
function readFileExpression(workspaceId: string, path: string): string {
|
|
return `(async () => {
|
|
const port = localStorage.getItem("openwork.server.port");
|
|
const token = localStorage.getItem("openwork.server.token");
|
|
if (!port || !token) return "";
|
|
const response = await fetch(
|
|
"http://127.0.0.1:" + port + "/workspace/" + encodeURIComponent(${JSON.stringify(workspaceId)}) + "/files/content?path=" + encodeURIComponent(${JSON.stringify(path)}),
|
|
{ headers: { Authorization: "Bearer " + token } },
|
|
);
|
|
if (!response.ok) return "";
|
|
const json = await response.json();
|
|
return typeof json.content === "string" ? json.content : "";
|
|
})()`;
|
|
}
|
|
|
|
async function waitForFileContent(
|
|
app: Surface,
|
|
workspaceId: string,
|
|
path: string,
|
|
predicate: (content: string) => boolean,
|
|
label: string,
|
|
): Promise<string> {
|
|
let last = "";
|
|
for (let attempt = 0; attempt < 60; attempt += 1) {
|
|
const value = await evalIn(app, readFileExpression(workspaceId, path), { awaitPromise: true });
|
|
last = typeof value === "string" ? value : "";
|
|
if (predicate(last)) return last;
|
|
await sleep(250);
|
|
}
|
|
throw new Error(`${label}: file ${path} never matched; last content was ${JSON.stringify(last)}`);
|
|
}
|
|
|
|
async function rightClick(app: Surface, selector: string): Promise<void> {
|
|
const rawPoint = await evalIn(app, `(() => {
|
|
const target = document.querySelector(${JSON.stringify(selector)});
|
|
if (!(target instanceof HTMLElement)) return "";
|
|
target.scrollIntoView({ block: "center" });
|
|
const rect = target.getBoundingClientRect();
|
|
return JSON.stringify({ x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 });
|
|
})()`);
|
|
if (typeof rawPoint !== "string" || !rawPoint) throw new Error(`Right-click target ${selector} did not return coordinates.`);
|
|
const point: unknown = JSON.parse(rawPoint);
|
|
if (!isRecord(point) || typeof point.x !== "number" || typeof point.y !== "number") {
|
|
throw new Error(`Right-click target ${selector} returned invalid coordinates: ${rawPoint}`);
|
|
}
|
|
await app.client.send("Input.dispatchMouseEvent", { type: "mouseMoved", x: point.x, y: point.y });
|
|
await app.client.send("Input.dispatchMouseEvent", { type: "mousePressed", x: point.x, y: point.y, button: "right", clickCount: 1 });
|
|
await app.client.send("Input.dispatchMouseEvent", { type: "mouseReleased", x: point.x, y: point.y, button: "right", clickCount: 1 });
|
|
}
|
|
|
|
test.skipIf(!e2eTestsEnabled)(title, async ({ evidence }) => {
|
|
needs({ optIn: ["OPENWORK_EVAL_E2E_TESTS"] });
|
|
|
|
await using app = await desktop({
|
|
name: "markdown-editor-autosave",
|
|
mode: process.env.OPENWORK_EVAL_CDP_URL?.trim() ? "attach" : "spawn",
|
|
});
|
|
const workspace = await createAndSelectWorkspace(app, {
|
|
path: `/tmp/openwork-markdown-editor-autosave-${Date.now()}`,
|
|
});
|
|
|
|
await waitFor(app, `window.__openworkControl.listActions().some((action) => action.id === "session.create_task" && !action.disabled)`, {
|
|
timeoutMs: 30_000,
|
|
label: "new task action enabled",
|
|
});
|
|
await control(app, "session.create_task");
|
|
await waitFor(app, `String(window.__openworkControl.snapshot().route || "").includes("/session/")`, {
|
|
timeoutMs: 30_000,
|
|
label: "session route open",
|
|
});
|
|
|
|
// Mount the side panel so the dev seed action registers, then open markdown artifacts.
|
|
const seedReady = await evalIn(app, `window.__openworkControl.listActions().some((action) => action.id === "eval.artifact_tabs.seed_overflow" && !action.disabled)`);
|
|
if (seedReady !== true) {
|
|
// browser.open_url sets the side panel open before navigating; the
|
|
// navigation itself can race the internal tab bootstrap, which is fine —
|
|
// we only need the panel mounted so its dev seed action registers.
|
|
await control(app, "browser.open_url", { url: "about:blank" }).catch(() => undefined);
|
|
}
|
|
await waitFor(app, `window.__openworkControl.listActions().some((action) => action.id === "eval.artifact_tabs.seed_overflow" && !action.disabled)`, {
|
|
timeoutMs: 30_000,
|
|
label: "artifact seed action enabled",
|
|
});
|
|
await control(app, "eval.artifact_tabs.seed_overflow", { count: 12 });
|
|
|
|
await waitFor(app, "Boolean(window.__artifactEditorView)", {
|
|
timeoutMs: 30_000,
|
|
label: "markdown artifact opened directly in the editor",
|
|
});
|
|
|
|
const header = await evalIn(app, `(() => {
|
|
const buttons = Array.from(document.querySelectorAll("button")).map((button) => (button.textContent ?? "").trim());
|
|
return JSON.stringify({
|
|
hasSave: buttons.includes("Save") || buttons.includes("Saving"),
|
|
hasDiscard: buttons.includes("Discard"),
|
|
mentionsBytes: /\\d+(\\.\\d+)?\\s*(bytes|KB|MB|GB|TB)/.test(document.body.innerText),
|
|
});
|
|
})()`);
|
|
if (typeof header !== "string") throw new Error("Header probe did not return JSON.");
|
|
const headerState: unknown = JSON.parse(header);
|
|
if (!isRecord(headerState)) throw new Error(`Header probe returned invalid JSON: ${header}`);
|
|
expect(headerState.hasSave).toBe(false);
|
|
expect(headerState.hasDiscard).toBe(false);
|
|
expect(headerState.mentionsBytes).toBe(false);
|
|
evidence.recordAssertionEvidence(
|
|
"The markdown editor exposes no Save or Discard buttons and no file byte count",
|
|
"With a markdown artifact open for direct editing, no button was labelled Save, Saving, or Discard, and no byte/KB size readout was visible.",
|
|
true,
|
|
);
|
|
|
|
const baseline = await waitForFileContent(
|
|
app,
|
|
workspace.workspaceId,
|
|
activeArtifactPath,
|
|
(content) => content.startsWith("# Overflow tab 12"),
|
|
"seeded artifact on disk",
|
|
);
|
|
const untouchedBaseline = await waitForFileContent(
|
|
app,
|
|
workspace.workspaceId,
|
|
untouchedArtifactPath,
|
|
(content) => content.startsWith("# Overflow tab 11"),
|
|
"sibling artifact on disk",
|
|
);
|
|
|
|
const typed = await evalIn(app, `(() => {
|
|
const view = window.__artifactEditorView;
|
|
if (!view) return false;
|
|
view.dispatch({ changes: { from: view.state.doc.length, insert: "\\n" + ${JSON.stringify(typedSentence)} } });
|
|
return true;
|
|
})()`);
|
|
expect(typed).toBe(true);
|
|
|
|
const savedContent = await waitForFileContent(
|
|
app,
|
|
workspace.workspaceId,
|
|
activeArtifactPath,
|
|
(content) => content.includes(typedSentence),
|
|
"autosave after typing",
|
|
);
|
|
expect(savedContent).toBe(`${baseline}\n${typedSentence}`);
|
|
await waitFor(app, `document.body.innerText.includes("Saved")`, {
|
|
timeoutMs: 15_000,
|
|
label: "Saved indicator after autosave",
|
|
});
|
|
evidence.recordAssertionEvidence(
|
|
"Typing in the markdown editor persists to disk without pressing any button",
|
|
`The artifact file content became exactly the baseline plus ${JSON.stringify(typedSentence)} and the header showed Saved.`,
|
|
true,
|
|
);
|
|
|
|
// Format the first line via the right-click menu.
|
|
const selected = await evalIn(app, `(() => {
|
|
const view = window.__artifactEditorView;
|
|
if (!view) return false;
|
|
view.dispatch({ selection: { anchor: 0 } });
|
|
return true;
|
|
})()`);
|
|
expect(selected).toBe(true);
|
|
await rightClick(app, ".cm-line");
|
|
await waitFor(app, `(() => {
|
|
const labels = [...document.querySelectorAll('[role="menuitem"]')].map((item) => item.textContent?.trim());
|
|
return ["Heading 1", "Heading 2", "Heading 3", "Bold", "Bullet list", "Quote"].every((label) => labels.includes(label));
|
|
})()`, { timeoutMs: 10_000, label: "markdown formatting context menu open" });
|
|
|
|
{
|
|
const shot = await screenshot(app);
|
|
const seen = await validate(shot, [
|
|
"A markdown editor is open with a right-click context menu offering Heading 1, Heading 2, Heading 3 and other formatting options",
|
|
"The editor header shows a Saved status label and contains no button labelled Save or Discard",
|
|
"No error dialog or crash message is visible",
|
|
]);
|
|
expect(seen.ok, seen.why).toBe(true);
|
|
}
|
|
|
|
const clickedHeading = await evalIn(app, `(() => {
|
|
const item = [...document.querySelectorAll('[role="menuitem"]')]
|
|
.find((entry) => entry.textContent?.trim() === "Heading 2");
|
|
if (!(item instanceof HTMLElement)) return false;
|
|
item.click();
|
|
return true;
|
|
})()`);
|
|
expect(clickedHeading).toBe(true);
|
|
|
|
await waitFor(app, `window.__artifactEditorView?.state.doc.line(1).text.startsWith("## ")`, {
|
|
timeoutMs: 10_000,
|
|
label: "first line converted to a level-2 heading",
|
|
});
|
|
const headingContent = await waitForFileContent(
|
|
app,
|
|
workspace.workspaceId,
|
|
activeArtifactPath,
|
|
(content) => content.startsWith("## Overflow tab 12"),
|
|
"autosave after heading formatting",
|
|
);
|
|
expect(headingContent).toBe(`#${baseline}\n${typedSentence}`);
|
|
evidence.recordAssertionEvidence(
|
|
"The right-click menu converts the current line to the chosen heading level and the change autosaves",
|
|
"Heading 2 rewrote the first line from `# Overflow tab 12` to `## Overflow tab 12`, and the file on disk matched exactly.",
|
|
true,
|
|
);
|
|
|
|
const untouchedAfter = await evalIn(app, readFileExpression(workspace.workspaceId, untouchedArtifactPath), { awaitPromise: true });
|
|
expect(untouchedAfter).toBe(untouchedBaseline);
|
|
evidence.recordAssertionEvidence(
|
|
"Autosave only writes the artifact being edited",
|
|
"A sibling markdown artifact opened in another tab kept byte-identical content after edits and formatting in the active tab.",
|
|
true,
|
|
);
|
|
});
|