diff --git a/web/workspace/src/lib/workspace/config-source/ConfigSourceEditor.browser.spec.ts b/web/workspace/src/lib/workspace/config-source/ConfigSourceEditor.browser.spec.ts new file mode 100644 index 000000000..6355eaa4d --- /dev/null +++ b/web/workspace/src/lib/workspace/config-source/ConfigSourceEditor.browser.spec.ts @@ -0,0 +1,231 @@ +// @vitest-environment happy-dom + +import { cleanup, fireEvent, render, waitFor } from "@testing-library/svelte"; +import { afterEach, beforeEach, expect, test, vi } from "vitest"; +import ConfigSourceEditor from "./ConfigSourceEditor.svelte"; + +const toolchain = vi.hoisted(() => ({ + construct: vi.fn(), + setSnapshot: vi.fn(), + analyze: vi.fn(), + complete: vi.fn(), + close: vi.fn(), +})); +vi.mock("./toolchain.ts", () => ({ + ConfigSourceToolchain: class { + constructor() { + toolchain.construct(); + } + setSnapshot = toolchain.setSnapshot; + analyze = toolchain.analyze; + complete = toolchain.complete; + close = toolchain.close; + }, +})); + +const schemaSource = "schema body\n".repeat(6000); +const tree = { + snapshot: { + revision: 7, + digest: "sha256:tree", + entries: { + "main.dcdl": { + path: "main.dcdl", + content_type: "decodal", + content: "WorkspaceConfigSchema({})\n", + content_digest: "sha256:main", + }, + }, + }, + contract: { + contract_version: 1, + decodal_version: "0.4.0", + schema_version: 1, + entrypoints: ["main.dcdl"], + import_policy_version: 1, + schema_bundle: { + contributions: [{ + provider_id: "builtin", + namespace: "workspace", + version: "1", + source: schemaSource, + source_digest: "sha256:contribution", + }], + source: schemaSource, + fingerprint: "sha256:schema", + }, + fingerprint: "sha256:toolchain", + }, + projection_digest: "sha256:projection", +}; +const fetcher = vi.fn(); +const response = () => new Response(JSON.stringify(tree)); + +beforeEach(() => { + vi.resetAllMocks(); + vi.stubGlobal("fetch", fetcher); + fetcher.mockImplementation(async () => response()); + toolchain.setSnapshot.mockResolvedValue(undefined); + toolchain.analyze.mockResolvedValue([]); + toolchain.complete.mockResolvedValue(null); +}); +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); +}); + +function renderEditor(workspaceId = "w") { + return render(ConfigSourceEditor, { workspaceId }); +} + +function expectNoEditor(view: ReturnType) { + expect(view.queryByText("Select or create a source")).toBeNull(); + expect(view.queryByRole("button", { name: "Create local source" })) + .toBeNull(); + expect(view.queryByRole("button", { name: "Commit" })).toBeNull(); + expect(view.container.querySelector(".cm-editor")).toBeNull(); +} + +async function expectReady(view: ReturnType) { + await waitFor(() => + expect(view.getByRole("button", { name: "main.dcdl entrypoint" })).not + .toBeNull() + ); + expect(view.container.querySelector('.cm-content[contenteditable="true"]')) + .not.toBeNull(); + expect(view.container.textContent).toContain("WorkspaceConfigSchema({})"); + expect(view.queryByRole("alert")).toBeNull(); +} + +test("loading is not an empty source tree; editing waits for schema installation", async () => { + let finishFetch!: (response: Response) => void; + let finishSetup!: () => void; + fetcher.mockReturnValueOnce( + new Promise((resolve) => { + finishFetch = resolve; + }), + ); + toolchain.setSnapshot.mockReturnValueOnce( + new Promise((resolve) => { + finishSetup = resolve; + }), + ); + const view = render(ConfigSourceEditor, { workspaceId: "w/one" }); + expect(view.getByRole("status").textContent).toContain("Loading source tree"); + expectNoEditor(view); + finishFetch(response()); + await waitFor(() => + expect(toolchain.setSnapshot).toHaveBeenCalledWith( + tree.snapshot, + tree.contract.schema_bundle, + ) + ); + expectNoEditor(view); + finishSetup(); + await expectReady(view); + expect(fetcher).toHaveBeenCalledWith( + "/api/w/w%2Fone/config/source-tree", + expect.anything(), + ); +}); + +test.each(["http", "invalid"])( + "%s failure has a manual reload, not creation controls or automatic retries", + async (failure) => { + fetcher.mockResolvedValueOnce( + failure === "http" + ? new Response("Backend unavailable", { status: 503 }) + : new Response(JSON.stringify({ ...tree, unexpected: true })), + ); + const view = renderEditor(); + await waitFor(() => + expect(view.getByRole("alert").textContent).toContain( + "Unable to load workspace configuration", + ) + ); + expect(view.getByRole("alert").textContent).toContain( + failure === "http" ? "Backend unavailable" : "invalid response", + ); + expectNoEditor(view); + expect(toolchain.setSnapshot).not.toHaveBeenCalled(); + await new Promise((resolve) => setTimeout(resolve, 300)); + expect(fetcher).toHaveBeenCalledOnce(); + await fireEvent.click( + view.getByRole("button", { name: "Reload source tree" }), + ); + await expectReady(view); + expect(fetcher).toHaveBeenCalledTimes(2); + }, +); + +test.each(["construct", "setSnapshot"] as const)( + "toolchain %s failure is caught and manual reload initializes a fresh toolchain", + async (phase) => { + if (phase === "construct") { + toolchain.construct.mockImplementationOnce(() => { + throw new Error("Worker unavailable"); + }); + } else {toolchain.setSnapshot.mockRejectedValueOnce( + new Error("Schema setup failed"), + );} + const view = renderEditor(); + await waitFor(() => + expect(view.getByRole("alert").textContent).toContain( + phase === "construct" ? "Worker unavailable" : "Schema setup failed", + ) + ); + expectNoEditor(view); + await fireEvent.click( + view.getByRole("button", { name: "Reload source tree" }), + ); + await expectReady(view); + expect(toolchain.construct).toHaveBeenCalledTimes(2); + }, +); + +test("a successfully loaded empty tree still offers source creation", async () => { + fetcher.mockResolvedValueOnce( + new Response(JSON.stringify({ + ...tree, + snapshot: { ...tree.snapshot, entries: {} }, + })), + ); + const view = renderEditor(); + await waitFor(() => + expect(view.getByText("Select or create a source")).not.toBeNull() + ); + expect(view.getByRole("button", { name: "Create local source" })).not + .toBeNull(); + expect(view.queryByRole("alert")).toBeNull(); +}); + +test("unmount during schema setup closes the toolchain", async () => { + let failSetup!: (error: Error) => void; + toolchain.setSnapshot.mockReturnValueOnce( + new Promise((_, reject) => { + failSetup = reject; + }), + ); + toolchain.close.mockImplementationOnce(() => + failSetup(new Error("toolchain closed")) + ); + const view = renderEditor(); + await waitFor(() => expect(toolchain.setSnapshot).toHaveBeenCalledOnce()); + view.unmount(); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(toolchain.close).toHaveBeenCalledOnce(); +}); + +test("unmount during fetch does not start a toolchain", async () => { + let finishFetch!: (response: Response) => void; + fetcher.mockReturnValueOnce( + new Promise((resolve) => { + finishFetch = resolve; + }), + ); + const view = renderEditor(); + view.unmount(); + finishFetch(response()); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(toolchain.construct).not.toHaveBeenCalled(); +}); diff --git a/web/workspace/src/lib/workspace/config-source/ConfigSourceEditor.svelte b/web/workspace/src/lib/workspace/config-source/ConfigSourceEditor.svelte index 0cf4471d4..78f9edb44 100644 --- a/web/workspace/src/lib/workspace/config-source/ConfigSourceEditor.svelte +++ b/web/workspace/src/lib/workspace/config-source/ConfigSourceEditor.svelte @@ -21,6 +21,8 @@ let newPath = $state("module.dcdl"); let diagnostics = $state([]); let status = $state("Loading source tree…"); + let loadState = $state<"loading" | "error" | "ready">("loading"); + let mounted = false; let busy = $state(false); let workingChanges = $state([]); let baseRevision = $state(0); @@ -42,9 +44,12 @@ const dirty = $derived(workingChanges.length > 0 || (selected ? source !== selected.content : source.length > 0)); onMount(() => { - toolchain = new ConfigSourceToolchain(); + mounted = true; void reload(); - return () => toolchain?.close(); + return () => { + mounted = false; + toolchain?.close(); + }; }); $effect(() => { @@ -70,9 +75,20 @@ }); async function reload() { + loadState = "loading"; + status = "Loading source tree…"; analysisReady = false; + analysisGeneration += 1; try { - treeState = await fetchConfigTree(workspaceId); + const remote = await fetchConfigTree(workspaceId); + if (!mounted) return; + toolchain?.close(); + toolchain = null; + toolchain = new ConfigSourceToolchain(); + await toolchain.setSnapshot(remote.snapshot, remote.contract.schema_bundle); + if (!mounted) return; + // Publish the new baseline only after both fetch and toolchain setup succeed. + treeState = remote; if (!selectedPath || !treeState.snapshot.entries[selectedPath]) { selectedPath = Object.keys(treeState.snapshot.entries).toSorted()[0] ?? ""; } @@ -80,15 +96,19 @@ baseSnapshot = $state.snapshot(treeState.snapshot); baseRevision = treeState.snapshot.revision; baseDigest = treeState.snapshot.digest; - await toolchain?.setSnapshot(treeState.snapshot, treeState.contract.schema_bundle); analysisReady = true; workingChanges = []; renamePath = selectedPath; diagnostics = []; conflict = false; status = `Revision ${treeState.snapshot.revision} · ${treeState.snapshot.digest.slice(0, 20)}…`; + loadState = "ready"; } catch (error) { + if (!mounted) return; + toolchain?.close(); + toolchain = null; status = String(error); + loadState = "error"; } } @@ -213,8 +233,6 @@ } async function discardAndReload() { - workingChanges = []; - source = ""; await reload(); } @@ -287,7 +305,18 @@ } -
+
+ {#if loadState === "loading"} +
Loading source tree…
+ {:else if loadState === "error"} +
+
+ Unable to load workspace configuration +

{status}

+
+ +
+ {:else}
+ + diff --git a/web/workspace/src/lib/workspace/config-source/api.ts b/web/workspace/src/lib/workspace/config-source/api.ts index 479ba0b9b..c277ccc5d 100644 --- a/web/workspace/src/lib/workspace/config-source/api.ts +++ b/web/workspace/src/lib/workspace/config-source/api.ts @@ -11,6 +11,9 @@ import type { } from "./types.ts"; const MAX_RESPONSE_BYTES = 8 * 1024 * 1024; +// Schema bodies are documents, not short metadata. Keep them within the +// response budget; readJson also bounds the combined bundle and contributions. +const MAX_SCHEMA_SOURCE_BYTES = MAX_RESPONSE_BYTES; const MAX_ERROR_BYTES = 4 * 1024; const MAX_ENTRY_COUNT = 256; const MAX_ENTRY_BYTES = 256 * 1024; @@ -193,7 +196,7 @@ function parseContribution(value: unknown): ConfigSchemaContribution { provider_id: boundedString(item.provider_id), namespace: boundedString(item.namespace), version: boundedString(item.version), - source: boundedString(item.source), + source: boundedString(item.source, MAX_SCHEMA_SOURCE_BYTES), ...(projection === undefined ? {} : { projection_validator: projection }), source_digest: boundedString(item.source_digest, MAX_DIGEST_BYTES), }; @@ -203,7 +206,7 @@ function parseSchemaBundle(value: unknown): WorkspaceConfigSchemaBundle { const item = record(value, ["contributions", "source", "fingerprint"]); return { contributions: boundedArray(item.contributions).map(parseContribution), - source: boundedString(item.source), + source: boundedString(item.source, MAX_SCHEMA_SOURCE_BYTES), fingerprint: boundedString(item.fingerprint, MAX_DIGEST_BYTES), }; } diff --git a/web/workspace/test/config-source/api.test.ts b/web/workspace/test/config-source/api.test.ts index 6c5771099..d0269c215 100644 --- a/web/workspace/test/config-source/api.test.ts +++ b/web/workspace/test/config-source/api.test.ts @@ -71,18 +71,105 @@ Deno.test("config source API commits directly through the workspace scope", asyn entrypoints: [], }, fetcher); - assertEquals(calls.map((call) => call.url), [ + assertEquals(calls.map((call) => call.url), [ "/api/w/w%2Fone/config/source-tree", "/api/w/w%2Fone/config/source-tree/revisions/7", "/api/w/w%2Fone/config/source-tree/entries/profiles%2Fmain.dcdl", "/api/w/w%2Fone/config/source-tree/commit", ]); - assertEquals(calls[3].init?.method, "POST"); + assertEquals(calls[3].init?.method, "POST"); assert( String(calls[3].init?.body).includes('"base_digest":"sha256:base"'), ); }); +function withSchema(source: unknown, contributionSource: unknown = "builtin") { + return { + ...tree, + contract: { + ...tree.contract, + schema_bundle: { + ...tree.contract.schema_bundle, + source, + contributions: [{ + provider_id: "builtin", + namespace: "workspace", + version: "1", + source: contributionSource, + source_digest: "sha256:contribution", + }], + }, + }, + }; +} + +for (const field of ["bundle", "contribution"] as const) { + Deno.test(`config source API accepts ${field} schema bodies larger than metadata`, async () => { + const source = "schema body\n".repeat(6000); + const body = field === "bundle" + ? withSchema(source) + : withSchema("builtin", source); + const fetcher = (() => Promise.resolve(response(body))) as typeof fetch; + assertEquals(await fetchConfigTree("w", fetcher), body); + assertEquals( + await commitConfigTree("w", { + base_revision: 7, + base_digest: snapshot.digest, + changes: [], + entrypoints: ["main.dcdl"], + }, fetcher), + body, + ); + }); + + Deno.test(`config source API bounds ${field} schema bodies in UTF-8 bytes and checks types`, () => { + const body = (source: unknown) => + field === "bundle" ? withSchema(source) : withSchema("builtin", source); + const limit = 8 * 1024 * 1024; + const atLimit = "é".repeat(limit / 2); + assertEquals( + parseWorkspaceConfigTreeResponse(body(atLimit)), + body(atLimit), + ); + for (const invalid of [atLimit + "x", null, 42, {}]) { + assertThrows( + () => parseWorkspaceConfigTreeResponse(body(invalid)), + ConfigSourceApiError, + ); + } + }); +} + +Deno.test("config source API retains metadata and total response limits", async () => { + const invalid = withSchema("builtin"); + invalid.contract.schema_bundle.contributions[0].provider_id = "x".repeat( + 4097, + ); + assertThrows( + () => parseWorkspaceConfigTreeResponse(invalid), + ConfigSourceApiError, + ); + for ( + const headers of [ + new Headers(), + new Headers({ "content-length": String(8 * 1024 * 1024 + 1) }), + ] + ) { + const fetcher = (() => + Promise.resolve( + new Response( + JSON.stringify(withSchema("x".repeat(8 * 1024 * 1024))), + { headers }, + ), + )) as typeof fetch; + await assertRejects( + () => fetchConfigTree("w", fetcher), + ConfigSourceApiError, + "too large", + ); + } +}); + Deno.test("config source API rejects unknown response fields", async () => { const fetcher = (() =>