feat(web): redesign Memory document reader
This commit is contained in:
@@ -0,0 +1,149 @@
|
||||
import { extname, join, normalize } from "jsr:@std/path@1.1.4";
|
||||
|
||||
const port = Number(Deno.args[0]);
|
||||
const buildRoot = Deno.args[1];
|
||||
const sectionCount = Deno.args[2] === undefined ? 90 : Number(Deno.args[2]);
|
||||
if (
|
||||
!Number.isInteger(port) || !buildRoot || !Number.isInteger(sectionCount) || sectionCount < 3 ||
|
||||
sectionCount > 100
|
||||
) {
|
||||
throw new Error("usage: server.ts <port> <build-root> [section-count: 3..100]");
|
||||
}
|
||||
|
||||
const workspaceId = "memory-review";
|
||||
const paragraphs = Array.from(
|
||||
{ length: sectionCount },
|
||||
(_, index) =>
|
||||
`## Section ${
|
||||
index + 1
|
||||
}\n\nThis is representative Memory content with **important context**, an identifier \`memory-item-${
|
||||
index + 1
|
||||
}\`, and a long URL https://example.com/${"long-segment-".repeat(12)}${
|
||||
index + 1
|
||||
}.\n\n> A durable note for section ${index + 1}.\n\n- first detail\n- second detail`,
|
||||
).join("\n\n");
|
||||
const bodyMd =
|
||||
`# Workspace Memory\n\nThis document is read-only.\n\n| Very wide heading | Another heading | Decision |\n| --- | --- | --- |\n| ${
|
||||
"wide-value-".repeat(16)
|
||||
} | stable | keep scrolling local |\n\n\`\`\`rust\nfn example() { println!("${
|
||||
"wide-code-".repeat(18)
|
||||
}"); }\n\`\`\`\n\n[Safe link](https://example.com) [Unsafe link](javascript:alert(1))\n\n<img src=x onerror=alert(1)>\n\n${paragraphs}\n\n## T-661 UNIQUE END MARKER`;
|
||||
|
||||
const json = (value: unknown, status = 200) =>
|
||||
new Response(JSON.stringify(value), {
|
||||
status,
|
||||
headers: { "content-type": "application/json; charset=utf-8" },
|
||||
});
|
||||
|
||||
const mime: Record<string, string> = {
|
||||
".html": "text/html; charset=utf-8",
|
||||
".js": "text/javascript; charset=utf-8",
|
||||
".css": "text/css; charset=utf-8",
|
||||
".json": "application/json; charset=utf-8",
|
||||
".svg": "image/svg+xml",
|
||||
".png": "image/png",
|
||||
".woff2": "font/woff2",
|
||||
};
|
||||
|
||||
async function staticResponse(pathname: string): Promise<Response> {
|
||||
const relative = pathname === "/" ? "index.html" : pathname.replace(/^\/+/, "");
|
||||
const normalized = normalize(relative);
|
||||
if (normalized.startsWith("..")) return new Response("not found", { status: 404 });
|
||||
let filePath = join(buildRoot, normalized);
|
||||
try {
|
||||
const stat = await Deno.stat(filePath);
|
||||
if (stat.isDirectory) filePath = join(filePath, "index.html");
|
||||
return new Response(await Deno.readFile(filePath), {
|
||||
headers: { "content-type": mime[extname(filePath)] ?? "application/octet-stream" },
|
||||
});
|
||||
} catch {
|
||||
return new Response(await Deno.readFile(join(buildRoot, "index.html")), {
|
||||
headers: { "content-type": "text/html; charset=utf-8" },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Deno.serve({ hostname: "127.0.0.1", port }, async (request) => {
|
||||
const url = new URL(request.url);
|
||||
if (url.pathname === "/health") return new Response("ok");
|
||||
if (url.pathname === "/api/workspaces") {
|
||||
return json([{
|
||||
workspace_id: workspaceId,
|
||||
owner_account_id: "owner",
|
||||
display_name: "Memory Review",
|
||||
state: "active",
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
updated_at: "2026-01-02T00:00:00Z",
|
||||
}]);
|
||||
}
|
||||
if (url.pathname === `/api/w/${workspaceId}/workspace`) {
|
||||
return json({
|
||||
workspace_id: workspaceId,
|
||||
display_name: "Memory Review",
|
||||
record_authority: "fixture",
|
||||
schema_version: 1,
|
||||
auth: {
|
||||
Passkey: {
|
||||
rp_id: "127.0.0.1",
|
||||
origin: `http://127.0.0.1:${port}`,
|
||||
public_base_url: `http://127.0.0.1:${port}`,
|
||||
cookie_name: "fixture",
|
||||
},
|
||||
},
|
||||
permissions: {
|
||||
manage_repositories: false,
|
||||
manage_secrets: false,
|
||||
manage_runtimes: false,
|
||||
delete_workspace: false,
|
||||
},
|
||||
extension_points: {
|
||||
store: "fixture",
|
||||
event_stream: { status: "ready", note: "fixture", diagnostics: [] },
|
||||
host_worker_bridge: { status: "ready", note: "fixture", diagnostics: [] },
|
||||
companion_console: { status: "ready", note: "fixture", diagnostics: [] },
|
||||
},
|
||||
});
|
||||
}
|
||||
if (url.pathname === `/api/w/${workspaceId}/repositories`) {
|
||||
return json({ workspace_id: workspaceId, items: [], source: "fixture", diagnostics: [] });
|
||||
}
|
||||
if (url.pathname === `/api/w/${workspaceId}/working-directories`) {
|
||||
return json({ workspace_id: workspaceId, items: [], diagnostics: [] });
|
||||
}
|
||||
if (url.pathname === `/api/w/${workspaceId}/memory`) {
|
||||
return json({
|
||||
body_md: bodyMd,
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
updated_at: "2026-01-02T03:04:05Z",
|
||||
bytes: new TextEncoder().encode(bodyMd).byteLength,
|
||||
record_source: "sqlite_workspace_authority.memory_document",
|
||||
});
|
||||
}
|
||||
if (
|
||||
url.pathname === `/api/w/${workspaceId}/protocol/ws` &&
|
||||
request.headers.get("upgrade") === "websocket"
|
||||
) {
|
||||
const { socket, response } = Deno.upgradeWebSocket(request);
|
||||
socket.onmessage = (event) => {
|
||||
const frame = JSON.parse(String(event.data));
|
||||
if (frame?.message?.method !== "subscribe_events") return;
|
||||
const requestId = frame.message.params.request_id;
|
||||
socket.send(JSON.stringify({
|
||||
protocol_version: 1,
|
||||
frame: "response",
|
||||
message: {
|
||||
result: "subscribed",
|
||||
payload: {
|
||||
request_id: requestId,
|
||||
subscription_id: "fixture-workers",
|
||||
selector: { topic: "workspace_workers" },
|
||||
snapshot_revision: 1,
|
||||
snapshot: { topic: "workers", data: { workers: [] } },
|
||||
},
|
||||
},
|
||||
}));
|
||||
};
|
||||
return response;
|
||||
}
|
||||
return await staticResponse(url.pathname);
|
||||
});
|
||||
@@ -0,0 +1,236 @@
|
||||
import { assert, assertEquals } from "@std/assert";
|
||||
import { dirname, fromFileUrl, join } from "@std/path";
|
||||
import { chromium, type Page } from "playwright";
|
||||
|
||||
const repositoryRoot = join(dirname(fromFileUrl(import.meta.url)), "../../..");
|
||||
const workspaceRoot = join(repositoryRoot, "web/workspace");
|
||||
const fixtureServer = join(
|
||||
repositoryRoot,
|
||||
"tools/web-ux/browser-tests/memory_document_fixture_server.ts",
|
||||
);
|
||||
const workspaceId = "memory-review";
|
||||
|
||||
async function freePort(): Promise<number> {
|
||||
const listener = Deno.listen({ hostname: "127.0.0.1", port: 0 });
|
||||
const port = (listener.addr as Deno.NetAddr).port;
|
||||
listener.close();
|
||||
return port;
|
||||
}
|
||||
|
||||
async function waitForServer(url: string): Promise<void> {
|
||||
for (let attempt = 0; attempt < 200; attempt += 1) {
|
||||
try {
|
||||
if ((await fetch(url)).ok) return;
|
||||
} catch {
|
||||
// Retry until the bounded deadline while the owned server starts.
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
}
|
||||
throw new Error(`fixture server did not become ready: ${url}`);
|
||||
}
|
||||
|
||||
async function mainScrollState(page: Page) {
|
||||
return await page.locator("main").evaluate((main) => ({
|
||||
scrollTop: main.scrollTop,
|
||||
scrollHeight: main.scrollHeight,
|
||||
clientHeight: main.clientHeight,
|
||||
}));
|
||||
}
|
||||
|
||||
async function markerIsInsideMain(page: Page): Promise<boolean> {
|
||||
return await page.evaluate(() => {
|
||||
const document = (globalThis as any).document;
|
||||
const main = document.querySelector("main");
|
||||
const marker = [...document.querySelectorAll("h2")].find((heading) =>
|
||||
heading.textContent === "T-661 UNIQUE END MARKER"
|
||||
);
|
||||
if (!main || !marker) return false;
|
||||
const mainRect = main.getBoundingClientRect();
|
||||
const markerRect = marker.getBoundingClientRect();
|
||||
return markerRect.top >= mainRect.top && markerRect.bottom <= mainRect.bottom;
|
||||
});
|
||||
}
|
||||
|
||||
async function openMemoryPage(
|
||||
page: Page,
|
||||
baseUrl: string,
|
||||
mobile = false,
|
||||
): Promise<void> {
|
||||
await page.goto(`${baseUrl}/w/${workspaceId}/memory`);
|
||||
const heading = page.locator("h1", { hasText: "Workspace Memory" });
|
||||
await heading.waitFor({ state: "attached" });
|
||||
if (mobile) await page.getByRole("button", { name: "Hide sidebar" }).click();
|
||||
await heading.waitFor();
|
||||
}
|
||||
|
||||
async function swipeMainToEnd(page: Page): Promise<void> {
|
||||
const session = await page.context().newCDPSession(page);
|
||||
for (let attempt = 0; attempt < 100; attempt += 1) {
|
||||
const state = await mainScrollState(page);
|
||||
if (state.scrollTop + state.clientHeight >= state.scrollHeight - 1) return;
|
||||
await session.send("Input.dispatchTouchEvent", {
|
||||
type: "touchStart",
|
||||
touchPoints: [{ x: 300, y: 700, id: 1 }],
|
||||
});
|
||||
for (const y of [580, 460, 340, 220, 100]) {
|
||||
await session.send("Input.dispatchTouchEvent", {
|
||||
type: "touchMove",
|
||||
touchPoints: [{ x: 300, y, id: 1 }],
|
||||
});
|
||||
}
|
||||
await session.send("Input.dispatchTouchEvent", {
|
||||
type: "touchEnd",
|
||||
touchPoints: [],
|
||||
});
|
||||
await page.waitForTimeout(20);
|
||||
}
|
||||
throw new Error("touch input did not reach the end of the Memory document");
|
||||
}
|
||||
|
||||
Deno.test("production Memory shell renders safe Markdown and scrolls to its end by wheel, keyboard, and touch", async () => {
|
||||
const build = await new Deno.Command(Deno.execPath(), {
|
||||
args: ["task", "build"],
|
||||
cwd: workspaceRoot,
|
||||
stdout: "piped",
|
||||
stderr: "piped",
|
||||
}).output();
|
||||
if (!build.success) {
|
||||
throw new Error(
|
||||
`Workspace production build failed:\n${new TextDecoder().decode(build.stderr)}`,
|
||||
);
|
||||
}
|
||||
|
||||
const port = await freePort();
|
||||
const baseUrl = `http://127.0.0.1:${port}`;
|
||||
const server = new Deno.Command(Deno.execPath(), {
|
||||
args: [
|
||||
"run",
|
||||
"--allow-net",
|
||||
"--allow-read",
|
||||
fixtureServer,
|
||||
String(port),
|
||||
join(workspaceRoot, "build"),
|
||||
],
|
||||
stdout: "null",
|
||||
stderr: "null",
|
||||
}).spawn();
|
||||
|
||||
try {
|
||||
await waitForServer(`${baseUrl}/health`);
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
try {
|
||||
for (
|
||||
const scenario of [
|
||||
{ label: "wheel", width: 1440, height: 900, colorScheme: "light" as const },
|
||||
{ label: "keyboard", width: 768, height: 900, colorScheme: "dark" as const },
|
||||
{
|
||||
label: "touch",
|
||||
width: 390,
|
||||
height: 844,
|
||||
colorScheme: "light" as const,
|
||||
mobile: true,
|
||||
},
|
||||
]
|
||||
) {
|
||||
const context = await browser.newContext({
|
||||
viewport: { width: scenario.width, height: scenario.height },
|
||||
colorScheme: scenario.colorScheme,
|
||||
hasTouch: scenario.mobile ?? false,
|
||||
isMobile: scenario.mobile ?? false,
|
||||
});
|
||||
const page = await context.newPage();
|
||||
const errors: string[] = [];
|
||||
page.on("console", (message) => {
|
||||
if (message.type() === "error") errors.push(message.text());
|
||||
});
|
||||
page.on("pageerror", (error) => errors.push(String(error)));
|
||||
await openMemoryPage(page, baseUrl, scenario.mobile);
|
||||
|
||||
const initial = await mainScrollState(page);
|
||||
assert(initial.scrollHeight > initial.clientHeight);
|
||||
assertEquals(await markerIsInsideMain(page), false);
|
||||
assertEquals(
|
||||
await page.evaluate(() => {
|
||||
const document = (globalThis as any).document;
|
||||
return document.documentElement.scrollWidth === document.documentElement.clientWidth;
|
||||
}),
|
||||
true,
|
||||
);
|
||||
|
||||
if (scenario.label === "wheel") {
|
||||
assertEquals(await page.locator("h1").allTextContents(), ["Workspace Memory"]);
|
||||
assertEquals(
|
||||
await page.locator("strong").filter({ hasText: "important context" }).count() > 0,
|
||||
true,
|
||||
);
|
||||
assertEquals(await page.locator("blockquote").count() > 0, true);
|
||||
assertEquals(await page.locator("ul li").count() > 0, true);
|
||||
assertEquals(await page.locator('a[href="https://example.com"]').count(), 1);
|
||||
assertEquals(await page.locator('a[href^="javascript:"]').count(), 0);
|
||||
assertEquals(await page.locator("article img, article script").count(), 0);
|
||||
assertEquals(await page.getByRole("heading", { name: "Memory Document" }).count(), 0);
|
||||
|
||||
for (const label of ["Markdown table", "rust code block"]) {
|
||||
const region = page.getByRole("region", { name: label });
|
||||
assertEquals(await region.getAttribute("tabindex"), "0");
|
||||
assert(await region.evaluate((element) => element.scrollWidth > element.clientWidth));
|
||||
}
|
||||
|
||||
await page.locator("main").hover();
|
||||
await page.mouse.wheel(0, 100_000);
|
||||
} else if (scenario.label === "keyboard") {
|
||||
await page.getByText("Document details", { exact: true }).focus();
|
||||
await page.keyboard.press("End");
|
||||
} else {
|
||||
await swipeMainToEnd(page);
|
||||
}
|
||||
|
||||
await page.waitForTimeout(500);
|
||||
const afterInteraction = await mainScrollState(page);
|
||||
assert(
|
||||
afterInteraction.scrollTop > 0,
|
||||
`${scenario.label} did not move the main scroll owner: ${
|
||||
JSON.stringify(afterInteraction)
|
||||
}`,
|
||||
);
|
||||
assertEquals(await markerIsInsideMain(page), true);
|
||||
assertEquals(errors, []);
|
||||
await context.close();
|
||||
}
|
||||
|
||||
const stateContext = await browser.newContext({ viewport: { width: 1440, height: 900 } });
|
||||
const emptyPage = await stateContext.newPage();
|
||||
await emptyPage.route(`**/api/w/${workspaceId}/memory`, async (route) => {
|
||||
await route.fulfill({
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
body_md: " \n",
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
updated_at: "2026-01-02T03:04:05Z",
|
||||
bytes: 2,
|
||||
record_source: "fixture",
|
||||
}),
|
||||
});
|
||||
});
|
||||
await emptyPage.goto(`${baseUrl}/w/${workspaceId}/memory`);
|
||||
await emptyPage.getByText("Memory document is empty.").waitFor();
|
||||
assertEquals(await emptyPage.getByRole("alert").count(), 0);
|
||||
|
||||
const errorPage = await stateContext.newPage();
|
||||
await errorPage.route(`**/api/w/${workspaceId}/memory`, async (route) => {
|
||||
await route.fulfill({ status: 503, contentType: "application/json", body: "{}" });
|
||||
});
|
||||
await errorPage.goto(`${baseUrl}/w/${workspaceId}/memory`);
|
||||
const alert = errorPage.getByRole("alert");
|
||||
await alert.waitFor();
|
||||
assert((await alert.textContent())?.includes("Memory document unavailable."));
|
||||
assertEquals(await errorPage.getByText("Memory document is empty.").count(), 0);
|
||||
await stateContext.close();
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
} finally {
|
||||
server.kill("SIGTERM");
|
||||
await server.status;
|
||||
}
|
||||
});
|
||||
@@ -11,7 +11,7 @@
|
||||
"web-ux": "deno run --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys cli.ts",
|
||||
"check": "deno check cli.ts src/*.ts tests/*.ts browser-tests/*.ts",
|
||||
"test": "deno test --allow-env --allow-read --allow-write --allow-run --allow-sys tests",
|
||||
"test:browser": "deno test --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys browser-tests/capture_smoke_test.ts"
|
||||
"test:browser": "deno test --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys browser-tests/capture_smoke_test.ts browser-tests/memory_document_shell_test.ts"
|
||||
},
|
||||
"fmt": {
|
||||
"lineWidth": 100,
|
||||
|
||||
Reference in New Issue
Block a user