From 3a5cb929a48735bf8ddf1c9b6c67aa67ae615036 Mon Sep 17 00:00:00 2001 From: callumalpass Date: Fri, 2 Oct 2026 22:26:58 +1000 Subject: [PATCH] Prepare shared private feedback integration for Writer --- apps/writer/package.json | 1 + apps/writer/scripts/deploy-pages.mjs | 2 + apps/writer/scripts/feedback-browser-test.mjs | 70 +++++++++++++++++++ apps/writer/src/App.tsx | 11 ++- apps/writer/src/FeedbackRoot.test.ts | 22 ++++++ apps/writer/src/FeedbackRoot.tsx | 30 ++++++++ apps/writer/src/env.d.ts | 4 ++ apps/writer/src/feedback-shell.css | 8 +++ apps/writer/src/main.tsx | 5 +- apps/writer/src/ui/ConnectGate.tsx | 30 ++++++-- apps/writer/src/ui/WorkspaceView.tsx | 21 +++++- apps/writer/vite.config.ts | 2 +- apps/writer/vitest.config.ts | 2 +- docs/shared-feedback.md | 42 +++++++++++ 14 files changed, 239 insertions(+), 11 deletions(-) create mode 100644 apps/writer/scripts/feedback-browser-test.mjs create mode 100644 apps/writer/src/FeedbackRoot.test.ts create mode 100644 apps/writer/src/FeedbackRoot.tsx create mode 100644 apps/writer/src/feedback-shell.css create mode 100644 docs/shared-feedback.md diff --git a/apps/writer/package.json b/apps/writer/package.json index 905707c..72e5310 100644 --- a/apps/writer/package.json +++ b/apps/writer/package.json @@ -12,6 +12,7 @@ "preview": "vite preview --host 127.0.0.1", "test": "pnpm manifest && node --test scripts/*.test.mjs && vitest run", "test:browser": "node scripts/browser-test.mjs && node scripts/browser-reliability-test.mjs", + "test:feedback": "node scripts/feedback-browser-test.mjs", "test:browser:reliability": "node scripts/browser-reliability-test.mjs", "typecheck": "pnpm manifest && tsc -p tsconfig.json", "deploy:lab": "MDBASE_ENV=lab node scripts/deploy-pages.mjs", diff --git a/apps/writer/scripts/deploy-pages.mjs b/apps/writer/scripts/deploy-pages.mjs index e197dae..5096e81 100644 --- a/apps/writer/scripts/deploy-pages.mjs +++ b/apps/writer/scripts/deploy-pages.mjs @@ -16,6 +16,8 @@ const PAGES_FILE_LIMIT = 25 * 1024 * 1024; await run("pnpm", ["build"], { ...process.env, MDBASE_WRITER_ORIGIN: deployment.origin, + VITE_MDBASE_ENV: target, + VITE_MDBASE_WRITER_BUILD_ID: commit, VITE_MDBASE_CONNECT_URL: deployment.connectUrl, VITE_MDBASE_CONNECT_LOOPBACK_URL: deployment.loopbackUrl, ...(deployment.demo ? { VITE_WRITER_DEMO: "1" } : {}), diff --git a/apps/writer/scripts/feedback-browser-test.mjs b/apps/writer/scripts/feedback-browser-test.mjs new file mode 100644 index 0000000..92d354a --- /dev/null +++ b/apps/writer/scripts/feedback-browser-test.mjs @@ -0,0 +1,70 @@ +// Demo-data acceptance only. Intercepts every feedback POST; never sends mail. +import assert from "node:assert/strict"; +import { chromium } from "playwright"; + +const origin = process.env.WRITER_FEEDBACK_TEST_ORIGIN ?? "http://127.0.0.1:8892"; +if (!/^http:\/\/(127\.0\.0\.1|localhost):\d+$/.test(origin)) throw new Error("Feedback acceptance requires a loopback preview."); +const browser = await chromium.launch(); +try { + for (const width of [1440, 390]) { + const context = await browser.newContext({ viewport: { width, height: 900 }, reducedMotion: "reduce" }); + const page = await context.newPage(); + const posts = []; + await context.route("**/v1/feedback", async (route) => { + posts.push(route.request().postDataJSON()); + await route.fulfill({ status: 200, contentType: "application/json", body: "{}" }); + }); + await page.addInitScript(() => { + window.feedbackCaptures = 0; + Object.defineProperty(navigator, "mediaDevices", { + configurable: true, + value: { getDisplayMedia: () => { window.feedbackCaptures++; return Promise.reject(new DOMException("Cancelled", "NotAllowedError")); } }, + }); + }); + await page.goto(`${origin}/?demo`); + await page.locator(".manuscript-row", { hasText: "Small Agents, Large Effects" }).click(); + await page.waitForFunction(() => window.writer?.workspace?.getSnapshot().phase === "ready"); + // A user-visible compiler failure, not a Markdown/typesetting diagnostic. + await page.evaluate(() => window.writer.workspace.update({ previewProblem: "PRIVATE compiler exception /private/manuscript.md" })); + const trigger = page.locator(".topbar").getByRole("button", { name: "Send feedback", exact: true }); + await trigger.click(); + const dialog = page.getByRole("dialog", { name: "Send feedback", exact: true }); + const message = dialog.getByLabel("What happened?"); + assert.equal(await message.evaluate((element) => document.activeElement === element), true); + assert.equal(await dialog.locator("details").evaluate((element) => element.open), false); + assert.equal(await page.evaluate(() => window.feedbackCaptures), 0); + await message.fill("A private sample draft"); + await page.screenshot({ path: `/tmp/shared-feedback-writer-form-${width}.png` }); + await message.press("Control+k"); + await message.press("F8"); + assert.equal(await page.locator("dialog[open]").count(), 1); + assert.equal(await message.evaluate((element) => document.activeElement === element), true); + await message.press("Escape"); + assert.equal(await dialog.isVisible(), false); + assert.equal(await trigger.evaluate((element) => document.activeElement === element), true); + await trigger.click(); + assert.equal(await message.inputValue(), "A private sample draft"); + await dialog.getByText("What gets sent", { exact: true }).click(); + await dialog.getByLabel("Include technical diagnostics", { exact: true }).check(); + const preview = await dialog.locator("pre").last().innerText(); + assert.ok(preview.includes("preview_failed")); + assert.ok(!preview.includes("PRIVATE")); + await dialog.getByRole("button", { name: "Send feedback", exact: true }).click(); + const thanks = dialog.getByRole("heading", { name: "Thanks for the report." }); + await thanks.waitFor(); + assert.equal(await thanks.evaluate((element) => document.activeElement === element), true); + assert.equal(posts.length, 1); + const payload = posts[0]; + assert.equal(payload.schema_version, 2); + assert.equal(payload.application.product, "mdbase writer"); + assert.equal(payload.application.source_view, "manuscript"); + assert.ok(payload.diagnostics.events.some((event) => event.code === "preview_failed")); + assert.ok(!JSON.stringify(payload).includes("PRIVATE")); + for (const key of ["context", "screenshot", "reply_email"]) assert.equal(payload[key], undefined); + await page.screenshot({ path: `/tmp/shared-feedback-writer-${width}.png` }); + await context.close(); + } + console.log("Writer feedback desktop/mobile acceptance passed; all delivery intercepted."); +} finally { + await browser.close(); +} diff --git a/apps/writer/src/App.tsx b/apps/writer/src/App.tsx index 9b9bc07..71d2b09 100644 --- a/apps/writer/src/App.tsx +++ b/apps/writer/src/App.tsx @@ -9,6 +9,8 @@ import type { WriterBackend } from "./backend/types.js"; import { createWriterSession, type WriterSession } from "./connect/session.js"; import { appUrls } from "./apps.js"; import { AppSwitcher } from "@mdbase-dev/ui/app-switcher"; +import { FeedbackButton, useFeedback } from "@mdbase-dev/ui/feedback"; +import { useWriterFeedbackContext } from "./FeedbackRoot.js"; import { ConnectGate } from "./ui/ConnectGate.js"; import { CollectionPicker } from "./ui/CollectionPicker.js"; import { Home } from "./ui/Home.js"; @@ -65,6 +67,7 @@ function useManuscriptParam(mayLeave: () => Promise): [string | null, ( } export function App() { + const { reportError } = useFeedback(); const [theme, setTheme] = useState(() => loadThemePreference()); const [slot, setSlot] = useState(null); useEffect(() => saveThemePreference(theme), [theme]); @@ -77,24 +80,27 @@ export function App() {
+ - {demoRequested ? : } + reportError({ code: "unknown_error" })}>{demoRequested ? : }
); } -class ErrorBoundary extends Component<{ children: ReactNode }, { error: Error | null }> { +class ErrorBoundary extends Component<{ children: ReactNode; onError(): void }, { error: Error | null }> { override state = { error: null as Error | null }; static getDerivedStateFromError(error: Error) { return { error }; } + override componentDidCatch() { this.props.onError(); } override render() { if (!this.state.error) return this.props.children; return (

Something went wrong

+

{this.state.error.message}

Your saved text is in your collection. Unsent local drafts are backed up in this browser when storage is available. Download a copy before reloading.

{snap.recoveredDrafts.size > 0 && <>

A local draft is still available in this browser.

}