From 26cc5e9306cd143dc509dab51ac8458faf56a4ab Mon Sep 17 00:00:00 2001 From: Hare Date: Fri, 30 Oct 2026 00:11:02 +0900 Subject: [PATCH] fix: supply missing Workspace permission fixtures for Memory tests --- .../memory/memory-page.browser.spec.ts | 230 ++++++++++++++---- 1 file changed, 181 insertions(+), 49 deletions(-) diff --git a/web/workspace/src/routes/w/[workspaceId]/memory/memory-page.browser.spec.ts b/web/workspace/src/routes/w/[workspaceId]/memory/memory-page.browser.spec.ts index 6d71ec0f2..2cb77c72f 100644 --- a/web/workspace/src/routes/w/[workspaceId]/memory/memory-page.browser.spec.ts +++ b/web/workspace/src/routes/w/[workspaceId]/memory/memory-page.browser.spec.ts @@ -8,6 +8,40 @@ import { waitFor, } from "@testing-library/svelte"; import { afterEach, expect, test, vi } from "vitest"; +import type { WorkspaceResponse } from "#lib/generated/legacy-server-api.ts"; + +// SubjectPage inherits this validated projection from the Workspace layout. +// No deletion grant is implied by these behavior/Memory read fixtures. +function memoryWorkspace(workspaceId = "workspace-1"): WorkspaceResponse { + const extension = { status: "ready", note: "fixture", diagnostics: [] }; + return { + workspace_id: workspaceId, + display_name: "Memory fixture", + record_authority: "fixture", + schema_version: 1, + auth: { + Passkey: { + rp_id: "127.0.0.1", + origin: "http://127.0.0.1", + public_base_url: "http://127.0.0.1", + cookie_name: "fixture", + }, + }, + permissions: { + manage_repositories: false, + manage_secrets: false, + manage_runtimes: false, + delete_workspace: false, + delete_subjects: false, + }, + extension_points: { + store: "fixture", + event_stream: extension, + host_worker_bridge: extension, + companion_console: extension, + }, + }; +} const { gotoMock } = vi.hoisted(() => ({ gotoMock: vi.fn(async () => {}), @@ -240,6 +274,7 @@ test("renders a ready resident surface and committed Memory lifecycle states", a render(SubjectPage, { data: { workspaceId: "workspace-1", + workspace: memoryWorkspace(), subjectId: "subject-1", cursor: "memory-current", subject: result({ @@ -334,6 +369,7 @@ test("edits and clears user-managed behavior with CAS while distinguishing save render(SubjectPage, { data: { workspaceId: "workspace-1", + workspace: memoryWorkspace(), subjectId: "subject-1", subject: result(subject()), surface: result({ @@ -378,15 +414,34 @@ test("edits and clears user-managed behavior with CAS while distinguishing save await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2)); const secondBody = (fetchMock.mock.calls[1] as unknown as [string, RequestInit])[1].body; - expect(secondBody).toBe('{"expected_behavior_md":"Ask before irreversible actions.","behavior_md":""}'); + expect(secondBody).toBe( + '{"expected_behavior_md":"Ask before irreversible actions.","behavior_md":""}', + ); await waitFor(() => expect(screen.getByText("No behavior is set.")).not.toBeNull() ); }); +test.each([false, true])( + "Subject deletion visibility follows the inherited explicit grant: %s", + (allowed) => { + const data = subjectPageData(); + data.workspace.permissions.delete_subjects = allowed; + render(SubjectPage, { data } as never); + expect( + screen.queryByRole("heading", { name: "Delete Subject" }) !== + null, + ).toBe(allowed); + // Behavior editability is not deletion authority. + expect(screen.getByRole("button", { name: "Edit" })).not + .toBeNull(); + }, +); + function subjectPageData(record = subject(), workspaceId = "workspace-1") { return { workspaceId, + workspace: memoryWorkspace(workspaceId), subjectId: record.id, subject: result(record), surface: result({ @@ -405,12 +460,20 @@ test("same-identity refresh updates Subject projection without rebasing an activ const view = render(SubjectPage, { data: subjectPageData() } as never); await fireEvent.click(screen.getByRole("button", { name: "Edit" })); const textarea = screen.getByRole("textbox", { name: "Subject behavior" }); - await fireEvent.input(textarea, { target: { value: "Unsaved behavior draft" } }); + await fireEvent.input(textarea, { + target: { value: "Unsaved behavior draft" }, + }); - const refreshed = { ...subject(), role: "Refreshed role", behavior_md: "Changed on server." }; + const refreshed = { + ...subject(), + role: "Refreshed role", + behavior_md: "Changed on server.", + }; await view.rerender({ data: subjectPageData(refreshed) } as never); - expect(screen.getByRole("heading", { name: "Refreshed role", level: 1 })).not.toBeNull(); - expect(screen.getByRole("textbox", { name: "Subject behavior" })).toHaveProperty("value", "Unsaved behavior draft"); + expect(screen.getByRole("heading", { name: "Refreshed role", level: 1 })).not + .toBeNull(); + expect(screen.getByRole("textbox", { name: "Subject behavior" })) + .toHaveProperty("value", "Unsaved behavior draft"); await fireEvent.click(screen.getByRole("button", { name: "Save behavior" })); await waitFor(() => expect(screen.getByRole("alert")).not.toBeNull()); expect(JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body))).toEqual({ @@ -418,36 +481,71 @@ test("same-identity refresh updates Subject projection without rebasing an activ behavior_md: "Unsaved behavior draft", }); - await view.rerender({ data: subjectPageData({ ...refreshed, behavior_md: "Changed again." }) } as never); + await view.rerender( + { + data: subjectPageData({ ...refreshed, behavior_md: "Changed again." }), + } as never, + ); expect(screen.getByRole("alert")).not.toBeNull(); expect(textarea).toHaveProperty("value", "Unsaved behavior draft"); await fireEvent.click(screen.getByRole("button", { name: "Save behavior" })); await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2)); - expect(JSON.parse(String(fetchMock.mock.calls[1]?.[1]?.body)).expected_behavior_md).toBe("Prefer explicit evidence."); - await waitFor(() => expect(screen.getByRole("button", { name: "Cancel" })).toHaveProperty("disabled", false)); + expect( + JSON.parse(String(fetchMock.mock.calls[1]?.[1]?.body)).expected_behavior_md, + ).toBe("Prefer explicit evidence."); + await waitFor(() => + expect(screen.getByRole("button", { name: "Cancel" })).toHaveProperty( + "disabled", + false, + ) + ); await fireEvent.click(screen.getByRole("button", { name: "Cancel" })); - expect(screen.getByRole("article", { name: "User-managed Subject behavior" }).textContent).toContain("Changed again."); + expect( + screen.getByRole("article", { name: "User-managed Subject behavior" }) + .textContent, + ).toContain("Changed again."); await fireEvent.click(screen.getByRole("button", { name: "Edit" })); - expect(screen.getByRole("textbox", { name: "Subject behavior" })).toHaveProperty("value", "Changed again."); + expect(screen.getByRole("textbox", { name: "Subject behavior" })) + .toHaveProperty("value", "Changed again."); }); test("same-identity refresh keeps an in-flight behavior save fenced and accepts its result", async () => { let resolveRequest!: (response: Response) => void; - const fetchMock = vi.fn(() => new Promise((resolve) => { resolveRequest = resolve; })); + const fetchMock = vi.fn(() => + new Promise((resolve) => { + resolveRequest = resolve; + }) + ); vi.stubGlobal("fetch", fetchMock); const view = render(SubjectPage, { data: subjectPageData() } as never); await fireEvent.click(screen.getByRole("button", { name: "Edit" })); - await fireEvent.input(screen.getByRole("textbox", { name: "Subject behavior" }), { - target: { value: "Saved draft" }, - }); + await fireEvent.input( + screen.getByRole("textbox", { name: "Subject behavior" }), + { + target: { value: "Saved draft" }, + }, + ); await fireEvent.click(screen.getByRole("button", { name: "Save behavior" })); await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1)); - await view.rerender({ data: subjectPageData({ ...subject(), behavior_md: "Refreshed text" }) } as never); - expect(screen.getByRole("button", { name: "Saving…" })).toHaveProperty("disabled", true); - expect(screen.getByRole("textbox", { name: "Subject behavior" })).toHaveProperty("value", "Saved draft"); + await view.rerender( + { + data: subjectPageData({ ...subject(), behavior_md: "Refreshed text" }), + } as never, + ); + expect(screen.getByRole("button", { name: "Saving…" })).toHaveProperty( + "disabled", + true, + ); + expect(screen.getByRole("textbox", { name: "Subject behavior" })) + .toHaveProperty("value", "Saved draft"); resolveRequest(Response.json({ ...subject(), behavior_md: "Saved draft" })); - await waitFor(() => expect(screen.getByText(/storage confirmed/)).not.toBeNull()); - expect(screen.getByRole("article", { name: "User-managed Subject behavior" }).textContent).toContain("Saved draft"); + await waitFor(() => + expect(screen.getByText(/storage confirmed/)).not.toBeNull() + ); + expect( + screen.getByRole("article", { name: "User-managed Subject behavior" }) + .textContent, + ).toContain("Saved draft"); }); test.each([ @@ -635,27 +733,39 @@ test("a refreshed loader snapshot fences old saves even after returning to the s ).toBeNull(); }); -test.each([409, 503])("behavior save rejection %s preserves the draft and observed CAS text", async (status) => { - const fetchMock = vi.fn(async () => Response.json({ message: "save rejected" }, { status })); - vi.stubGlobal("fetch", fetchMock); - render(SubjectPage, { data: subjectPageData() } as never); - await fireEvent.click(screen.getByRole("button", { name: "Edit" })); - const textarea = screen.getByRole("textbox", { name: "Subject behavior" }); - await fireEvent.input(textarea, { target: { value: " Unsaved\nbehavior.\n" } }); - await fireEvent.click(screen.getByRole("button", { name: "Save behavior" })); - await waitFor(() => expect(screen.getByRole("alert")).not.toBeNull()); - expect(textarea).toHaveProperty("value", " Unsaved\nbehavior.\n"); - expect(screen.queryByText(/storage confirmed/)).toBeNull(); - expect(fetchMock).toHaveBeenCalledTimes(1); - expect(fetchMock.mock.calls[0]).toEqual([ - "/api/w/workspace-1/subjektiv/subjects/subject-1/behavior", - { - method: "PATCH", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ expected_behavior_md: "Prefer explicit evidence.", behavior_md: " Unsaved\nbehavior.\n" }), - }, - ]); -}); +test.each([409, 503])( + "behavior save rejection %s preserves the draft and observed CAS text", + async (status) => { + const fetchMock = vi.fn(async () => + Response.json({ message: "save rejected" }, { status }) + ); + vi.stubGlobal("fetch", fetchMock); + render(SubjectPage, { data: subjectPageData() } as never); + await fireEvent.click(screen.getByRole("button", { name: "Edit" })); + const textarea = screen.getByRole("textbox", { name: "Subject behavior" }); + await fireEvent.input(textarea, { + target: { value: " Unsaved\nbehavior.\n" }, + }); + await fireEvent.click( + screen.getByRole("button", { name: "Save behavior" }), + ); + await waitFor(() => expect(screen.getByRole("alert")).not.toBeNull()); + expect(textarea).toHaveProperty("value", " Unsaved\nbehavior.\n"); + expect(screen.queryByText(/storage confirmed/)).toBeNull(); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock.mock.calls[0]).toEqual([ + "/api/w/workspace-1/subjektiv/subjects/subject-1/behavior", + { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + expected_behavior_md: "Prefer explicit evidence.", + behavior_md: " Unsaved\nbehavior.\n", + }), + }, + ]); + }, +); test("candidate-only Subjects show no committed Memories, not an inferred candidate count", () => { // The store regression candidate_decisions_reject_conflicts_and_roll_back_partial_writes @@ -664,6 +774,7 @@ test("candidate-only Subjects show no committed Memories, not an inferred candid render(SubjectPage, { data: { workspaceId: "workspace-1", + workspace: memoryWorkspace(), subjectId: "subject-1", cursor: null, subject: result({ ...subject(), memory_fingerprint: "fingerprint-0" }), @@ -747,6 +858,7 @@ test.each(["ready", "ungenerated", "stale", "failed"] as const)( test("distinguishes ready-empty, stale, failed, unavailable, and request error surfaces", async () => { const base = { workspaceId: "workspace-1", + workspace: memoryWorkspace(), subjectId: "subject-1", subject: result(subject()), memories: result({ items: [], has_more: false }), @@ -832,6 +944,7 @@ test("renders committed Memory detail, provenance, derivation, and immutable cha render(MemoryDetailPage, { data: { workspaceId: "workspace-1", + workspace: memoryWorkspace(), subjectId: "subject-1", memoryId: "memory-1", changeCursor: "a/current", @@ -872,7 +985,10 @@ test("renders committed Memory detail, provenance, derivation, and immutable cha source_refs_total: 1, source_refs_truncated: false, }], - derived_from: [{ memory_id: "memory-parent", change_id: "parent/source & context" }], + derived_from: [{ + memory_id: "memory-parent", + change_id: "parent/source & context", + }], evidence_has_more: true, evidence_next_cursor: "evidence/next", }), @@ -912,23 +1028,39 @@ test("renders committed Memory detail, provenance, derivation, and immutable cha expect(screen.getByText("Historical change")).not.toBeNull(); expect(screen.getByRole("navigation", { name: "Change history pages" })) .not.toBeNull(); - const historicalLink = screen.getByRole("link", { name: /Memory change z\/earlier/ }); + const historicalLink = screen.getByRole("link", { + name: /Memory change z\/earlier/, + }); expect(historicalLink.getAttribute("aria-current")).toBe("page"); - expect(historicalLink.getAttribute("href")).toBe("/w/workspace-1/memory/subject-1/memory-1?change_id=z%2Fearlier%20%26%20context"); - const currentLink = screen.getByRole("link", { name: /Memory change a\/current/ }); + expect(historicalLink.getAttribute("href")).toBe( + "/w/workspace-1/memory/subject-1/memory-1?change_id=z%2Fearlier%20%26%20context", + ); + const currentLink = screen.getByRole("link", { + name: /Memory change a\/current/, + }); expect(currentLink.getAttribute("aria-current")).toBeNull(); expect(currentLink.textContent).toContain("Current"); - expect(screen.getByRole("link", { name: /memory-parent/ }).getAttribute("href")).toBe("/w/workspace-1/memory/subject-1/memory-parent?change_id=parent%2Fsource%20%26%20context"); - const bodyHref = screen.getByRole("link", { name: "View next body segment" }).getAttribute("href"); + expect( + screen.getByRole("link", { name: /memory-parent/ }).getAttribute("href"), + ).toBe( + "/w/workspace-1/memory/subject-1/memory-parent?change_id=parent%2Fsource%20%26%20context", + ); + const bodyHref = screen.getByRole("link", { name: "View next body segment" }) + .getAttribute("href"); const bodyQuery = new URL(bodyHref!, "https://example.com").searchParams; expect(bodyQuery.get("change_id")).toBe("z/earlier & context"); expect(bodyQuery.get("offset")).toBe("2"); expect(bodyQuery.get("byte_offset")).toBe("0"); - const evidenceHref = screen.getByRole("link", { name: "View next evidence page" }).getAttribute("href"); - const evidenceQuery = new URL(evidenceHref!, "https://example.com").searchParams; + const evidenceHref = screen.getByRole("link", { + name: "View next evidence page", + }).getAttribute("href"); + const evidenceQuery = + new URL(evidenceHref!, "https://example.com").searchParams; expect(evidenceQuery.get("change_id")).toBe("z/earlier & context"); expect(evidenceQuery.get("evidence_cursor")).toBe("evidence/next"); - const nextHref = screen.getByRole("link", { name: /Next page/ }).getAttribute("href"); + const nextHref = screen.getByRole("link", { name: /Next page/ }).getAttribute( + "href", + ); const nextQuery = new URL(nextHref!, "https://example.com").searchParams; expect(nextQuery.get("change_cursor")).toBe("change-next"); expect(nextQuery.get("change_id")).toBe("z/earlier & context");