diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 567606fa..c89a7332 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -68,12 +68,8 @@ jobs: pnpm --filter @mdbase-writer/app test:browser:reliability || result=1 exit "$result" - name: Verify private feedback with demo data and intercepted delivery - env: - VITE_WRITER_DEMO: "1" - VITE_MDBASE_FEEDBACK_URL: http://127.0.0.1:8892/v1/feedback - VITE_MDBASE_ENV: lab run: | - pnpm --filter @mdbase-writer/app build + pnpm --filter @mdbase-writer/app build:feedback-test pnpm --filter @mdbase-writer/app exec vite preview --host 127.0.0.1 --port 8892 --strictPort > "$RUNNER_TEMP/writer-feedback-preview.log" 2>&1 & preview_pid=$! trap 'kill "$preview_pid" 2>/dev/null || true' EXIT diff --git a/apps/writer/package.json b/apps/writer/package.json index 25955009..2d58a870 100644 --- a/apps/writer/package.json +++ b/apps/writer/package.json @@ -7,6 +7,7 @@ "icons": "node scripts/write-pwa-icons.mjs", "build": "pnpm manifest && pnpm manifest:validate && vite build", "build:lab": "VITE_MDBASE_ENV=lab pnpm build", + "build:feedback-test": "VITE_WRITER_DEMO=1 VITE_MDBASE_ENV=lab VITE_MDBASE_FEEDBACK_URL=http://127.0.0.1:8892/v1/feedback pnpm build", "dev": "pnpm manifest && vite --host 127.0.0.1", "manifest": "node scripts/write-manifest.mjs", "manifest:validate": "mdbase-connect-dev validate-manifest public/.well-known/mdbase-app.json", diff --git a/apps/writer/src/LegacyApp.tsx b/apps/writer/src/LegacyApp.tsx index 0b370570..b80c87e3 100644 --- a/apps/writer/src/LegacyApp.tsx +++ b/apps/writer/src/LegacyApp.tsx @@ -21,10 +21,11 @@ import { ThemeSelect } from "@mdbase-dev/ui/theme-select"; import { ConnectLayout, OpeningScreen } from "@mdbase-dev/ui/screens"; import { ManuscriptWorkspace } from "./workspace/workspace.js"; +import { writerDemoRequested } from "./demo-mode.js"; const params = new URL(location.href).searchParams; -const demoRequested = (import.meta.env.DEV || import.meta.env.VITE_WRITER_DEMO === "1") && params.has("demo"); +const demoRequested = writerDemoRequested(); export function LegacyApp() { const { reportError } = useFeedback(); diff --git a/apps/writer/src/demo-mode.ts b/apps/writer/src/demo-mode.ts new file mode 100644 index 00000000..a8bbeebe --- /dev/null +++ b/apps/writer/src/demo-mode.ts @@ -0,0 +1,6 @@ +/** Explicit development/test fixtures only. A demo query alone never bypasses + * the native entry in ordinary deployment builds. */ +export function writerDemoRequested(): boolean { + return (import.meta.env.DEV || import.meta.env.VITE_WRITER_DEMO === "1") + && new URL(location.href).searchParams.has("demo"); +} diff --git a/apps/writer/src/main.test.tsx b/apps/writer/src/main.test.tsx new file mode 100644 index 00000000..947500af --- /dev/null +++ b/apps/writer/src/main.test.tsx @@ -0,0 +1,67 @@ +// @vitest-environment jsdom +import { afterEach, beforeEach, expect, it, vi } from "vitest"; + +const bootstrap = vi.hoisted(() => ({ + render: vi.fn(), + entry: { application: {} }, + create: vi.fn(), + grantReturn: vi.fn(), + deliver: vi.fn(async () => {}), +})); +vi.mock("react-dom/client", () => ({ createRoot: () => ({ render: bootstrap.render }) })); +vi.mock("./App.js", () => ({ App: () => null })); +vi.mock("./FeedbackRoot.js", () => ({ FeedbackRoot: () => null })); +vi.mock("./pwa-install.js", () => ({ setupPwaInstall: () => () => {} })); +vi.mock("./connect/sdk-application.js", () => ({ + writerMdbaseForBuild: bootstrap.create, + isWriterGrantReturn: bootstrap.grantReturn, + deliverWriterGrantReturn: bootstrap.deliver, +})); + +beforeEach(() => { + vi.resetModules(); + vi.clearAllMocks(); + bootstrap.create.mockReturnValue(bootstrap.entry); + bootstrap.grantReturn.mockReturnValue(false); + document.body.innerHTML = '
'; + history.replaceState({}, "", "/?demo"); + vi.stubEnv("DEV", false); + vi.stubEnv("VITE_MDBASE_ENV", "lab"); + vi.stubEnv("VITE_WRITER_DEMO", undefined); +}); +afterEach(() => vi.unstubAllEnvs()); + +it("keeps an ordinary LAB demo query on the native entry without an enabled fixture build", async () => { + await import("./main.js"); + expect(bootstrap.create).toHaveBeenCalledExactlyOnceWith("lab"); + expect(bootstrap.render.mock.calls[0]![0].props.children.props.children.props.sdk).toBe(bootstrap.entry); +}); + +it("opens the explicit production demo fixture without allocating a native account entry", async () => { + vi.stubEnv("VITE_WRITER_DEMO", "1"); + await import("./main.js"); + expect(bootstrap.create).not.toHaveBeenCalled(); + expect(bootstrap.render.mock.calls[0]![0].props.children.props.children.props.sdk).toBeUndefined(); +}); + +it("still opens the normal native entry in a fixture-enabled build without the demo query", async () => { + vi.stubEnv("VITE_WRITER_DEMO", "1"); + history.replaceState({}, "", "/"); + await import("./main.js"); + expect(bootstrap.create).toHaveBeenCalledExactlyOnceWith("lab"); +}); + +it("honors the existing development demo without allocating a native entry", async () => { + vi.stubEnv("DEV", true); + await import("./main.js"); + expect(bootstrap.create).not.toHaveBeenCalled(); +}); + +it("delivers a grant return ahead of an explicitly enabled demo and never allocates a second entry", async () => { + vi.stubEnv("VITE_WRITER_DEMO", "1"); + bootstrap.grantReturn.mockReturnValue(true); + await import("./main.js"); + expect(bootstrap.deliver).toHaveBeenCalledExactlyOnceWith(location.href); + expect(bootstrap.create).not.toHaveBeenCalled(); + expect(bootstrap.render.mock.calls[0]![0].props.children.type).toBe("p"); +}); diff --git a/apps/writer/src/main.tsx b/apps/writer/src/main.tsx index 2d3f7001..d18a8aea 100644 --- a/apps/writer/src/main.tsx +++ b/apps/writer/src/main.tsx @@ -13,6 +13,7 @@ import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; import { App } from "./App.js"; +import { writerDemoRequested } from "./demo-mode.js"; import { FeedbackRoot } from "./FeedbackRoot.js"; import { deliverWriterGrantReturn, isWriterGrantReturn, writerMdbaseForBuild } from "./connect/sdk-application.js"; @@ -27,7 +28,7 @@ if (import.meta.hot) import.meta.hot.dispose(stopPwaInstall); const grantReturn = isWriterGrantReturn(import.meta.env.VITE_MDBASE_ENV, location.href); if (grantReturn) void deliverWriterGrantReturn(location.href).catch(() => {}); // One declaration-only singleton outside React/StrictMode. SDK owns its runtime. -const sdk = grantReturn ? undefined : writerMdbaseForBuild(import.meta.env.VITE_MDBASE_ENV); +const sdk = grantReturn || writerDemoRequested() ? undefined : writerMdbaseForBuild(import.meta.env.VITE_MDBASE_ENV); const root = document.getElementById("root"); if (!root) throw new Error("Missing #root"); createRoot(root).render( diff --git a/apps/writer/src/workspace/workspace.test.ts b/apps/writer/src/workspace/workspace.test.ts index d9be9b2f..511b7809 100644 --- a/apps/writer/src/workspace/workspace.test.ts +++ b/apps/writer/src/workspace/workspace.test.ts @@ -34,7 +34,9 @@ afterEach(async () => { function fixture() { const authority = createRecordTestAuthority(); authority.records.follow(authority.watch); - authority.seed("main.md", { body: "# Main\n\n![[chapters/one]]\n", frontmatter: { type: "custom", local_title: "Mapped title", local_style: "chicago-notes-bibliography" } }); + // Use a small real CSL style for workspace-state tests. Repeatedly parsing + // the much larger Chicago style dominates their CI budget; core tests cover it. + authority.seed("main.md", { body: "# Main\n\n![[chapters/one]]\n", frontmatter: { type: "custom", local_title: "Mapped title", local_style: "ieee" } }); authority.seed("chapters/one.md", { body: "# First chapter\n\n![[two]]\n" }); authority.seed("chapters/two.md", { body: "# Nested chapter\n\nComplete text.\n\n![Figure](image.svg)\n" }); const backend: WriterBackend = { @@ -286,6 +288,7 @@ describe("workspace reliability", () => { await ready(workspace); await vi.waitFor(() => expect(workspace["compile"].send).toHaveBeenCalledWith(expect.objectContaining({ type: "init" }))); expect(workspace.getSnapshot().annotationsLoad.phase).toBe("ready"); + expect(workspace.getSnapshot().records.get("main.md")?.snapshot.frontmatter["csl"]).toBe("ieee"); expect((await workspace.exportBundle()).problems).toEqual([]); paths.mockImplementationOnce(async () => fail("Temporary annotation failure")); await workspace.retryMetadata(); diff --git a/docs/development.md b/docs/development.md index 981fba28..5b96c5a7 100644 --- a/docs/development.md +++ b/docs/development.md @@ -44,6 +44,24 @@ The existing browser acceptance coverage uses the demo collection, including the Acceptance against a real Connect collection has not yet been recorded in the architecture notes. Do not treat demo coverage as proof of connected-collection behaviour. +## Private feedback acceptance + +Build the explicit local fixture, then serve it on the feedback test's loopback port: + +```sh +pnpm --filter @mdbase-writer/app build:feedback-test +pnpm --filter @mdbase-writer/app exec vite preview --host 127.0.0.1 --port 8892 --strictPort +# In another terminal: +pnpm --filter @mdbase-writer/app test:feedback +``` + +This build opts into the in-memory demo with `?demo`, including with the LAB environment +selected. Bootstrap does not create a native account entry for that explicit demo; ordinary +LAB URLs still use the SDK, and grant-return callbacks take precedence. The unchanged test +checks desktop/mobile feedback, focus and draft restoration, redacted diagnostics and +consented payloads. All feedback delivery is intercepted. Do not deploy this fixture build: +its feedback endpoint is loopback, and it is not connected-collection acceptance evidence. + ## Against a real collection The hosted mdbase Connect service accepts applications served over HTTPS only, so a localhost