feat(workspace): add shared Drive browsing editing and Worker grants
This commit is contained in:
@@ -0,0 +1,50 @@
|
||||
# T-724 browser fixture validation
|
||||
|
||||
These tests serve the real static production Web shell against a loopback, in-memory API fixture. They are **not actual Backend/DB/LocalFileSystem authorization or round-trip evidence**. The parent owns the separate real API checks.
|
||||
|
||||
## Runtime prerequisites
|
||||
|
||||
Deno and Playwright Chromium must be available. The server binds loopback only and owns/cleans up its shared Home fixture subprocess. Tests use an ephemeral port and independent fixture state; no real workspace data, credentials, git operations, environment test switches, or UI/CSS fixture branches are used.
|
||||
|
||||
The parent must confirm UI readiness before the production build and browser run. Tests intentionally never build automatically.
|
||||
|
||||
```sh
|
||||
# Narrow API-fixture compatibility checks (safe before UI readiness)
|
||||
deno test --config web/workspace/test/drive-browser/deno.json --allow-read web/workspace/test/drive-browser/fixture-api.test.ts
|
||||
|
||||
# After explicit parent UI-ready signal and successful production build only:
|
||||
deno test --config web/workspace/test/drive-browser/deno.json --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys web/workspace/test/drive-browser/drive.browser.test.ts
|
||||
|
||||
# Responsive audit, with optional frozen production site and artifact suffix:
|
||||
deno test --config web/workspace/test/drive-browser/deno.json --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys web/workspace/test/drive-browser/responsive.browser.test.ts -- web/workspace/.svelte-kit/t724/verified-build/site final-reviewed
|
||||
|
||||
# Official tools/web-ux bundles; no build is performed by this runner.
|
||||
deno run --config web/workspace/test/drive-browser/deno.json --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys web/workspace/test/drive-browser/capture-visual.ts before <unique-run-suffix>
|
||||
deno run --config web/workspace/test/drive-browser/deno.json --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys web/workspace/test/drive-browser/capture-visual.ts after <unique-run-suffix> [workdir-relative-production-site]
|
||||
```
|
||||
|
||||
## Fixture API
|
||||
|
||||
Start `tools/web-ux/drive-fixture/server.ts <port> <build-root>` with `--allow-net --allow-read --allow-run --allow-env`. It extends the shared Home/settings API fixture through a loopback proxy and implements Drive root/list/metadata/search/read-text/read-chunk/download/mutate/upload/request-status/grants APIs. Response wire types derive from generated Drive DTOs; current client parsers are exercised by the fixture compatibility tests.
|
||||
|
||||
- Root ID `9007199254740993` tests lossless decimal references.
|
||||
- `2`: folder 資料; `3`: Markdown README; `4`: text inside folder 2.
|
||||
- `5`: valid synthetic 1200×800 `landscape.png` (16,813 compressed bytes), generated as real binary PNG data by `tools/web-ux/drive-fixture/raster.ts`; `6`: empty text; `7`: undecodable PNG; `8`: SVG download-only; `9`: text exceeding 64 KiB; `10`: long Japanese name; `11`: valid 64 KiB NUL text whose escaped JSON exceeds 256 KiB but stays below the separate 512 KiB JSON bound.
|
||||
- `home-member` and `*readonly*`: reads succeed, mutations are typed HTTP 403. No metadata permission flag is invented. The UI reacts to a denied operation, not speculative permission inference.
|
||||
- `*denied*`: typed HTTP 403 root/read; `*offline*`/`home-error`: typed HTTP 503; `*empty*`: empty successful root list; `*paged*`: 240 additional nodes.
|
||||
- `POST /__fixture/fault` JSON `{workspace, mode}`: one-shot `conflict` or committed `unknown` mutation response; persistent `image_error` binary fetch failure.
|
||||
- `home-owner`/`home-member`/`home-long`: representative authoritative Worker catalogs; REST and subscription projections come from the same generated-shape records. `home-long` includes long display names and opaque Runtime/Worker IDs. `workers-error` intentionally fails catalog reads with HTTP 503.
|
||||
- `GET /__fixture/log`: observable API request IDs/methods/paths/node IDs/CAS revisions for identity and no-resend assertions.
|
||||
- `POST /__fixture/reset`: fresh in-memory state.
|
||||
|
||||
Request delay/loss tests intercept the API boundary with Playwright and synchronize on gates, not guessed sleeps. They let the fixture commit before dropping/holding the response, then verify receipt query without blind upload retry.
|
||||
|
||||
## Visual evidence
|
||||
|
||||
Preserved production baseline: `.svelte-kit/t724/before-build/{site,output,build.log}`. Official bundles live below `.svelte-kit/t724/visual/`. Each contains screenshots, console/request/page diagnostics, accessibility snapshots, screenshot hashes, source revision/dirty context, and `review-context.json`.
|
||||
|
||||
Home/sidebar and Workers are captured under identical owner/member/error/empty/long conditions, light/dark at 1440/768/390/320. After captures add Drive owner/member/empty/error/denied/paged/Markdown/image/image-error/truncated surfaces, pinned rename/replace revision forms, and owner grant selection/read-only/read-write/revoked states. Expected injected 503/403 diagnostics and superseded breadcrumb metadata aborts remain in official context; `diagnostic-disposition.json` explicitly separates unexpected diagnostics. CLI `completed-with-errors` is not relabeled a visual pass.
|
||||
|
||||
The production shell scrolls `main` internally, so `fullPage` does not expand that scroll panel. Capture points use ordinary keyboard focus to show the actual form or the top denial state; no CSS/DOM screenshot override is applied. The responsive audit separately records document/main/table-region geometry, visible keyboard focus, and page errors across the full theme/viewport matrix. Raster checks wait for actual decoding, assert natural dimensions 1200×800, preserve the 3:2 aspect ratio, and constrain the rendered box by both the available width and 60vh height at all four widths/both themes.
|
||||
|
||||
Parent and reviewer must open images and inspect context before asserting visual acceptance. In particular, a successful capture command is not sufficient and HTTP 200 does not excuse visible UI errors.
|
||||
@@ -0,0 +1,113 @@
|
||||
/// <reference lib="dom" />
|
||||
// Captures the preserved production baseline; no UI mutations or Backend connection.
|
||||
import { chromium } from "playwright";
|
||||
import { dirname, fromFileUrl, join } from "@std/path";
|
||||
const root = join(dirname(fromFileUrl(import.meta.url)), "../../../..");
|
||||
const artifactRoot = join(root, "web/workspace/.svelte-kit/t724/before-build");
|
||||
const listener = Deno.listen({ hostname: "127.0.0.1", port: 0 });
|
||||
const port = (listener.addr as Deno.NetAddr).port;
|
||||
listener.close();
|
||||
const server = new Deno.Command(Deno.execPath(), {
|
||||
args: [
|
||||
"run",
|
||||
"--allow-net",
|
||||
"--allow-read",
|
||||
join(root, "tools/web-ux/browser-tests/workspace_home_fixture_server.ts"),
|
||||
String(port),
|
||||
join(artifactRoot, "site"),
|
||||
],
|
||||
stdout: "null",
|
||||
stderr: "piped",
|
||||
}).spawn();
|
||||
const origin = `http://127.0.0.1:${port}`;
|
||||
const evidence: unknown[] = [];
|
||||
try {
|
||||
await Deno.mkdir(join(artifactRoot, "screenshots"), { recursive: true });
|
||||
const deadline = Date.now() + 10000;
|
||||
while (true) {
|
||||
try {
|
||||
const response = await fetch(`${origin}/health`);
|
||||
await response.body?.cancel();
|
||||
if (response.ok) break;
|
||||
} catch { /* startup */ }
|
||||
if (Date.now() > deadline) throw new Error("Home fixture did not start");
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
}
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
try {
|
||||
for (const colorScheme of ["light", "dark"] as const) {
|
||||
for (const width of [1440, 768, 390, 320]) {
|
||||
const context = await browser.newContext({
|
||||
viewport: { width, height: 900 },
|
||||
colorScheme,
|
||||
reducedMotion: "reduce",
|
||||
});
|
||||
try {
|
||||
const page = await context.newPage();
|
||||
// Shared Home server predates the sidebar's authoritative /workers fetch.
|
||||
// Complete that API fixture only, without changing the production shell.
|
||||
await page.route("**/api/w/*/workers", async (route) => {
|
||||
const workspaceId = new URL(route.request().url()).pathname.split("/")[3];
|
||||
await route.fulfill({
|
||||
json: {
|
||||
workspace_id: workspaceId,
|
||||
items: [],
|
||||
limit: 100,
|
||||
source: "fixture",
|
||||
diagnostics: [],
|
||||
},
|
||||
});
|
||||
});
|
||||
for (const state of ["owner", "member", "error", "empty"]) {
|
||||
const errors: string[] = [];
|
||||
const record = (error: Error) => errors.push(String(error));
|
||||
page.on("pageerror", record);
|
||||
await page.goto(`${origin}/w/home-${state}`);
|
||||
if (state === "error") {
|
||||
await page.locator("main [role=alert]").first().waitFor();
|
||||
} else {for (
|
||||
const feed of ["home-attention", "home-active", "home-recent"]
|
||||
) await page.locator(`[data-feed-ready="${feed}"]`).waitFor();}
|
||||
await page.evaluate(() => document.fonts.ready);
|
||||
const filename = `home-sidebar-workers-${state}-${colorScheme}-${width}.png`;
|
||||
await page.screenshot({
|
||||
path: join(artifactRoot, "screenshots", filename),
|
||||
fullPage: true,
|
||||
});
|
||||
evidence.push({
|
||||
filename,
|
||||
state,
|
||||
colorScheme,
|
||||
width,
|
||||
title: await page.locator("main h1").allTextContents(),
|
||||
sidebar: await page.locator("aside").allTextContents(),
|
||||
errors,
|
||||
overflow: await page.evaluate(() =>
|
||||
document.documentElement.scrollWidth > innerWidth
|
||||
),
|
||||
});
|
||||
page.off("pageerror", record);
|
||||
}
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
await Deno.writeTextFile(
|
||||
join(artifactRoot, "capture.json"),
|
||||
JSON.stringify(
|
||||
{ authority: "fixture, not actual Backend", evidence },
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
console.log(
|
||||
`Captured ${evidence.length} baseline screenshots at ${artifactRoot}/screenshots`,
|
||||
);
|
||||
} finally {
|
||||
server.kill("SIGTERM");
|
||||
await server.status;
|
||||
}
|
||||
@@ -0,0 +1,427 @@
|
||||
// Official tools/web-ux capture bundles (console, request, accessibility, context).
|
||||
// Does not build. Before always uses isolated saved site; after requires explicit parent readiness.
|
||||
import { dirname, fromFileUrl, join } from "@std/path";
|
||||
import type { RouteScenario } from "../../../../tools/web-ux/src/types.ts";
|
||||
const root = join(dirname(fromFileUrl(import.meta.url)), "../../../..");
|
||||
const stage = Deno.args[0];
|
||||
if (stage !== "before" && stage !== "after") {
|
||||
throw new Error("usage: capture-visual.ts before|after");
|
||||
}
|
||||
const output = join(root, "web/workspace/.svelte-kit/t724/visual");
|
||||
await Deno.mkdir(output, { recursive: true });
|
||||
const runSuffix = Deno.args[1] ? `-${Deno.args[1]}` : "";
|
||||
for (const scheme of ["light", "dark"]) {
|
||||
const runId = `t724-${stage}-${scheme}${runSuffix}`;
|
||||
const routes = ["owner", "member", "empty", "error", "long"].map((
|
||||
state,
|
||||
): RouteScenario => ({
|
||||
id: `home-${state}`,
|
||||
label: `Home/sidebar Workers ${state}`,
|
||||
path: `/w/home-${state}`,
|
||||
goal:
|
||||
"Compare unchanged Home/sidebar/Workers composition under identical API fixture conditions.",
|
||||
dataState: state === "error"
|
||||
? "Intentional 503 for Home merge-requests; visible scoped error expected."
|
||||
: `Shared Home ${state}; representative authoritative Workers (empty for new workspace).`,
|
||||
ready: {
|
||||
kind: "selector",
|
||||
selector:
|
||||
".workspace-home:has([data-feed-ready='home-active']):has([data-feed-ready='home-recent'])",
|
||||
},
|
||||
capturePoints: [{
|
||||
id: "initial",
|
||||
label: "Home and Workers sidebar",
|
||||
fullPage: true,
|
||||
}],
|
||||
}));
|
||||
for (const state of ["owner", "member", "empty", "error", "long"]) {
|
||||
routes.push({
|
||||
id: `workers-${state}`,
|
||||
label: `Workers ${state}`,
|
||||
path: state === "error" ? "/w/workers-error/workers" : `/w/home-${state}/workers`,
|
||||
goal: "Compare the existing Worker management surface with identical authoritative catalogs.",
|
||||
dataState:
|
||||
`Shared Home ${state} identity; representative Worker list (empty or 503 when requested). Owner controls follow workspace permission projection.`,
|
||||
ready: {
|
||||
kind: "selector",
|
||||
selector: state === "empty"
|
||||
? ".workers-page:has-text('No Workers are visible.')"
|
||||
: state === "error"
|
||||
? ".workers-page .section-state.error"
|
||||
: ".workers-page .workers-table tbody tr",
|
||||
},
|
||||
capturePoints: [{ id: "initial", label: "Workers", fullPage: true }],
|
||||
});
|
||||
}
|
||||
if (stage === "after") {
|
||||
routes.push({
|
||||
id: "drive-form-revision",
|
||||
label: "Pinned rename and replace form revisions",
|
||||
path: "/w/home-owner/drive/3",
|
||||
goal: "Show the form's locked read revision and explicit destination/file controls.",
|
||||
dataState:
|
||||
"Revision 1 read from fixture metadata; no mutation is sent by these capture interactions.",
|
||||
ready: { kind: "selector", selector: '.drive-page[data-drive-ready="true"]' },
|
||||
capturePoints: [
|
||||
{
|
||||
id: "rename",
|
||||
label: "Rename / move pinned revision",
|
||||
fullPage: true,
|
||||
interaction: [
|
||||
{ action: "click", selector: '.drive-toolbar button:has-text("Rename / move")' },
|
||||
{
|
||||
action: "wait",
|
||||
ready: { kind: "selector", selector: '.drive-form:has-text("Expected revision 1.")' },
|
||||
},
|
||||
{ action: "press", selector: ".drive-form input:not([inputmode])", key: "Shift" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "replace",
|
||||
label: "Replace file pinned revision",
|
||||
fullPage: true,
|
||||
interaction: [
|
||||
{ action: "click", selector: '.drive-form button:has-text("Cancel form")' },
|
||||
{ action: "click", selector: '.drive-toolbar button:has-text("Replace file")' },
|
||||
{
|
||||
action: "wait",
|
||||
ready: { kind: "selector", selector: '.drive-form:has-text("Expected revision 1.")' },
|
||||
},
|
||||
{ action: "press", selector: ".drive-form input[type=file]", key: "Shift" },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
routes.push({
|
||||
id: "worker-grants-owner",
|
||||
label: "Owner Worker Drive grant lifecycle",
|
||||
path: "/w/home-owner/workers",
|
||||
goal:
|
||||
"Use the authoritative Worker catalog to grant read-only/read-write access and revoke it without conflating Profile enablement.",
|
||||
dataState:
|
||||
"Representative REST catalog and matching protocol snapshot; in-memory grant API, not actual Backend.",
|
||||
ready: { kind: "selector", selector: ".drive-grants select" },
|
||||
capturePoints: [
|
||||
{
|
||||
id: "selected",
|
||||
label: "Selected Worker without grants",
|
||||
fullPage: true,
|
||||
interaction: [
|
||||
{
|
||||
action: "press",
|
||||
selector: ".drive-grants .worker-select select",
|
||||
key: "ArrowDown",
|
||||
},
|
||||
{
|
||||
action: "press",
|
||||
selector: ".drive-grants .worker-select select",
|
||||
key: "Enter",
|
||||
},
|
||||
{
|
||||
action: "wait",
|
||||
ready: {
|
||||
kind: "selector",
|
||||
selector: '.drive-grants button:has-text("Add Drive grant")',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "read-only",
|
||||
label: "Read-only grant",
|
||||
fullPage: true,
|
||||
interaction: [
|
||||
{
|
||||
action: "click",
|
||||
selector: '.drive-grants button:has-text("Add Drive grant")',
|
||||
},
|
||||
{ action: "click", selector: ".drive-grants button[type=submit]" },
|
||||
{
|
||||
action: "wait",
|
||||
ready: {
|
||||
kind: "selector",
|
||||
selector: '.drive-grants strong:has-text("Read only")',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "read-write",
|
||||
label: "Read-write grant after explicit revoke",
|
||||
fullPage: true,
|
||||
interaction: [
|
||||
{
|
||||
action: "click",
|
||||
selector: '.drive-grants button[aria-label^="Revoke Drive grant"]',
|
||||
},
|
||||
{
|
||||
action: "wait",
|
||||
ready: {
|
||||
kind: "selector",
|
||||
selector: '.drive-grants:has-text("No active Drive grants for this Worker.")',
|
||||
},
|
||||
},
|
||||
{
|
||||
action: "click",
|
||||
selector: '.drive-grants button:has-text("Add Drive grant")',
|
||||
},
|
||||
{
|
||||
action: "press",
|
||||
selector: ".drive-grants form select",
|
||||
key: "ArrowDown",
|
||||
},
|
||||
{
|
||||
action: "press",
|
||||
selector: ".drive-grants form select",
|
||||
key: "Enter",
|
||||
},
|
||||
{ action: "click", selector: ".drive-grants button[type=submit]" },
|
||||
{
|
||||
action: "wait",
|
||||
ready: {
|
||||
kind: "selector",
|
||||
selector: '.drive-grants strong:has-text("Read and write")',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "revoked",
|
||||
label: "Grant revoked",
|
||||
fullPage: true,
|
||||
interaction: [
|
||||
{
|
||||
action: "click",
|
||||
selector: '.drive-grants button[aria-label^="Revoke Drive grant"]',
|
||||
},
|
||||
{
|
||||
action: "wait",
|
||||
ready: {
|
||||
kind: "selector",
|
||||
selector: '.drive-grants:has-text("No active Drive grants for this Worker.")',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
for (
|
||||
const [state, workspace, nodeId] of [
|
||||
["owner", "home-owner", ""],
|
||||
["member", "home-member", ""],
|
||||
["empty", "home-empty", ""],
|
||||
["error", "home-error", ""],
|
||||
["denied", "drive-denied", ""],
|
||||
["paged", "drive-paged", ""],
|
||||
["markdown", "home-owner", "/3"],
|
||||
["image", "home-owner", "/5"],
|
||||
["image-error", "home-owner", "/7"],
|
||||
["truncated", "home-owner", "/9"],
|
||||
]
|
||||
) {
|
||||
routes.push({
|
||||
id: `drive-${state}`,
|
||||
label: `Drive ${state}`,
|
||||
path: `/w/${workspace}/drive${nodeId}`,
|
||||
goal:
|
||||
"Review real production Drive layout, bounded safe preview and distinct failure states.",
|
||||
dataState: `In-memory Drive API ${state}, explicitly NOT actual Backend.`,
|
||||
ready: {
|
||||
kind: "selector",
|
||||
selector: state === "image-error"
|
||||
? "main .drive-page [role=alert]"
|
||||
: 'main [data-drive-ready="true"]',
|
||||
},
|
||||
capturePoints: state === "member"
|
||||
? [
|
||||
{
|
||||
id: "initial",
|
||||
label: "Member before authority denial",
|
||||
fullPage: true,
|
||||
},
|
||||
{
|
||||
id: "denied-write",
|
||||
label: "Member after typed write denial",
|
||||
fullPage: true,
|
||||
interaction: [
|
||||
{
|
||||
action: "click",
|
||||
selector: '.drive-page button:has-text("New folder")',
|
||||
},
|
||||
{
|
||||
action: "fill",
|
||||
selector: ".drive-form input:not([type=file])",
|
||||
value: "member-denied-folder",
|
||||
},
|
||||
{
|
||||
action: "click",
|
||||
selector: ".drive-form button[type=submit]",
|
||||
},
|
||||
{
|
||||
action: "wait",
|
||||
ready: {
|
||||
kind: "selector",
|
||||
selector: '.drive-page:has-text("Read only / access denied by Backend")',
|
||||
},
|
||||
},
|
||||
{
|
||||
action: "press",
|
||||
selector: ".drive-heading button",
|
||||
key: "Shift",
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
: [{ id: "initial", label: `Drive ${state}`, fullPage: true }],
|
||||
});
|
||||
}
|
||||
}
|
||||
const port = scheme === "light" ? 17241 : 17242;
|
||||
const scenario = {
|
||||
schemaVersion: 1,
|
||||
id: runId,
|
||||
title: `T-724 fixture-only ${stage} ${scheme}`,
|
||||
baseUrl: `http://127.0.0.1:${port}`,
|
||||
colorScheme: scheme,
|
||||
reducedMotion: "reduce",
|
||||
personas: [{
|
||||
id: "fixture",
|
||||
label: "Fixture account; Backend not connected",
|
||||
auth: { kind: "anonymous" },
|
||||
}],
|
||||
viewports: [1440, 768, 390, 320].map((width) => ({
|
||||
label: String(width),
|
||||
width,
|
||||
height: 900,
|
||||
})),
|
||||
routes,
|
||||
processes: [{
|
||||
id: "drive-fixture",
|
||||
command: Deno.execPath(),
|
||||
args: [
|
||||
"run",
|
||||
"--allow-net",
|
||||
"--allow-read",
|
||||
"--allow-run",
|
||||
"--allow-env",
|
||||
join(root, "tools/web-ux/drive-fixture/server.ts"),
|
||||
String(port),
|
||||
Deno.args[2]
|
||||
? join(root, Deno.args[2])
|
||||
: stage === "before"
|
||||
? join(root, "web/workspace/.svelte-kit/t724/before-build/site")
|
||||
: join(root, "web/workspace/build"),
|
||||
],
|
||||
readyUrl: `http://127.0.0.1:${port}/health`,
|
||||
}],
|
||||
};
|
||||
const path = join(output, `${stage}-${scheme}.json`);
|
||||
await Deno.writeTextFile(path, JSON.stringify(scenario, null, 2));
|
||||
const command = new Deno.Command(Deno.execPath(), {
|
||||
cwd: root,
|
||||
args: [
|
||||
"run",
|
||||
"--config",
|
||||
"tools/web-ux/deno.json",
|
||||
"--allow-env",
|
||||
"--allow-net",
|
||||
"--allow-read",
|
||||
"--allow-write",
|
||||
"--allow-run",
|
||||
"--allow-sys",
|
||||
"tools/web-ux/cli.ts",
|
||||
"capture",
|
||||
"--scenario",
|
||||
path,
|
||||
"--output",
|
||||
output,
|
||||
"--run-id",
|
||||
runId,
|
||||
],
|
||||
stdout: "inherit",
|
||||
stderr: "inherit",
|
||||
});
|
||||
const result = await command.output();
|
||||
if (!result.success && result.code !== 2) {
|
||||
throw new Error(
|
||||
`Official ${stage}/${scheme} capture failed (${result.code}); inspect review-context.`,
|
||||
);
|
||||
}
|
||||
// CLI exit 2 retains intentional HTTP error-state diagnostics rather than hiding them.
|
||||
// Parent must inspect the contexts; report unexpected errors separately.
|
||||
const context = JSON.parse(
|
||||
await Deno.readTextFile(join(output, runId, "review-context.json")),
|
||||
);
|
||||
const unexpected = context.captures.flatMap((
|
||||
capture: {
|
||||
route: { id: string };
|
||||
errors: { kind: string; message: string; url?: string }[];
|
||||
},
|
||||
) =>
|
||||
capture.errors.filter((error) => {
|
||||
// Selection/read-text republishing entry metadata supersedes the in-flight
|
||||
// breadcrumb lookup. Its abort is intentional and must not become a UI error.
|
||||
if (
|
||||
[
|
||||
"drive-markdown",
|
||||
"drive-form-revision",
|
||||
"drive-image",
|
||||
"drive-image-error",
|
||||
"drive-truncated",
|
||||
].includes(capture.route.id) && error.kind === "request" &&
|
||||
error.message === "net::ERR_ABORTED" &&
|
||||
error.url?.includes("/home-owner/drive/metadata?")
|
||||
) return false;
|
||||
if (
|
||||
["drive-member", "drive-denied"].includes(capture.route.id) &&
|
||||
error.kind === "document" &&
|
||||
error.message ===
|
||||
"visible UI error: Drive request failed: denied. No empty content or success is inferred from this failure."
|
||||
) return false;
|
||||
if (
|
||||
capture.route.id === "drive-error" && error.kind === "document" &&
|
||||
error.message ===
|
||||
"visible UI error: Drive request failed: storage_unavailable. No empty content or success is inferred from this failure."
|
||||
) return false;
|
||||
if (capture.route.id === "home-error") {
|
||||
return !(error.kind === "console" && error.message.includes("503")) &&
|
||||
!error.url?.includes("/home-error/merge-requests?");
|
||||
}
|
||||
if (capture.route.id === "workers-error") {
|
||||
return !(error.kind === "console" && error.message.includes("503")) &&
|
||||
!(error.url?.includes("/workers-error/workers") &&
|
||||
/HTTP 503|net::ERR_ABORTED/.test(error.message));
|
||||
}
|
||||
if (capture.route.id === "drive-member") {
|
||||
return !(error.kind === "console" && error.message.includes("403")) &&
|
||||
!(error.url?.includes("/home-member/drive/mutate") &&
|
||||
/HTTP 403|net::ERR_ABORTED/.test(error.message));
|
||||
}
|
||||
if (
|
||||
capture.route.id === "drive-error" ||
|
||||
capture.route.id === "drive-denied"
|
||||
) {
|
||||
return !(error.kind === "console" && /403|503/.test(error.message)) &&
|
||||
!error.url?.includes("/drive/");
|
||||
}
|
||||
return true;
|
||||
}).map((error) => ({ route: capture.route.id, ...error }))
|
||||
);
|
||||
await Deno.writeTextFile(
|
||||
join(output, runId, "diagnostic-disposition.json"),
|
||||
JSON.stringify(
|
||||
{
|
||||
expected:
|
||||
"Home reviews/Worker catalog error: injected 503. Drive denied/member/error: injected typed 403/503 and their precise visible failure messages. File metadata ERR_ABORTED is a superseded breadcrumb read, fenced by the UI epoch with no visible breadcrumb failure. All retained in review-context; no diagnostics are removed from official evidence.",
|
||||
unexpected,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
if (unexpected.length) {
|
||||
throw new Error(
|
||||
`${unexpected.length} unexpected visual diagnostics; inspect disposition.json`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"nodeModulesDir": "auto",
|
||||
"imports": {
|
||||
"#lib/": "../../src/lib/",
|
||||
"@std/assert": "jsr:@std/assert@1.0.19",
|
||||
"@std/path": "jsr:@std/path@1.1.4",
|
||||
"playwright": "npm:playwright@1.59.1"
|
||||
},
|
||||
"lock": false,
|
||||
"tasks": {
|
||||
"test:fixture": "deno test --allow-read fixture-api.test.ts",
|
||||
"test:browser": "deno test --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys drive.browser.test.ts",
|
||||
"test:responsive": "deno test --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys responsive.browser.test.ts"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,715 @@
|
||||
/// <reference lib="dom" />
|
||||
import { assert, assertEquals } from "@std/assert";
|
||||
import { dirname, fromFileUrl, join } from "@std/path";
|
||||
import { chromium, type Page, type Route } from "playwright";
|
||||
import { Buffer } from "node:buffer";
|
||||
import type { FixtureLog } from "../../../../tools/web-ux/drive-fixture/api.ts";
|
||||
const root = join(dirname(fromFileUrl(import.meta.url)), "../../../..");
|
||||
const ready = (page: Page) => page.locator('[data-drive-ready="true"]').waitFor();
|
||||
const button = (page: Page, name: string) => page.getByRole("button", { name, exact: true });
|
||||
const draft = (page: Page) => page.getByRole("textbox", { name: /^Draft \(expected revision/ });
|
||||
const preview = (page: Page) => page.getByRole("article", { name: "Current saved content" });
|
||||
function gate() {
|
||||
let release!: () => void;
|
||||
const promise = new Promise<void>((resolve) => release = resolve);
|
||||
return {
|
||||
release,
|
||||
get promise() {
|
||||
return (async () => {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
try {
|
||||
return await Promise.race([
|
||||
promise,
|
||||
new Promise<void>((_resolve, reject) => {
|
||||
timer = setTimeout(
|
||||
() =>
|
||||
reject(
|
||||
new Error(
|
||||
"Expected API gate was not observed/released within 30 seconds",
|
||||
),
|
||||
),
|
||||
30000,
|
||||
);
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
})();
|
||||
},
|
||||
};
|
||||
}
|
||||
async function harness(
|
||||
run: (
|
||||
origin: string,
|
||||
browser: Awaited<ReturnType<typeof chromium.launch>>,
|
||||
) => Promise<void>,
|
||||
) {
|
||||
// Intentionally no build here. Parent must authorize and build production UI first.
|
||||
const listener = Deno.listen({ hostname: "127.0.0.1", port: 0 });
|
||||
const port = (listener.addr as Deno.NetAddr).port;
|
||||
listener.close();
|
||||
const origin = `http://127.0.0.1:${port}`;
|
||||
const server = new Deno.Command(Deno.execPath(), {
|
||||
args: [
|
||||
"run",
|
||||
"--allow-read",
|
||||
"--allow-net",
|
||||
"--allow-run",
|
||||
"--allow-env",
|
||||
join(root, "tools/web-ux/drive-fixture/server.ts"),
|
||||
String(port),
|
||||
join(root, "web/workspace/build"),
|
||||
],
|
||||
stdout: "null",
|
||||
stderr: "inherit",
|
||||
}).spawn();
|
||||
try {
|
||||
const deadline = Date.now() + 10000;
|
||||
while (true) {
|
||||
try {
|
||||
const response = await fetch(`${origin}/health`);
|
||||
await response.body?.cancel();
|
||||
if (response.ok) break;
|
||||
} catch { /* startup */ }
|
||||
if (Date.now() > deadline) {
|
||||
throw new Error("Drive fixture failed startup");
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 30));
|
||||
}
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
try {
|
||||
await run(origin, browser);
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
} finally {
|
||||
server.kill("SIGTERM");
|
||||
await server.status;
|
||||
}
|
||||
}
|
||||
async function editor(page: Page, text: string) {
|
||||
await ready(page);
|
||||
await button(page, "Edit text").click();
|
||||
await draft(page).fill(text);
|
||||
}
|
||||
async function rootFolder(page: Page) {
|
||||
await page.getByRole("navigation", { name: "Drive folders" }).getByRole(
|
||||
"link",
|
||||
{
|
||||
name: "Drive",
|
||||
exact: true,
|
||||
},
|
||||
).click();
|
||||
await page.getByRole("heading", { name: "Drive", exact: true }).waitFor();
|
||||
await ready(page);
|
||||
}
|
||||
async function otherFile(page: Page) {
|
||||
await rootFolder(page);
|
||||
await page.getByRole("link", { name: "資料", exact: true }).click();
|
||||
await ready(page);
|
||||
await page.getByRole("link", { name: "notes.txt", exact: true }).click();
|
||||
await ready(page);
|
||||
}
|
||||
async function fault(origin: string, workspace: string, mode: string) {
|
||||
const response = await fetch(`${origin}/__fixture/fault`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ workspace, mode }),
|
||||
});
|
||||
await response.body?.cancel();
|
||||
}
|
||||
async function logs(origin: string) {
|
||||
return await (await fetch(`${origin}/__fixture/log`)).json() as FixtureLog[];
|
||||
}
|
||||
async function safeFulfill(
|
||||
route: Route,
|
||||
response: Awaited<ReturnType<Route["fetch"]>>,
|
||||
) {
|
||||
try {
|
||||
await route.fulfill({ response });
|
||||
} catch { /* selection intentionally canceled this old request */ }
|
||||
}
|
||||
|
||||
Deno.test("production Drive two tabs retain losing CAS draft and never retry with a newer revision", () =>
|
||||
harness(async (origin, browser) => {
|
||||
const context = await browser.newContext();
|
||||
const first = await context.newPage(), second = await context.newPage();
|
||||
const errors: string[] = [];
|
||||
first.on("pageerror", (e) => errors.push(String(e)));
|
||||
second.on("pageerror", (e) => errors.push(String(e)));
|
||||
try {
|
||||
await first.goto(`${origin}/w/home-owner/drive/3`);
|
||||
await second.goto(`${origin}/w/home-owner/drive/3`);
|
||||
await editor(first, "First actor wins");
|
||||
await editor(second, "Second actor unsaved draft");
|
||||
await button(first, "Save").click();
|
||||
await first.getByText("Published — DB commit confirmed", { exact: false })
|
||||
.last().waitFor();
|
||||
await button(second, "Save").click();
|
||||
await second.getByText("Revision conflict — your draft is retained.", {
|
||||
exact: true,
|
||||
})
|
||||
.waitFor();
|
||||
assertEquals(
|
||||
await draft(second).inputValue(),
|
||||
"Second actor unsaved draft",
|
||||
);
|
||||
assertEquals(await button(second, "Save").isDisabled(), true);
|
||||
await button(second, "Refresh").click();
|
||||
await ready(second);
|
||||
assertEquals(
|
||||
await draft(second).inputValue(),
|
||||
"Second actor unsaved draft",
|
||||
);
|
||||
assert(
|
||||
(await second.getByText("Draft (expected revision 1)", { exact: false })
|
||||
.count()) > 0,
|
||||
);
|
||||
assert(
|
||||
(await preview(second).textContent())!.includes("First actor wins"),
|
||||
);
|
||||
await otherFile(second);
|
||||
await editor(second, "Independent second-file draft");
|
||||
await rootFolder(second);
|
||||
await second.getByRole("link", { name: "README.md", exact: true })
|
||||
.click();
|
||||
await ready(second);
|
||||
await button(second, "Edit text").click();
|
||||
assertEquals(
|
||||
await draft(second).inputValue(),
|
||||
"Second actor unsaved draft",
|
||||
);
|
||||
const casWrites = (await logs(origin)).filter((x) => x.path === "/mutate");
|
||||
assertEquals(casWrites.map((x) => [x.nodeId, x.expectedRevision]), [[
|
||||
"3",
|
||||
"1",
|
||||
], ["3", "1"]]);
|
||||
assertEquals(errors, []);
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
}));
|
||||
|
||||
Deno.test("production Drive delayed old read cannot replace another file or workspace content", () =>
|
||||
harness(async (origin, browser) => {
|
||||
const context = await browser.newContext();
|
||||
const page = await context.newPage();
|
||||
const received = gate(), release = gate();
|
||||
let handled: Promise<void> = Promise.resolve();
|
||||
await page.route("**/drive/read-text?**", (route) => {
|
||||
if (new URL(route.request().url()).searchParams.get("id") !== "3") {
|
||||
return route.continue();
|
||||
}
|
||||
handled = (async () => {
|
||||
const response = await route.fetch();
|
||||
received.release();
|
||||
await release.promise;
|
||||
await safeFulfill(route, response);
|
||||
})();
|
||||
return handled;
|
||||
});
|
||||
try {
|
||||
await page.goto(`${origin}/w/home-owner/drive/3`);
|
||||
await received.promise;
|
||||
await otherFile(page);
|
||||
await editor(page, "Only current file draft");
|
||||
release.release();
|
||||
await handled;
|
||||
assertEquals(await page.locator("main h1").textContent(), "notes.txt");
|
||||
assertEquals(await draft(page).inputValue(), "Only current file draft");
|
||||
await button(page, "Save").click();
|
||||
await page.getByText("Published — DB commit confirmed", { exact: false })
|
||||
.last().waitFor();
|
||||
const mutation = (await logs(origin)).filter((x) => x.path === "/mutate");
|
||||
assertEquals(
|
||||
mutation.map((x) => [x.workspace, x.nodeId, x.expectedRevision]),
|
||||
[[
|
||||
"home-owner",
|
||||
"4",
|
||||
"1",
|
||||
]],
|
||||
);
|
||||
// Full workspace navigation must likewise never resurrect the old workspace.
|
||||
await page.goto(`${origin}/w/home-member/drive/4`);
|
||||
await ready(page);
|
||||
assert((await preview(page).textContent())!.includes("Second document"));
|
||||
assertEquals(
|
||||
await page.locator("main .drive-url a").getAttribute("href"),
|
||||
"/w/home-member/drive/4",
|
||||
);
|
||||
} finally {
|
||||
release.release();
|
||||
await handled;
|
||||
await context.close();
|
||||
}
|
||||
}));
|
||||
|
||||
Deno.test("production Drive delayed old save and status preserve destination drafts and save identity", () =>
|
||||
harness(async (origin, browser) => {
|
||||
const context = await browser.newContext();
|
||||
const page = await context.newPage();
|
||||
const sent = gate(), release = gate();
|
||||
let handled = Promise.resolve();
|
||||
await page.route("**/drive/mutate", (route) => {
|
||||
handled = (async () => {
|
||||
const response = await route.fetch();
|
||||
sent.release();
|
||||
await release.promise;
|
||||
await safeFulfill(route, response);
|
||||
})();
|
||||
return handled;
|
||||
});
|
||||
try {
|
||||
await page.goto(`${origin}/w/home-owner/drive/3`);
|
||||
await editor(page, "Old file transmitted draft");
|
||||
await button(page, "Save").click();
|
||||
await sent.promise;
|
||||
await otherFile(page);
|
||||
await editor(page, "Destination draft never rebased");
|
||||
release.release();
|
||||
await handled;
|
||||
await page.unroute("**/drive/mutate");
|
||||
assertEquals(
|
||||
await draft(page).inputValue(),
|
||||
"Destination draft never rebased",
|
||||
);
|
||||
await button(page, "Save").click();
|
||||
await page.getByText("Published — DB commit confirmed", { exact: false })
|
||||
.last().waitFor();
|
||||
await rootFolder(page);
|
||||
await page.getByRole("link", { name: "README.md", exact: true }).click();
|
||||
await ready(page);
|
||||
await page.getByRole("button", { name: "Check request result" }).click();
|
||||
await page.getByText("Published — DB commit confirmed", { exact: false })
|
||||
.last().waitFor();
|
||||
assert(
|
||||
(await preview(page).textContent())!.includes(
|
||||
"Old file transmitted draft",
|
||||
),
|
||||
);
|
||||
await editor(page, "Another old request draft");
|
||||
await fault(origin, "home-owner", "unknown");
|
||||
await button(page, "Save").click();
|
||||
await button(page, "Check request result").waitFor();
|
||||
const statusSent = gate(), statusRelease = gate();
|
||||
let statusHandled = Promise.resolve();
|
||||
await page.route("**/drive/requests/*", (route) => {
|
||||
statusHandled = (async () => {
|
||||
const response = await route.fetch();
|
||||
statusSent.release();
|
||||
await statusRelease.promise;
|
||||
await safeFulfill(route, response);
|
||||
})();
|
||||
return statusHandled;
|
||||
});
|
||||
await button(page, "Check request result").click();
|
||||
await statusSent.promise;
|
||||
await otherFile(page);
|
||||
await editor(page, "Destination draft during old status");
|
||||
statusRelease.release();
|
||||
await statusHandled;
|
||||
assertEquals(
|
||||
await draft(page).inputValue(),
|
||||
"Destination draft during old status",
|
||||
);
|
||||
assert(
|
||||
(await preview(page).textContent())!.includes(
|
||||
"Destination draft never rebased",
|
||||
),
|
||||
);
|
||||
await button(page, "Save").click();
|
||||
await page.getByText("Published — DB commit confirmed", { exact: false })
|
||||
.last().waitFor();
|
||||
const pinnedWrites = (await logs(origin)).filter((x) => x.path === "/mutate");
|
||||
assertEquals(pinnedWrites.map((x) => [x.nodeId, x.expectedRevision]), [
|
||||
["3", "1"],
|
||||
["4", "1"],
|
||||
["3", "2"],
|
||||
["4", "2"],
|
||||
]);
|
||||
} finally {
|
||||
release.release();
|
||||
await handled;
|
||||
await context.close();
|
||||
}
|
||||
}));
|
||||
|
||||
Deno.test("production Drive rename move delete recreate preserve stable identity and missing old URL", () =>
|
||||
harness(async (origin, browser) => {
|
||||
const page = await browser.newPage();
|
||||
page.on("dialog", (dialog) => dialog.accept());
|
||||
try {
|
||||
await page.goto(`${origin}/w/home-owner/drive/3`);
|
||||
await ready(page);
|
||||
await button(page, "Rename / move").click();
|
||||
await page.getByRole("textbox", { name: "Name", exact: true }).fill(
|
||||
"renamed.md",
|
||||
);
|
||||
await page.getByRole("textbox", { name: "Destination folder node ID" })
|
||||
.fill("2");
|
||||
await button(page, "Apply rename / move").click();
|
||||
await page.getByRole("heading", { name: "renamed.md", exact: true })
|
||||
.waitFor();
|
||||
assertEquals(new URL(page.url()).pathname, "/w/home-owner/drive/3");
|
||||
await page.getByRole("navigation", { name: "Drive folders" }).getByRole(
|
||||
"link",
|
||||
{
|
||||
name: "資料",
|
||||
},
|
||||
).click();
|
||||
await ready(page);
|
||||
await page.getByRole("link", { name: "renamed.md", exact: true }).click();
|
||||
await ready(page);
|
||||
await button(page, "Delete").click();
|
||||
await page.getByRole("alert").filter({ hasText: /not[_ ]found/i })
|
||||
.waitFor();
|
||||
assertEquals(new URL(page.url()).pathname, "/w/home-owner/drive/3");
|
||||
await rootFolder(page);
|
||||
await page.getByRole("link", { name: "資料", exact: true }).click();
|
||||
await ready(page);
|
||||
await button(page, "New Markdown").click();
|
||||
await page.getByRole("textbox", { name: "Name", exact: true }).fill(
|
||||
"renamed.md",
|
||||
);
|
||||
await page.getByRole("textbox", { name: "Markdown", exact: true }).fill(
|
||||
"Recreated identity",
|
||||
);
|
||||
await button(page, "Create").click();
|
||||
await page.getByRole("link", { name: "renamed.md", exact: true })
|
||||
.waitFor();
|
||||
const href = await page.getByRole("link", {
|
||||
name: "renamed.md",
|
||||
exact: true,
|
||||
}).getAttribute(
|
||||
"href",
|
||||
);
|
||||
assert(href !== "/w/home-owner/drive/3");
|
||||
await page.goto(`${origin}/w/home-owner/drive/3`);
|
||||
await ready(page);
|
||||
await page.getByRole("alert").filter({ hasText: /not[_ ]found/i })
|
||||
.waitFor();
|
||||
} finally {
|
||||
await page.close();
|
||||
}
|
||||
}));
|
||||
|
||||
Deno.test("production Drive upload response loss and navigation cancellation query receipts without resend", () =>
|
||||
harness(async (origin, browser) => {
|
||||
const context = await browser.newContext();
|
||||
const page = await context.newPage();
|
||||
try {
|
||||
await page.goto(`${origin}/w/home-owner/drive`);
|
||||
await ready(page);
|
||||
await page.route("**/drive/upload?**", async (route) => {
|
||||
await route.fetch();
|
||||
await route.abort("failed");
|
||||
});
|
||||
await button(page, "Upload").click();
|
||||
await page.getByLabel("File (maximum 16 MiB)").setInputFiles({
|
||||
name: "lost-response.txt",
|
||||
mimeType: "text/plain",
|
||||
buffer: Buffer.from("Published despite response loss"),
|
||||
});
|
||||
await button(page, "Upload file").click();
|
||||
await button(page, "Check request result").waitFor();
|
||||
assertEquals(
|
||||
(await logs(origin)).filter((x) => x.path === "/upload").length,
|
||||
1,
|
||||
);
|
||||
await button(page, "Check request result").click();
|
||||
await page.getByText("Published — DB commit confirmed", { exact: false })
|
||||
.last().waitFor();
|
||||
await page.unroute("**/drive/upload?**");
|
||||
await button(page, "Refresh").click();
|
||||
await ready(page);
|
||||
await page.getByRole("link", { name: "lost-response.txt" }).waitFor();
|
||||
const transmitted = gate(), release = gate();
|
||||
let handled = Promise.resolve();
|
||||
await page.route("**/drive/upload?**", (route) => {
|
||||
handled = (async () => {
|
||||
const response = await route.fetch();
|
||||
transmitted.release();
|
||||
await release.promise;
|
||||
await safeFulfill(route, response);
|
||||
})();
|
||||
return handled;
|
||||
});
|
||||
await button(page, "Upload").click();
|
||||
await page.getByLabel("File (maximum 16 MiB)").setInputFiles({
|
||||
name: "canceled-response.txt",
|
||||
mimeType: "text/plain",
|
||||
buffer: Buffer.from("Transmitted before navigation abort"),
|
||||
});
|
||||
await button(page, "Upload file").click();
|
||||
await transmitted.promise;
|
||||
await button(page, "Cancel transfer / check outcome").click();
|
||||
await button(page, "Check request result").waitFor();
|
||||
await page.getByRole("link", { name: "資料", exact: true }).click();
|
||||
await page.getByRole("heading", { name: "資料", exact: true }).waitFor();
|
||||
await ready(page);
|
||||
release.release();
|
||||
await handled;
|
||||
await rootFolder(page);
|
||||
await button(page, "Check request result").waitFor();
|
||||
assertEquals(
|
||||
(await logs(origin)).filter((x) => x.path === "/upload").length,
|
||||
2,
|
||||
);
|
||||
await button(page, "Check request result").click();
|
||||
await page.getByText("Published — DB commit confirmed", { exact: false })
|
||||
.last().waitFor();
|
||||
assertEquals(
|
||||
(await logs(origin)).filter((x) => x.path === "/upload").length,
|
||||
2,
|
||||
);
|
||||
} catch (error) {
|
||||
console.log("UPLOAD FAILURE UI", await page.locator("main").innerText());
|
||||
console.log("UPLOAD FAILURE REQUESTS", await logs(origin));
|
||||
await page.screenshot({
|
||||
path: join(root, "web/workspace/.svelte-kit/t724/upload-failure.png"),
|
||||
fullPage: true,
|
||||
});
|
||||
throw error;
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
}));
|
||||
|
||||
Deno.test("production Drive workspace switch fences a delayed previous workspace read", () =>
|
||||
harness(async (origin, browser) => {
|
||||
const context = await browser.newContext();
|
||||
const page = await context.newPage();
|
||||
const sent = gate(), release = gate();
|
||||
let handled = Promise.resolve();
|
||||
await page.route("**/home-owner/drive/read-text?**", (route) => {
|
||||
handled = (async () => {
|
||||
const response = await route.fetch();
|
||||
sent.release();
|
||||
await release.promise;
|
||||
await safeFulfill(route, response);
|
||||
})();
|
||||
return handled;
|
||||
});
|
||||
try {
|
||||
await page.goto(`${origin}/w/home-owner/drive/3`);
|
||||
await sent.promise;
|
||||
await page.getByRole("button", {
|
||||
name: "Workspace Home Review",
|
||||
exact: true,
|
||||
}).first()
|
||||
.click();
|
||||
await page.getByRole("menuitem", {
|
||||
name: "Shared Workspace",
|
||||
exact: true,
|
||||
}).click();
|
||||
await page.waitForURL("**/w/home-member");
|
||||
await page.locator("aside").getByRole("link", {
|
||||
name: "Drive",
|
||||
exact: true,
|
||||
}).click();
|
||||
await ready(page);
|
||||
await page.getByRole("link", { name: "資料", exact: true }).click();
|
||||
await ready(page);
|
||||
await page.getByRole("link", { name: "notes.txt", exact: true }).click();
|
||||
await ready(page);
|
||||
await editor(page, "Member-only current draft");
|
||||
release.release();
|
||||
await handled;
|
||||
assertEquals(new URL(page.url()).pathname, "/w/home-member/drive/4");
|
||||
assertEquals(await draft(page).inputValue(), "Member-only current draft");
|
||||
assert((await preview(page).textContent())!.includes("Second document"));
|
||||
assertEquals(
|
||||
(await logs(origin)).filter((x) => x.path === "/mutate").length,
|
||||
0,
|
||||
);
|
||||
} finally {
|
||||
release.release();
|
||||
await handled;
|
||||
await context.close();
|
||||
}
|
||||
}));
|
||||
|
||||
Deno.test("production Drive denial empty pages truncation unsafe content and image failure remain distinct", () =>
|
||||
harness(async (origin, browser) => {
|
||||
const page = await browser.newPage();
|
||||
const external: string[] = [];
|
||||
page.on("request", (request) => {
|
||||
if (new URL(request.url()).hostname === "example.invalid") {
|
||||
external.push(request.url());
|
||||
}
|
||||
});
|
||||
try {
|
||||
await page.goto(`${origin}/w/home-member/drive/3`);
|
||||
await editor(page, "Denied member draft");
|
||||
await button(page, "Save").click();
|
||||
await page.getByText(
|
||||
"Read only / access denied by Backend. Editing and upload are unavailable.",
|
||||
).waitFor();
|
||||
assertEquals(await draft(page).inputValue(), "Denied member draft");
|
||||
assertEquals(await button(page, "Save").count(), 0);
|
||||
assertEquals(await draft(page).getAttribute("readonly"), "");
|
||||
for (
|
||||
const [workspace, text] of [["drive-denied", "denied"], [
|
||||
"drive-offline",
|
||||
"unavailable",
|
||||
]]
|
||||
) {
|
||||
await page.goto(`${origin}/w/${workspace}/drive`);
|
||||
await ready(page);
|
||||
await page.getByRole("alert").filter({ hasText: new RegExp(text, "i") })
|
||||
.waitFor();
|
||||
assertEquals(
|
||||
await page.getByText("This folder is empty.", { exact: true })
|
||||
.count(),
|
||||
0,
|
||||
);
|
||||
}
|
||||
await page.goto(`${origin}/w/drive-empty/drive`);
|
||||
await ready(page);
|
||||
await page.getByText("This folder is empty.", { exact: true }).waitFor();
|
||||
await page.goto(`${origin}/w/drive-paged/drive`);
|
||||
await ready(page);
|
||||
const firstCount = await page.locator(".drive-table tbody tr").count();
|
||||
await button(page, "Load more").click();
|
||||
await ready(page);
|
||||
assert(
|
||||
(await page.locator(".drive-table tbody tr").count()) > firstCount,
|
||||
);
|
||||
await page.goto(`${origin}/w/home-owner/drive/9`);
|
||||
await ready(page);
|
||||
await page.getByText("Preview truncated at 64 KiB.", { exact: false })
|
||||
.waitFor();
|
||||
assertEquals(await button(page, "Edit text").count(), 0);
|
||||
await page.goto(`${origin}/w/home-owner/drive/6`);
|
||||
await ready(page);
|
||||
await page.getByText("Empty file.", { exact: true }).waitFor();
|
||||
await page.goto(`${origin}/w/home-owner/drive/3`);
|
||||
await ready(page);
|
||||
assertEquals(
|
||||
await page.locator('main a[href^="javascript:"]').count(),
|
||||
0,
|
||||
);
|
||||
assertEquals(await page.locator("main img[onerror]").count(), 0);
|
||||
assertEquals(external, []);
|
||||
const local = page.getByRole("link", {
|
||||
name: "Local folder",
|
||||
exact: true,
|
||||
});
|
||||
assertEquals(await local.getAttribute("href"), "/w/home-owner/drive/2");
|
||||
await local.click();
|
||||
await page.waitForURL("**/w/home-owner/drive/2");
|
||||
await page.getByRole("heading", { name: "資料", exact: true }).waitFor();
|
||||
await ready(page);
|
||||
assertEquals(new URL(page.url()).pathname, "/w/home-owner/drive/2");
|
||||
await page.goto(`${origin}/w/home-owner/drive/8`);
|
||||
await ready(page);
|
||||
await page.getByText("This file type is download-only.", { exact: false })
|
||||
.waitFor();
|
||||
assertEquals(
|
||||
await page.locator("main svg, main iframe, main object").count(),
|
||||
0,
|
||||
);
|
||||
await page.goto(`${origin}/w/home-owner/drive/5`);
|
||||
await ready(page);
|
||||
await page.waitForFunction(() => {
|
||||
const image = document.querySelector("main .drive-image");
|
||||
return image instanceof HTMLImageElement && image.complete && image.naturalWidth === 1200 &&
|
||||
image.naturalHeight === 800;
|
||||
});
|
||||
assertEquals(
|
||||
await page.getByRole("img", { name: "landscape.png", exact: true }).evaluate((image) =>
|
||||
image instanceof HTMLImageElement ? [image.naturalWidth, image.naturalHeight] : []
|
||||
),
|
||||
[1200, 800],
|
||||
);
|
||||
await page.goto(`${origin}/w/home-owner/drive/7`);
|
||||
await ready(page);
|
||||
await page.getByRole("alert").filter({
|
||||
hasText: "Image could not be decoded",
|
||||
}).waitFor();
|
||||
await fault(origin, "home-owner", "image_error");
|
||||
await page.goto(`${origin}/w/home-owner/drive/5`);
|
||||
await ready(page);
|
||||
await page.getByRole("alert").filter({ hasText: /unavailable/i })
|
||||
.waitFor();
|
||||
} finally {
|
||||
await page.close();
|
||||
}
|
||||
}));
|
||||
|
||||
Deno.test("production Workers owner grants use the authoritative catalog while members have no grant controls", () =>
|
||||
harness(async (origin, browser) => {
|
||||
const page = await browser.newPage();
|
||||
const errors: string[] = [];
|
||||
page.on("pageerror", (error) => errors.push(String(error)));
|
||||
try {
|
||||
await page.goto(`${origin}/w/home-owner/workers`);
|
||||
await page.getByRole("combobox", { name: "Worker", exact: true })
|
||||
.selectOption({
|
||||
label: "Documentation Worker · fixture-runtime / fixture-worker-a",
|
||||
});
|
||||
await button(page, "Add Drive grant").waitFor();
|
||||
await button(page, "Add Drive grant").click();
|
||||
await button(page, "Create Drive grant").click();
|
||||
const grants = page.getByRole("region", { name: "Worker Drive grants" });
|
||||
await grants.getByText("Read only", { exact: true }).waitFor();
|
||||
await page.getByRole("button", { name: /^Revoke Drive grant/ }).click();
|
||||
await grants.getByText("No active Drive grants for this Worker.", {
|
||||
exact: true,
|
||||
}).waitFor();
|
||||
await button(page, "Add Drive grant").click();
|
||||
await page.getByRole("combobox", { name: "Access", exact: true })
|
||||
.selectOption("read_write");
|
||||
await button(page, "Create Drive grant").click();
|
||||
await grants.getByText("Read and write", { exact: true }).waitFor();
|
||||
await page.getByRole("button", { name: /^Revoke Drive grant/ }).click();
|
||||
await grants.getByText("No active Drive grants for this Worker.", {
|
||||
exact: true,
|
||||
}).waitFor();
|
||||
const mutations = (await logs(origin)).filter((event) =>
|
||||
event.path.startsWith("/grants") && event.method !== "GET"
|
||||
);
|
||||
assertEquals(
|
||||
mutations.map((event) => [event.workspace, event.method, event.path]),
|
||||
[
|
||||
["home-owner", "POST", "/grants"],
|
||||
["home-owner", "DELETE", "/grants/1"],
|
||||
["home-owner", "POST", "/grants"],
|
||||
["home-owner", "DELETE", "/grants/2"],
|
||||
],
|
||||
);
|
||||
await page.goto(`${origin}/w/home-member/workers`);
|
||||
await page.locator(".workers-table tbody tr").first().waitFor();
|
||||
assertEquals(
|
||||
await page.getByRole("region", { name: "Worker Drive grants" }).count(),
|
||||
0,
|
||||
);
|
||||
assertEquals(await button(page, "Add Drive grant").count(), 0);
|
||||
assertEquals(errors, []);
|
||||
} finally {
|
||||
await page.close();
|
||||
}
|
||||
}));
|
||||
|
||||
Deno.test("production Drive Markdown safe image placeholders preserve each image alt label", () =>
|
||||
harness(async (origin, browser) => {
|
||||
const page = await browser.newPage();
|
||||
try {
|
||||
await page.goto(`${origin}/w/home-owner/drive/3`);
|
||||
await ready(page);
|
||||
const labels = await page.locator(".drive-inline-image")
|
||||
.allTextContents();
|
||||
assert(
|
||||
labels.some((label) => label.includes("Image: External")),
|
||||
`External image alt label lost: ${JSON.stringify(labels)}`,
|
||||
);
|
||||
assert(
|
||||
labels.some((label) => label.includes("Image: Image")),
|
||||
`Local image alt label lost: ${JSON.stringify(labels)}`,
|
||||
);
|
||||
assertEquals(await preview(page).locator("img").count(), 0);
|
||||
} finally {
|
||||
await page.close();
|
||||
}
|
||||
}));
|
||||
@@ -0,0 +1,140 @@
|
||||
import { assert, assertEquals, assertRejects } from "@std/assert";
|
||||
import { createDriveFixture, fixtureRootId } from "../../../../tools/web-ux/drive-fixture/api.ts";
|
||||
import { createDriveClient, DriveRequestError } from "../../src/lib/workspace/drive/api.ts";
|
||||
function setup() {
|
||||
const fixture = createDriveFixture();
|
||||
const fetchFixture: typeof fetch = async (input, init) => {
|
||||
const request = new Request(new URL(String(input), "http://127.0.0.1"), init);
|
||||
const response = await fixture.handler(request);
|
||||
if (!response) throw new Error(`Unimplemented fixture ${request.url}`);
|
||||
return response;
|
||||
};
|
||||
return { fixture, client: createDriveClient(fetchFixture), fetchFixture };
|
||||
}
|
||||
const ref = (workspace_id: string, node_id: string) => ({ workspace_id, node_id });
|
||||
Deno.test("browser fixture preserves decimal identity, bounded reads, binary and page cursors", async () => {
|
||||
const { client, fetchFixture } = setup();
|
||||
const root = await client.root("drive-paged");
|
||||
assertEquals(root.entry.node_id, fixtureRootId);
|
||||
const first = await client.list("drive-paged", root.entry, { limit: 100 });
|
||||
const second = await client.list("drive-paged", root.entry, {
|
||||
limit: 100,
|
||||
after: first.next_after,
|
||||
});
|
||||
assertEquals(first.entries.length, 100);
|
||||
assertEquals(second.entries.length, 100);
|
||||
assert(
|
||||
!second.entries.some((entry) =>
|
||||
first.entries.some((other) => other.entry.node_id === entry.entry.node_id)
|
||||
),
|
||||
);
|
||||
const text = await client.readText("drive-paged", ref("drive-paged", "9"));
|
||||
assertEquals(text.truncated, true);
|
||||
assert(new TextEncoder().encode(text.text).length <= 65536);
|
||||
const image = await client.metadata("drive-paged", ref("drive-paged", "5"));
|
||||
const imageBytes = await client.image("drive-paged", image);
|
||||
assertEquals(imageBytes.size, image.size);
|
||||
assert(imageBytes.size < 256 * 1024);
|
||||
const png = new Uint8Array(await imageBytes.arrayBuffer());
|
||||
const dimensions = new DataView(png.buffer);
|
||||
assertEquals([dimensions.getUint32(16), dimensions.getUint32(20)], [1200, 800]);
|
||||
const escapedWire = await fetchFixture(
|
||||
"/api/w/drive-paged/drive/read-text?entry_workspace_id=drive-paged&id=11&max_bytes=65536",
|
||||
);
|
||||
const escapedLength = (await escapedWire.arrayBuffer()).byteLength;
|
||||
assert(escapedLength > 256 * 1024 && escapedLength < 512 * 1024);
|
||||
const escaped = await client.readText("drive-paged", ref("drive-paged", "11"));
|
||||
assertEquals(escaped.text, "\u0000".repeat(65536));
|
||||
assertEquals(escaped.truncated, false);
|
||||
});
|
||||
Deno.test("browser fixture two revisions conflict without committing the losing request", async () => {
|
||||
const { client } = setup();
|
||||
const entry = await client.metadata("home-owner", ref("home-owner", "3"));
|
||||
await client.mutate("home-owner", "tab-one", {
|
||||
operation: "update_text",
|
||||
id: entry.entry,
|
||||
expected_revision: entry.revision,
|
||||
text: "first actor",
|
||||
content_type: "text/markdown",
|
||||
});
|
||||
const failure = await assertRejects(
|
||||
() =>
|
||||
client.mutate("home-owner", "tab-two", {
|
||||
operation: "update_text",
|
||||
id: entry.entry,
|
||||
expected_revision: entry.revision,
|
||||
text: "second actor draft",
|
||||
content_type: "text/markdown",
|
||||
}),
|
||||
DriveRequestError,
|
||||
);
|
||||
assertEquals((failure as DriveRequestError).code, "conflict");
|
||||
assertEquals((await client.status("home-owner", "tab-one")).state, "committed");
|
||||
assertEquals((await client.status("home-owner", "tab-two")).state, "uncommitted");
|
||||
assertEquals((await client.readText("home-owner", entry.entry)).text, "first actor");
|
||||
});
|
||||
Deno.test("browser fixture upload unknown is reconciled by receipt and denied writes never commit", async () => {
|
||||
const { client, fetchFixture } = setup();
|
||||
await fetchFixture("/__fixture/fault", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ workspace: "home-owner", mode: "unknown" }),
|
||||
});
|
||||
const root = await client.root("home-owner");
|
||||
const failure = await assertRejects(
|
||||
() =>
|
||||
client.upload("home-owner", "upload-one", new Blob(["upload bytes"]), {
|
||||
operation: "create",
|
||||
parent: root.entry,
|
||||
name: "upload.txt",
|
||||
content_type: "text/plain",
|
||||
}),
|
||||
DriveRequestError,
|
||||
);
|
||||
assertEquals((failure as DriveRequestError).code, "outcome_unknown");
|
||||
assertEquals((await client.status("home-owner", "upload-one")).state, "committed");
|
||||
const memberRoot = await client.root("home-member");
|
||||
const denied = await assertRejects(
|
||||
() =>
|
||||
client.mutate("home-member", "member-one", {
|
||||
operation: "create_folder",
|
||||
parent: memberRoot.entry,
|
||||
name: "denied",
|
||||
}),
|
||||
DriveRequestError,
|
||||
);
|
||||
assertEquals((denied as DriveRequestError).code, "denied");
|
||||
assertEquals((await client.status("home-member", "member-one")).state, "uncommitted");
|
||||
});
|
||||
Deno.test("browser fixture rename and move retain identity while delete and recreate do not reuse links", async () => {
|
||||
const { client } = setup();
|
||||
let entry = await client.metadata("home-owner", ref("home-owner", "3"));
|
||||
const latest = entry.latest_url;
|
||||
const moved = await client.mutate("home-owner", "move", {
|
||||
operation: "relocate",
|
||||
id: entry.entry,
|
||||
expected_revision: entry.revision,
|
||||
parent: ref("home-owner", "2"),
|
||||
name: "renamed.md",
|
||||
});
|
||||
entry = moved.entry!;
|
||||
assertEquals(entry.latest_url, latest);
|
||||
await client.mutate("home-owner", "delete", {
|
||||
operation: "delete",
|
||||
id: entry.entry,
|
||||
expected_revision: entry.revision,
|
||||
});
|
||||
const replacement = await client.mutate("home-owner", "recreate", {
|
||||
operation: "create_text",
|
||||
parent: ref("home-owner", "2"),
|
||||
name: "renamed.md",
|
||||
text: "new entity",
|
||||
content_type: "text/markdown",
|
||||
});
|
||||
assert(replacement.entry!.entry.node_id !== entry.entry.node_id);
|
||||
const missing = await assertRejects(
|
||||
() => client.metadata("home-owner", entry.entry),
|
||||
DriveRequestError,
|
||||
);
|
||||
assertEquals((missing as DriveRequestError).code, "not_found");
|
||||
});
|
||||
@@ -0,0 +1,212 @@
|
||||
/// <reference lib="dom" />
|
||||
// Responsive DOM geometry audit over the frozen production shell and fake API.
|
||||
// Read-only observation: no UI/CSS mutation or actual Backend connection.
|
||||
import { assert, assertEquals } from "@std/assert";
|
||||
import { dirname, fromFileUrl, join } from "@std/path";
|
||||
import { chromium } from "playwright";
|
||||
const root = join(dirname(fromFileUrl(import.meta.url)), "../../../..");
|
||||
const auditId = Deno.args[1] ?? "verified";
|
||||
Deno.test("production Drive and Workers constrain page overflow and expose keyboard grant controls at every viewport", async () => {
|
||||
const listener = Deno.listen({ hostname: "127.0.0.1", port: 0 });
|
||||
const port = (listener.addr as Deno.NetAddr).port;
|
||||
listener.close();
|
||||
const origin = `http://127.0.0.1:${port}`;
|
||||
const server = new Deno.Command(Deno.execPath(), {
|
||||
args: [
|
||||
"run",
|
||||
"--allow-read",
|
||||
"--allow-net",
|
||||
"--allow-run",
|
||||
"--allow-env",
|
||||
join(root, "tools/web-ux/drive-fixture/server.ts"),
|
||||
String(port),
|
||||
join(root, Deno.args[0] ?? "web/workspace/build"),
|
||||
],
|
||||
stdout: "null",
|
||||
stderr: "inherit",
|
||||
}).spawn();
|
||||
const evidence: unknown[] = [];
|
||||
try {
|
||||
const deadline = Date.now() + 10000;
|
||||
while (true) {
|
||||
try {
|
||||
const response = await fetch(`${origin}/health`);
|
||||
await response.body?.cancel();
|
||||
if (response.ok) break;
|
||||
} catch { /* startup */ }
|
||||
if (Date.now() > deadline) throw new Error("fixture failed startup");
|
||||
await new Promise((resolve) => setTimeout(resolve, 30));
|
||||
}
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
try {
|
||||
for (const colorScheme of ["light", "dark"] as const) {
|
||||
for (const width of [1440, 768, 390, 320]) {
|
||||
const context = await browser.newContext({
|
||||
viewport: { width, height: 900 },
|
||||
colorScheme,
|
||||
reducedMotion: "reduce",
|
||||
});
|
||||
try {
|
||||
const page = await context.newPage();
|
||||
const pageErrors: string[] = [];
|
||||
page.on("pageerror", (e) => pageErrors.push(String(e)));
|
||||
for (
|
||||
const [workspace, surface] of [
|
||||
["home-owner", "workers"],
|
||||
["home-member", "workers"],
|
||||
["home-empty", "workers"],
|
||||
["workers-error", "workers"],
|
||||
["home-long", "workers"],
|
||||
["home-owner", "drive"],
|
||||
["home-member", "drive"],
|
||||
["home-empty", "drive"],
|
||||
["home-error", "drive"],
|
||||
["drive-paged", "drive"],
|
||||
["home-owner", "drive/10"],
|
||||
["home-owner", "drive/5"],
|
||||
]
|
||||
) {
|
||||
await page.goto(`${origin}/w/${workspace}/${surface}`);
|
||||
if (surface.startsWith("drive")) {
|
||||
await page.locator('[data-drive-ready="true"]').waitFor();
|
||||
} else if (workspace === "workers-error") {
|
||||
await page.locator(".workers-page .section-state.error")
|
||||
.waitFor();
|
||||
} else if (workspace === "home-empty") {
|
||||
await page.getByText("No Workers are visible.", { exact: true })
|
||||
.waitFor();
|
||||
} else {await page.locator(".workers-table tbody tr").first()
|
||||
.waitFor();}
|
||||
if (surface === "drive/5") {
|
||||
await page.waitForFunction(() => {
|
||||
const image = document.querySelector("main .drive-image");
|
||||
return image instanceof HTMLImageElement && image.complete &&
|
||||
image.naturalWidth === 1200 && image.naturalHeight === 800;
|
||||
});
|
||||
}
|
||||
if (workspace === "home-owner" && surface === "workers") {
|
||||
await page.getByRole("combobox", {
|
||||
name: "Worker",
|
||||
exact: true,
|
||||
}).selectOption({
|
||||
label: "Documentation Worker · fixture-runtime / fixture-worker-a",
|
||||
});
|
||||
await page.getByRole("button", {
|
||||
name: "Add Drive grant",
|
||||
exact: true,
|
||||
}).click();
|
||||
await page.getByRole("combobox", {
|
||||
name: "Access",
|
||||
exact: true,
|
||||
}).waitFor();
|
||||
await page.getByRole("button", {
|
||||
name: "Refresh grants",
|
||||
exact: true,
|
||||
}).press("Tab");
|
||||
const focus = await page.evaluate(() => ({
|
||||
tag: document.activeElement?.tagName,
|
||||
outline: document.activeElement
|
||||
? getComputedStyle(document.activeElement).outlineStyle
|
||||
: "none",
|
||||
}));
|
||||
assert(
|
||||
focus.tag === "BUTTON" && focus.outline !== "none",
|
||||
JSON.stringify(focus),
|
||||
);
|
||||
}
|
||||
if (workspace === "workers-error" && width === 320 && colorScheme === "dark") {
|
||||
await page.screenshot({
|
||||
path: join(root, `web/workspace/.svelte-kit/t724/workers-error-${auditId}.png`),
|
||||
fullPage: false,
|
||||
});
|
||||
}
|
||||
const geometry = await page.evaluate(() => {
|
||||
const main = document.querySelector("main")! as HTMLElement;
|
||||
const grants = main.querySelector(".drive-grants");
|
||||
const boundedTables = [
|
||||
...main.querySelectorAll(".table-wrap, .drive-table-region"),
|
||||
].map((el) => ({
|
||||
width: el.clientWidth,
|
||||
scrollWidth: el.scrollWidth,
|
||||
overflowX: getComputedStyle(el).overflowX,
|
||||
}));
|
||||
const raster = main.querySelector(".drive-image");
|
||||
const image = raster instanceof HTMLImageElement
|
||||
? {
|
||||
naturalWidth: raster.naturalWidth,
|
||||
naturalHeight: raster.naturalHeight,
|
||||
renderedWidth: raster.getBoundingClientRect().width,
|
||||
renderedHeight: raster.getBoundingClientRect().height,
|
||||
availableWidth: raster.parentElement!.getBoundingClientRect().width,
|
||||
heightLimit: innerHeight * 0.6,
|
||||
}
|
||||
: null;
|
||||
return {
|
||||
image,
|
||||
pageWidth: document.documentElement.scrollWidth,
|
||||
viewportWidth: innerWidth,
|
||||
mainWidth: main.clientWidth,
|
||||
mainScrollWidth: main.scrollWidth,
|
||||
documentScrollY: scrollY,
|
||||
mainScrollTop: main.scrollTop,
|
||||
mainTop: main.getBoundingClientRect().top,
|
||||
firstHeadingTop: main.querySelector("h1")?.getBoundingClientRect().top ?? null,
|
||||
grantsWidth: grants?.getBoundingClientRect().width ?? null,
|
||||
boundedTables,
|
||||
};
|
||||
});
|
||||
evidence.push({
|
||||
colorScheme,
|
||||
width,
|
||||
workspace,
|
||||
surface,
|
||||
...geometry,
|
||||
});
|
||||
assert(
|
||||
geometry.pageWidth <= width,
|
||||
JSON.stringify(evidence.at(-1)),
|
||||
);
|
||||
assert(
|
||||
geometry.mainScrollWidth <= geometry.mainWidth + 1,
|
||||
JSON.stringify(evidence.at(-1)),
|
||||
);
|
||||
if (surface === "drive/5") {
|
||||
const image = geometry.image;
|
||||
assert(image !== null);
|
||||
assertEquals([image.naturalWidth, image.naturalHeight], [1200, 800]);
|
||||
assert(image.renderedWidth > 100 && image.renderedHeight > 100);
|
||||
assert(image.renderedWidth <= image.availableWidth + 1);
|
||||
assert(image.renderedHeight <= image.heightLimit + 1);
|
||||
assert(Math.abs(image.renderedWidth / image.renderedHeight - 1.5) < 0.02);
|
||||
}
|
||||
if (workspace === "home-member" && surface === "workers") {
|
||||
assertEquals(
|
||||
await page.getByRole("region", {
|
||||
name: "Worker Drive grants",
|
||||
}).count(),
|
||||
0,
|
||||
);
|
||||
}
|
||||
}
|
||||
assertEquals(pageErrors, []);
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
} finally {
|
||||
await Deno.writeTextFile(
|
||||
join(root, `web/workspace/.svelte-kit/t724/responsive-${auditId}.json`),
|
||||
JSON.stringify(
|
||||
{ authority: "fixture, not actual Backend", evidence },
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
server.kill("SIGTERM");
|
||||
await server.status;
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
# Real Drive API/Web roundtrip (not the browser fixture)
|
||||
|
||||
This harness uses a fresh RAII temporary directory, real Server SQLite records, FeatureStorage Drive nodes and `object_store::local::LocalFileSystem` blobs. It creates a Workspace through T-717's actual repositoryless `/api/workspaces` endpoint. No repositories, user's data, live Server, environment-variable test configuration, Tools or WIP are involved.
|
||||
|
||||
The ordinary Rust test exercises loopback HTTP signed Worker ingress: explicit read-only/read-write grants, rejection of read-only writes, Worker-created bytes visible through Web/user API, and immediate grant revocation (even when the request also carries an owner's bearer token). Only the Runtime worker-spawn boundary uses the existing deterministic workdirless fixture runtime.
|
||||
|
||||
The explicit process test starts a loopback Axum Server and launches Deno against it. `roundtrip.ts` imports the production `src/lib/workspace/drive/api.ts` adapter, not a copy or mock. Its typed `fetchFn` resolves relative paths only to the bound loopback origin and adds fixture bearer authorization. Tokens travel over stdin, not command-line arguments or logs; Rust failure diagnostics defensively redact both fixture bearer tokens. Deno has network permission only for the exact loopback port, uses no npm dependencies, and runs with `--cached-only`. Each listener is abort-on-drop, child processes are kill-on-drop, and the child has a 60-second upper timeout, without sleeps or readiness polling.
|
||||
|
||||
Coverage: Worker file list/read, Japanese folder/Markdown names, create/read/edit/download, empty files, actual PNG upload/authenticated image/download and binary update, publication receipt lookup, paged list, two authenticated accounts racing a common revision (one commit/one typed conflict, no hidden retry), stable latest URL through rename and move, deleted URL and same-name recreation, and unauthorized latest URLs. The exact Workers-page `drive-grants/api.ts` adapter also lists persisted grants, creates both access levels, revokes them and re-reads durable state, and rejects member administration. Its global browser fetch is temporarily origin/auth-bound in this isolated child; network responses are never mocked. This tests real authenticated HTTP with test bearer credentials, not a Passkey/cookie browser login or a full Runtime process. UI dirty-draft retention, delayed response handling, visual review and browser cancellation belong to the **separate browser fixture**, not this API test.
|
||||
|
||||
## Registration
|
||||
|
||||
The inline `mod tests` block in `crates/workspace-server/src/server.rs` registers the module alongside the existing Drive API tests:
|
||||
|
||||
```rust
|
||||
mod drive_web_roundtrip_tests;
|
||||
```
|
||||
|
||||
No product routing modification is needed. The scoped production router is cached by the harness without starting unrelated Orchestrator hook tasks; production server ingress, authorization and API/storage handlers still run.
|
||||
|
||||
## Run
|
||||
|
||||
From the repository root:
|
||||
|
||||
```sh
|
||||
cargo test -p yoi-workspace-server --lib drive_web_roundtrip_tests
|
||||
cargo test -p yoi-workspace-server --lib drive_web_roundtrip_tests -- --ignored
|
||||
```
|
||||
|
||||
The second command requires Deno on PATH. Its ignored marker is an explicit process/E2E prerequisite, not an environment-variable gate or silent fallback. An absent Deno fails the explicit test. The ordinary real HTTP test does not require Deno.
|
||||
|
||||
The script is harness-only: it receives fresh credentials and Workspace identity from the parent over stdin; do not invoke it against a live Server. Type-check it without starting the Server:
|
||||
|
||||
```sh
|
||||
deno check --config web/workspace/test/drive-real-api/deno.json \
|
||||
web/workspace/test/drive-real-api/roundtrip.ts
|
||||
```
|
||||
|
||||
## Validation evidence
|
||||
|
||||
Validated against the production Server endpoints and Web adapter, not the browser mock routes:
|
||||
|
||||
- `cargo test -p yoi-workspace-server --lib drive_web_roundtrip_tests -- --test-threads=4`: **1 passed, 1 explicitly ignored** (the process prerequisite).
|
||||
- `cargo test -p yoi-workspace-server --lib drive_web_roundtrip_tests -- --ignored --test-threads=4`: **1 passed**; the actual Deno adapter process completed against real SQLite/LocalFileSystem HTTP.
|
||||
- `cargo test -p yoi-workspace-server -- --test-threads=4`: **693 library tests passed, 1 ignored; 11 binary tests passed; doc-tests passed**.
|
||||
|
||||
The repositoryless fixture was adjusted to retain the production runtime trust gate's exact normalized binding snapshot and to scope the reusable Runtime observation to the newly bootstrapped Workspace. Neither fix changes the API, Drive authorization policy, Tools, WIP, or browser fixtures.
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"nodeModulesDir": "none",
|
||||
"lock": false,
|
||||
"imports": {
|
||||
"#lib/": "../../src/lib/"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,448 @@
|
||||
// Invoked only by the Rust loopback harness. No mocked responses, live server,
|
||||
// browser fixtures, environment variables, or bearer credentials in argv/logs.
|
||||
import {
|
||||
createDriveClient,
|
||||
type DriveClient,
|
||||
driveDownloadUrl,
|
||||
type DriveEntry,
|
||||
type DriveMutation,
|
||||
DriveRequestError,
|
||||
readBoundedDriveBytes,
|
||||
} from "../../src/lib/workspace/drive/api.ts";
|
||||
import {
|
||||
createDriveGrant,
|
||||
DriveGrantError,
|
||||
listDriveGrants,
|
||||
revokeDriveGrant,
|
||||
} from "../../src/lib/workspace/drive-grants/api.ts";
|
||||
// Preserve the real network implementation before temporarily binding the grant
|
||||
// adapter's same-origin global fetch to this isolated child/loopback authority.
|
||||
const networkFetch = globalThis.fetch;
|
||||
|
||||
interface Fixture {
|
||||
origin: string;
|
||||
workspace_id: string;
|
||||
owner_token: string;
|
||||
second_token: string;
|
||||
worker_entry: DriveEntry;
|
||||
}
|
||||
function assert(value: unknown, message: string): asserts value {
|
||||
if (!value) throw new Error(message);
|
||||
}
|
||||
function equal(actual: unknown, expected: unknown, message: string): void {
|
||||
assert(JSON.stringify(actual) === JSON.stringify(expected), message);
|
||||
}
|
||||
async function rejected(
|
||||
call: () => Promise<unknown>,
|
||||
code: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await call();
|
||||
} catch (error) {
|
||||
assert(
|
||||
error instanceof DriveRequestError,
|
||||
"rejection must preserve typed Drive API error",
|
||||
);
|
||||
equal(error.code, code, `expected typed ${code} rejection`);
|
||||
equal(
|
||||
error.classification,
|
||||
"not_committed",
|
||||
"rejected request must not look successful/unknown",
|
||||
);
|
||||
return;
|
||||
}
|
||||
throw new Error(`expected ${code} rejection`);
|
||||
}
|
||||
function authenticatedFetch(origin: string, token: string): typeof fetch {
|
||||
const endpoint = new URL(origin);
|
||||
assert(
|
||||
endpoint.protocol === "http:" && endpoint.hostname === "127.0.0.1",
|
||||
"fixture must be loopback only",
|
||||
);
|
||||
return (input, init) => {
|
||||
const path = input instanceof Request ? input.url : String(input);
|
||||
const url = new URL(path, endpoint);
|
||||
assert(
|
||||
url.origin === endpoint.origin && url.pathname.startsWith("/api/"),
|
||||
"never send fixture credentials off-origin",
|
||||
);
|
||||
const headers = new Headers(
|
||||
input instanceof Request ? input.headers : undefined,
|
||||
);
|
||||
new Headers(init && "headers" in init ? init.headers : undefined).forEach((
|
||||
value,
|
||||
key,
|
||||
) => headers.set(key, value));
|
||||
headers.set("authorization", `Bearer ${token}`);
|
||||
return networkFetch(url, { ...init, headers, redirect: "error" });
|
||||
};
|
||||
}
|
||||
async function saved(
|
||||
client: DriveClient,
|
||||
ws: string,
|
||||
request: string,
|
||||
mutation: DriveMutation,
|
||||
): Promise<DriveEntry> {
|
||||
const result = await client.mutate(ws, request, mutation);
|
||||
assert(result.entry !== null, "creation/update must return live entry");
|
||||
return result.entry;
|
||||
}
|
||||
async function download(
|
||||
fetchFn: typeof fetch,
|
||||
ws: string,
|
||||
entry: DriveEntry,
|
||||
): Promise<Uint8Array> {
|
||||
const response = await fetchFn(
|
||||
driveDownloadUrl(ws, entry.entry, entry.revision),
|
||||
);
|
||||
assert(response.status === 200, "download must succeed at exact revision");
|
||||
equal(
|
||||
response.headers.get("x-content-type-options"),
|
||||
"nosniff",
|
||||
"downloads must not sniff active content",
|
||||
);
|
||||
assert(
|
||||
response.headers.get("content-disposition")?.startsWith("attachment;"),
|
||||
"download must be attachment",
|
||||
);
|
||||
return readBoundedDriveBytes(response, 16 * 1024 * 1024);
|
||||
}
|
||||
|
||||
// Credentials arrive exclusively over the child pipe from a fresh temporary DB.
|
||||
const fixture: Fixture = JSON.parse(
|
||||
await new Response(Deno.stdin.readable).text(),
|
||||
);
|
||||
const ws = fixture.workspace_id;
|
||||
const fetchOwner = authenticatedFetch(fixture.origin, fixture.owner_token);
|
||||
const owner = createDriveClient(fetchOwner);
|
||||
const second = createDriveClient(
|
||||
authenticatedFetch(fixture.origin, fixture.second_token),
|
||||
);
|
||||
const root = await owner.root(ws);
|
||||
const initial = await owner.list(ws, root.entry);
|
||||
equal(initial.entries.map((entry) => entry.entry.node_id), [
|
||||
fixture.worker_entry.entry.node_id,
|
||||
], "Worker-created file must appear in the real Web adapter list");
|
||||
equal(
|
||||
(await owner.readText(ws, fixture.worker_entry.entry)).text,
|
||||
"# Worker artifact\n",
|
||||
"Web adapter must read Worker bytes from LocalFileSystem",
|
||||
);
|
||||
// Exercise the exact grant adapter used by the Workers page, not a hand-written
|
||||
// HTTP facsimile. Only URL resolution/auth are bound; every response is real DB/API.
|
||||
try {
|
||||
globalThis.fetch = fetchOwner;
|
||||
const grants = await listDriveGrants(ws);
|
||||
assert(
|
||||
grants.length === 2,
|
||||
"real Worker grants must be listed by the Web adapter",
|
||||
);
|
||||
const worker = grants[0];
|
||||
for (const access of ["read_only", "read_write"] as const) {
|
||||
const created = await createDriveGrant(ws, {
|
||||
runtime_id: worker.runtime_id,
|
||||
worker_id: worker.worker_id,
|
||||
access,
|
||||
});
|
||||
equal(
|
||||
created.access,
|
||||
access,
|
||||
"grant access must not be silently downgraded",
|
||||
);
|
||||
assert(
|
||||
(await listDriveGrants(ws)).some((grant) =>
|
||||
grant.grant_id === created.grant_id && !grant.revoked
|
||||
),
|
||||
"grant must be durably visible",
|
||||
);
|
||||
const revoked = await revokeDriveGrant(ws, created);
|
||||
assert(
|
||||
revoked.revoked &&
|
||||
(await listDriveGrants(ws)).some((grant) =>
|
||||
grant.grant_id === created.grant_id && grant.revoked
|
||||
),
|
||||
"revoke must be durably visible",
|
||||
);
|
||||
}
|
||||
globalThis.fetch = authenticatedFetch(fixture.origin, fixture.second_token);
|
||||
try {
|
||||
await listDriveGrants(ws);
|
||||
throw new Error("member must not manage grants");
|
||||
} catch (error) {
|
||||
assert(
|
||||
error instanceof DriveGrantError && error.outcome === "not_committed",
|
||||
"member grant read must be a real authorized rejection",
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
globalThis.fetch = networkFetch;
|
||||
}
|
||||
|
||||
const folder = await saved(owner, ws, "web-folder", {
|
||||
operation: "create_folder",
|
||||
parent: root.entry,
|
||||
name: "共有資料",
|
||||
});
|
||||
equal(
|
||||
(await owner.list(ws, folder.entry)).entries,
|
||||
[],
|
||||
"new folder must be empty",
|
||||
);
|
||||
const markdown = await saved(owner, ws, "web-markdown", {
|
||||
operation: "create_text",
|
||||
parent: folder.entry,
|
||||
name: "日本語.md",
|
||||
text: "# 初版\n",
|
||||
content_type: "text/markdown",
|
||||
});
|
||||
equal(
|
||||
(await owner.readText(ws, markdown.entry)).text,
|
||||
"# 初版\n",
|
||||
"real Markdown read must match created bytes",
|
||||
);
|
||||
const updated = await saved(owner, ws, "web-edit", {
|
||||
operation: "update_text",
|
||||
id: markdown.entry,
|
||||
expected_revision: markdown.revision,
|
||||
text: "# 更新\n",
|
||||
content_type: "text/markdown",
|
||||
});
|
||||
equal(
|
||||
new TextDecoder().decode(await download(fetchOwner, ws, updated)),
|
||||
"# 更新\n",
|
||||
"updated Markdown download must use real blob bytes",
|
||||
);
|
||||
const empty = await saved(owner, ws, "web-empty", {
|
||||
operation: "create_text",
|
||||
parent: folder.entry,
|
||||
name: "empty.txt",
|
||||
text: "",
|
||||
content_type: "text/plain",
|
||||
});
|
||||
equal(
|
||||
(await owner.readText(ws, empty.entry)).text,
|
||||
"",
|
||||
"empty file is not a missing/error response",
|
||||
);
|
||||
equal(
|
||||
(await download(fetchOwner, ws, empty)).length,
|
||||
0,
|
||||
"empty file download must succeed with zero bytes",
|
||||
);
|
||||
// A real 1x1 PNG, not a claimed media type over arbitrary text.
|
||||
const png = Uint8Array.from(
|
||||
atob(
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/l9sAAAAASUVORK5CYII=",
|
||||
),
|
||||
(character) => character.charCodeAt(0),
|
||||
);
|
||||
const uploaded = await owner.upload(
|
||||
ws,
|
||||
"web-png",
|
||||
new Blob([png], { type: "image/png" }),
|
||||
{
|
||||
operation: "create",
|
||||
parent: folder.entry,
|
||||
name: "画像.png",
|
||||
content_type: "image/png",
|
||||
},
|
||||
);
|
||||
assert(uploaded.entry !== null, "PNG upload must publish metadata");
|
||||
equal(
|
||||
uploaded.entry.size,
|
||||
png.length,
|
||||
"binary byte count must survive actual upload",
|
||||
);
|
||||
equal(
|
||||
[
|
||||
...new Uint8Array(
|
||||
await (await owner.image(ws, uploaded.entry)).arrayBuffer(),
|
||||
),
|
||||
],
|
||||
[...png],
|
||||
"authenticated image bytes must survive upload, storage and Web adapter download",
|
||||
);
|
||||
equal(
|
||||
[...(await download(fetchOwner, ws, uploaded.entry))],
|
||||
[...png],
|
||||
"PNG download must match original bytes",
|
||||
);
|
||||
const binaryUpdated = await owner.upload(
|
||||
ws,
|
||||
"web-empty-to-png",
|
||||
new Blob([png]),
|
||||
{
|
||||
operation: "update",
|
||||
id: empty.entry,
|
||||
expected_revision: empty.revision,
|
||||
content_type: "image/png",
|
||||
},
|
||||
);
|
||||
assert(binaryUpdated.entry !== null, "binary update must publish metadata");
|
||||
equal(
|
||||
[...await download(fetchOwner, ws, binaryUpdated.entry)],
|
||||
[...png],
|
||||
"binary update must replace zero-length file bytes",
|
||||
);
|
||||
const status = await owner.status(ws, "web-png");
|
||||
equal(
|
||||
status.state,
|
||||
"committed",
|
||||
"adapter must distinguish publication from transmission",
|
||||
);
|
||||
equal(
|
||||
status.response,
|
||||
uploaded,
|
||||
"status must refer to exact real upload receipt",
|
||||
);
|
||||
const firstPage = await owner.list(ws, folder.entry, { limit: 1 });
|
||||
assert(
|
||||
firstPage.next_after !== null && firstPage.entries.length === 1,
|
||||
"real list must expose a service-issued page cursor",
|
||||
);
|
||||
const following = await owner.list(ws, folder.entry, {
|
||||
after: firstPage.next_after,
|
||||
});
|
||||
equal(
|
||||
new Set(
|
||||
[...firstPage.entries, ...following.entries].map((entry) =>
|
||||
entry.entry.node_id
|
||||
),
|
||||
).size,
|
||||
3,
|
||||
"paged traversal must contain each file once",
|
||||
);
|
||||
|
||||
// Two authenticated accounts read the same revision, then race real HTTP writes.
|
||||
const secondRead = await second.readText(ws, updated.entry);
|
||||
equal(
|
||||
secondRead.entry.revision,
|
||||
updated.revision,
|
||||
"second actor must observe same revision",
|
||||
);
|
||||
const drafts = ["# actor A draft\n", "# actor B draft\n"];
|
||||
const race = await Promise.allSettled(
|
||||
[owner, second].map((client, index) =>
|
||||
saved(client, ws, `web-race-${index}`, {
|
||||
operation: "update_text",
|
||||
id: updated.entry,
|
||||
expected_revision: updated.revision,
|
||||
text: drafts[index],
|
||||
content_type: "text/markdown",
|
||||
})
|
||||
),
|
||||
);
|
||||
equal(
|
||||
race.filter((result) => result.status === "fulfilled").length,
|
||||
1,
|
||||
"same-revision two-actor CAS must have exactly one winner",
|
||||
);
|
||||
const loser = race.find((result) => result.status === "rejected");
|
||||
assert(
|
||||
loser?.status === "rejected" && loser.reason instanceof DriveRequestError,
|
||||
"loser must expose typed conflict, not silently retry",
|
||||
);
|
||||
equal(loser.reason.code, "conflict", "CAS loser must remain a conflict");
|
||||
const winnerIndex = race.findIndex((result) => result.status === "fulfilled");
|
||||
const latest = await owner.readText(ws, updated.entry);
|
||||
equal(
|
||||
latest.text,
|
||||
drafts[winnerIndex],
|
||||
"durable content must be the winning actor's draft",
|
||||
);
|
||||
// This API boundary proves no automatic retry/overwrite. UI dirty-draft
|
||||
// retention is deliberately verified by the separate browser fixture.
|
||||
|
||||
// Latest references bind node IDs, not names/parents, and never become public.
|
||||
const stableUrl = markdown.latest_url;
|
||||
const renamed = await saved(owner, ws, "web-rename", {
|
||||
operation: "relocate",
|
||||
id: latest.entry.entry,
|
||||
expected_revision: latest.entry.revision,
|
||||
parent: folder.entry,
|
||||
name: "改名.md",
|
||||
});
|
||||
const moved = await saved(owner, ws, "web-move", {
|
||||
operation: "relocate",
|
||||
id: renamed.entry,
|
||||
expected_revision: renamed.revision,
|
||||
parent: root.entry,
|
||||
name: renamed.name,
|
||||
});
|
||||
equal(moved.entry, markdown.entry, "rename/move must preserve node identity");
|
||||
equal(moved.latest_url, stableUrl, "rename/move must preserve latest URL");
|
||||
equal(
|
||||
(await owner.metadata(ws, markdown.entry)).parent,
|
||||
root.entry,
|
||||
"original ref must resolve new parent",
|
||||
);
|
||||
equal(
|
||||
await (await fetchOwner(stableUrl)).text(),
|
||||
drafts[winnerIndex],
|
||||
"original latest URL must resolve current bytes",
|
||||
);
|
||||
const unauthenticated = await fetch(new URL(stableUrl, fixture.origin), {
|
||||
redirect: "error",
|
||||
});
|
||||
assert(
|
||||
unauthenticated.status === 403 || unauthenticated.status === 401,
|
||||
"possessing a latest URL must not authorize reads",
|
||||
);
|
||||
await unauthenticated.body?.cancel();
|
||||
await rejected(
|
||||
() =>
|
||||
owner.metadata(ws, { workspace_id: ws, node_id: "9223372036854775807" }),
|
||||
"not_found",
|
||||
);
|
||||
const deleted = await owner.mutate(ws, "web-delete", {
|
||||
operation: "delete",
|
||||
id: moved.entry,
|
||||
expected_revision: moved.revision,
|
||||
});
|
||||
equal(deleted.entry, null, "delete must remove live metadata");
|
||||
await rejected(() => owner.readText(ws, moved.entry), "not_found");
|
||||
const oldUrl = await fetchOwner(stableUrl);
|
||||
equal(
|
||||
oldUrl.status,
|
||||
404,
|
||||
"deleted stable URL must not resolve a recreated same-name file",
|
||||
);
|
||||
await oldUrl.body?.cancel();
|
||||
const recreated = await saved(owner, ws, "web-recreate", {
|
||||
operation: "create_text",
|
||||
parent: root.entry,
|
||||
name: moved.name,
|
||||
text: "recreated",
|
||||
content_type: "text/markdown",
|
||||
});
|
||||
assert(
|
||||
recreated.entry.node_id !== moved.entry.node_id &&
|
||||
recreated.latest_url !== stableUrl,
|
||||
"same-name recreate must allocate a new ID and latest URL",
|
||||
);
|
||||
|
||||
// The decoded text bound is independent of JSON escape expansion on the wire.
|
||||
const escapedText = "\u0000".repeat(64 * 1024);
|
||||
const escaped = await saved(owner, ws, "web-escaped-text", {
|
||||
operation: "create_text",
|
||||
parent: root.entry,
|
||||
name: "escaped.txt",
|
||||
text: escapedText,
|
||||
content_type: "text/plain",
|
||||
});
|
||||
assert(
|
||||
(await owner.readText(ws, escaped.entry)).text === escapedText,
|
||||
"real API 64 KiB escaped text must roundtrip without a false response-limit failure",
|
||||
);
|
||||
|
||||
// The adapter also handles real current authorization, not just fixture promises.
|
||||
await rejected(
|
||||
() =>
|
||||
createDriveClient(
|
||||
authenticatedFetch(fixture.origin, "invalid-fixture-token"),
|
||||
).readText(ws, fixture.worker_entry.entry),
|
||||
"denied",
|
||||
);
|
||||
console.log(
|
||||
"real Web adapter roundtrip passed: Worker visibility, folder/Markdown/PNG/empty bytes, CAS, stable URL lifecycle, authorization",
|
||||
);
|
||||
Reference in New Issue
Block a user