Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,26 @@ jobs:
run: pnpm --filter @mdbase-reader/app exec playwright install --with-deps chromium
- name: Verify PDF gestures and mobile highlight persistence
run: pnpm --filter @mdbase-reader/app test:pdf-touch
- name: Verify private feedback with sample data and intercepted delivery
env:
VITE_MDBASE_FEEDBACK_URL: http://127.0.0.1:8891/v1/feedback
VITE_MDBASE_ENV: lab
run: |
pnpm --filter @mdbase-reader/app build
pnpm --filter @mdbase-reader/app exec vite preview --host 127.0.0.1 --port 8891 --strictPort > "$RUNNER_TEMP/reader-feedback-preview.log" 2>&1 &
preview_pid=$!
trap 'kill "$preview_pid" 2>/dev/null || true' EXIT
ready=0
for attempt in {1..30}; do
kill -0 "$preview_pid" 2>/dev/null || exit 1
if curl --max-time 2 --fail --silent http://127.0.0.1:8891/ >/dev/null; then
ready=1
break
fi
sleep 1
done
test "$ready" -eq 1
pnpm --filter @mdbase-reader/app test:feedback
# Generated manifests are committed for production; building for it must leave the tree clean.
- run: pnpm build
env:
Expand Down
2 changes: 2 additions & 0 deletions .github/workflows/deploy-reader.yml
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,8 @@ jobs:
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
VITE_MDBASE_FEEDBACK_URL: ${{ vars.MDBASE_FEEDBACK_URL }}
VITE_MDBASE_FEEDBACK_TURNSTILE_SITE_KEY: ${{ vars.MDBASE_FEEDBACK_TURNSTILE_SITE_KEY }}
- name: Verify deployed manifest
env:
ORIGIN: ${{ inputs.target == 'production' && 'https://reader.mdbase.dev' || 'https://staging.mdbase-reader.pages.dev' }}
Expand Down
1 change: 1 addition & 0 deletions apps/reader/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
"preview": "vite preview --host 127.0.0.1",
"test": "pnpm manifest && pnpm manifest:verify && node --test scripts/*.test.mjs && vitest run src capture --maxWorkers=2 --testTimeout=15000",
"test:browser": "node scripts/audit-reader.mjs",
"test:feedback": "node scripts/feedback-browser-test.mjs",
"test:pdf-touch": "node scripts/run-pdf-touch-audits.mjs",
"typecheck": "tsc -p tsconfig.json",
"test:a11y": "node scripts/audit-accessibility.mjs"
Expand Down
69 changes: 69 additions & 0 deletions apps/reader/scripts/feedback-browser-test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
// Sample-data acceptance only. Intercepts every feedback POST; never sends mail.
/* global window */
import assert from "node:assert/strict";
import { chromium, expect } from "@playwright/test";

const origin = process.env.READER_FEEDBACK_TEST_ORIGIN ?? "http://127.0.0.1:8891";
if (!/^http:\/\/(127\.0\.0\.1|localhost):\d+$/u.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 } });
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}/?preview`);
const trigger = page
.locator(".reader-header")
.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?");
await expect(message).toBeFocused();
await expect(dialog.locator("details")).not.toHaveAttribute("open");
assert.equal(await page.evaluate(() => window.feedbackCaptures), 0);
await message.fill("A private sample draft");
await page.screenshot({ path: `/tmp/shared-feedback-reader-form-${width}.png` });
await message.press("Control+k");
assert.equal(await page.locator("dialog[open]").count(), 1);
await message.press("Escape");
await expect(dialog).not.toBeVisible();
await expect(trigger).toBeFocused();
await trigger.click();
await expect(message).toHaveValue("A private sample draft");
await dialog.getByRole("button", { name: "Attach screenshot", exact: true }).click();
await expect(dialog.getByRole("status")).toContainText("No screenshot taken");
assert.equal(await page.evaluate(() => window.feedbackCaptures), 1);
await dialog.getByRole("button", { name: "Send feedback", exact: true }).click();
await expect(dialog.getByRole("heading", { name: "Thanks for the report." })).toBeFocused();
assert.equal(posts.length, 1);
const payload = posts[0];
assert.equal(payload.schema_version, 2);
assert.equal(payload.application.product, "mdbase reader");
assert.ok(["library", "document"].includes(payload.application.source_view));
for (const key of ["context", "diagnostics", "screenshot", "reply_email"])
assert.equal(payload[key], undefined);
await page.screenshot({ path: `/tmp/shared-feedback-reader-${width}.png` });
await context.close();
}
console.log("Reader feedback desktop/mobile acceptance passed; all delivery intercepted.");
} finally {
await browser.close();
}
50 changes: 41 additions & 9 deletions apps/reader/src/ConnectReader.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { FeedbackButton, useFeedback } from "@mdbase-dev/ui/feedback";
import { ConnectLayout, OpeningScreen } from "@mdbase-dev/ui/screens";
import { connectProblemMessage, type ReaderConnectSnapshot } from "@mdbase-reader/connect";
import { createReaderRuntimeServices, createWebPlatform } from "@mdbase-reader/platform";
Expand Down Expand Up @@ -54,14 +55,23 @@ async function startSession(): Promise<Awaited<ReturnType<typeof readerSession.s
}

export function ConnectReader(): JSX.Element {
const { reportError } = useFeedback();
const session = useSyncExternalStore(subscribe, snapshot, snapshot);
const [error, setError] = useState<string | null>(null);

const start = async (): Promise<void> => {
setError(null);
try {
setError(connectProblemMessage(await startSession()));
const outcome = await startSession();
setError(connectProblemMessage(outcome));
if (!outcome.ok && !/cancel|abort|supersed/u.test(outcome.problem.code)) {
reportError({ code: "unknown_error" });
}
} catch (reason) {
if (reason instanceof DOMException && reason.name === "AbortError") {
return;
}
reportError({ code: "source_open_failed" });
setError(readerErrorMessage(reason, "Reader could not open this collection."));
}
};
Expand All @@ -72,17 +82,21 @@ export function ConnectReader(): JSX.Element {
.then((outcome) => {
if (active) {
setError(connectProblemMessage(outcome));
if (!outcome.ok && !/cancel|abort|supersed/u.test(outcome.problem.code)) {
reportError({ code: "unknown_error" });
}
}
})
.catch((reason: unknown) => {
if (active) {
if (active && !(reason instanceof DOMException && reason.name === "AbortError")) {
reportError({ code: "source_open_failed" });
setError(readerErrorMessage(reason, "Reader could not open this collection."));
}
});
return () => {
active = false;
};
}, []);
}, [reportError]);

useEffect(() => {
// Overlap workspace download with live collection checks, not the first query.
Expand Down Expand Up @@ -197,17 +211,20 @@ function OpenedReader({ collectionId }: { readonly collectionId: string }): JSX.
);
}

interface ConnectionScreenProps {
readonly session: Exclude<ReaderConnectSnapshot, { status: "ready" }>;
readonly error: string | null;
readonly onError: (message: string | null) => void;
readonly onRetry: () => void;
}

function ConnectionScreen({
session,
error,
onError,
onRetry,
}: {
readonly session: Exclude<ReaderConnectSnapshot, { status: "ready" }>;
readonly error: string | null;
readonly onError: (message: string | null) => void;
readonly onRetry: () => void;
}): JSX.Element {
}: ConnectionScreenProps): JSX.Element {
const { reportError } = useFeedback();
const [working, setWorking] = useState(false);
const selectedCollectionId = "collectionId" in session ? session.collectionId : null;
// Some failures arrive only as the session's status, not as a step's error.
Expand All @@ -219,7 +236,14 @@ function ConnectionScreen({
try {
const outcome = await readerSession.authorize(target);
onError(connectProblemMessage(outcome));
if (!outcome.ok && !/cancel|abort|supersed/u.test(outcome.problem.code)) {
reportError({ code: "unknown_error" });
}
} catch (reason) {
if (reason instanceof DOMException && reason.name === "AbortError") {
return;
}
reportError({ code: "unknown_error" });
onError(readerErrorMessage(reason, "Reader could not review application access."));
} finally {
setWorking(false);
Expand All @@ -231,7 +255,14 @@ function ConnectionScreen({
try {
const outcome = await readerSession.applyCollectionSetup();
onError(connectProblemMessage(outcome));
if (!outcome.ok && !/cancel|abort|supersed/u.test(outcome.problem.code)) {
reportError({ code: "unknown_error" });
}
} catch (reason) {
if (reason instanceof DOMException && reason.name === "AbortError") {
return;
}
reportError({ code: "unknown_error" });
onError(readerErrorMessage(reason, "Reader could not apply the reviewed setup."));
} finally {
setWorking(false);
Expand Down Expand Up @@ -304,6 +335,7 @@ function ConnectionScreen({
The managed service requires an HTTPS Reader origin.
</p>
) : null}
<FeedbackButton />
</ConnectLayout>
);
}
Expand Down
7 changes: 5 additions & 2 deletions apps/reader/src/ConnectedDocument.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { useFeedback } from "@mdbase-dev/ui/feedback";
import { lazy, Suspense, useCallback, useEffect, useMemo, useState, type JSX } from "react";

import { isEpub, isHtml, isPdf } from "./document-media.js";
Expand Down Expand Up @@ -63,6 +64,7 @@ function OpenConnectedDocument({
source,
onSurfaceChange,
}: ConnectedDocumentProps & { readonly descriptor: DocumentDescriptor }): JSX.Element {
const { reportError } = useFeedback();
const [state, setState] = useState<OpenDocumentState>({ status: "opening" });
const [attempt, setAttempt] = useState(0);
const stableDescriptor = useMemo(
Expand Down Expand Up @@ -99,7 +101,8 @@ function OpenConnectedDocument({
}
})
.catch((reason: unknown) => {
if (active) {
if (active && !controller.signal.aborted) {
reportError({ code: "source_open_failed" });
setState({ status: "error", message: message(reason) });
}
});
Expand All @@ -110,7 +113,7 @@ function OpenConnectedDocument({
void opened.close();
}
};
}, [attempt, repository, source.collectionId, stableDescriptor]);
}, [attempt, repository, source.collectionId, stableDescriptor, reportError]);

useEffect(() => () => onSurfaceChange(null), [onSurfaceChange]);
const handle = state.status === "open" ? state.handle : null;
Expand Down
10 changes: 9 additions & 1 deletion apps/reader/src/DocumentRendererStage.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { JSX, ReactNode } from "react";
import { FeedbackButton, useFeedback } from "@mdbase-dev/ui/feedback";
import { useEffect, type JSX, type ReactNode } from "react";

export type RendererState =
{ readonly status: "opening" | "ready" } | { readonly status: "error"; readonly message: string };
Expand All @@ -14,6 +15,12 @@ export function RendererStage({
readonly errorName: string;
readonly children: ReactNode;
}): JSX.Element {
const { reportError } = useFeedback();
useEffect(() => {
if (state.status === "error") {
reportError({ code: "preview_failed" });
}
}, [state.status, reportError]);
return (
<div className="document-renderer-stage">
{children}
Expand Down Expand Up @@ -49,6 +56,7 @@ export function DocumentMessage({
role={tone === "error" ? "alert" : "status"}
>
<span>{label}</span>
{tone === "error" ? <FeedbackButton topic="problem" /> : null}
{action ? (
<button type="button" onClick={action.run}>
{action.label}
Expand Down
32 changes: 32 additions & 0 deletions apps/reader/src/FeedbackRoot.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { FeedbackButton } from "@mdbase-dev/ui/feedback";
import { renderToStaticMarkup } from "react-dom/server";
import { afterEach, expect, it, vi } from "vitest";

import { FeedbackRoot } from "./FeedbackRoot.js";

afterEach(() => vi.unstubAllEnvs());

it("hides unconfigured feedback without hiding the Reader shell", () => {
vi.stubEnv("VITE_MDBASE_FEEDBACK_URL", "");
const markup = renderToStaticMarkup(
<FeedbackRoot>
<p>Reader shell</p>
<FeedbackButton />
</FeedbackRoot>,
);
expect(markup).toContain("Reader shell");
expect(markup).not.toContain("Send feedback");
});

it("uses the shared entry and excludes unsafe destinations", () => {
for (const endpoint of ["https://feedback.example/v1/feedback", "javascript:alert(1)"]) {
vi.stubEnv("VITE_MDBASE_FEEDBACK_URL", endpoint);
const markup = renderToStaticMarkup(
<FeedbackRoot>
<FeedbackButton />
</FeedbackRoot>,
);
expect(markup.includes("Send feedback")).toBe(endpoint.startsWith("https:"));
expect(markup).not.toContain("HELP US MAKE IT BETTER");
}
});
43 changes: 43 additions & 0 deletions apps/reader/src/FeedbackRoot.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import {
FeedbackProvider,
feedbackApplication,
resolveFeedbackEndpoint,
} from "@mdbase-dev/ui/feedback";
import { createContext, useContext, useLayoutEffect, useState, type ReactNode } from "react";

interface FeedbackContextValue {
readonly view: "connection" | "library" | "document";
readonly collectionName?: string;
}
const connection: FeedbackContextValue = { view: "connection" };
const Context = createContext<(value: FeedbackContextValue) => void>(() => undefined);

/** Keep the draft above connection and workspace lifetimes; never pass record identities. */
export function FeedbackRoot({ children }: { readonly children: ReactNode }): ReactNode {
const [context, setContext] = useState(connection);
return (
<Context.Provider value={setContext}>
<FeedbackProvider
endpoint={resolveFeedbackEndpoint(import.meta.env.VITE_MDBASE_FEEDBACK_URL)}
turnstileSiteKey={import.meta.env.VITE_MDBASE_FEEDBACK_TURNSTILE_SITE_KEY ?? null}
application={feedbackApplication(
"mdbase reader",
context.view,
import.meta.env.VITE_MDBASE_READER_BUILD_ID,
import.meta.env.VITE_MDBASE_ENV ?? (import.meta.env.DEV ? "development" : "production"),
)}
{...(context.collectionName ? { collectionName: context.collectionName } : {})}
>
{children}
</FeedbackProvider>
</Context.Provider>
);
}

export function useReaderFeedbackContext(documentOpen: boolean, collectionName: string): void {
const setContext = useContext(Context);
useLayoutEffect(() => {
setContext({ view: documentOpen ? "document" : "library", collectionName });
return () => setContext(connection);
}, [documentOpen, collectionName, setContext]);
}
3 changes: 3 additions & 0 deletions apps/reader/src/LibraryRowMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,9 @@ function useDismiss(
}
};
const onKeyDown = (event: KeyboardEvent): void => {
if (event.target instanceof Element && event.target.closest("dialog[open]")) {
return;
}
if (event.key === "Escape") {
event.stopPropagation();
dismiss();
Expand Down
Loading