Skip to content
Open
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
3 changes: 1 addition & 2 deletions .prettierignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,8 @@ dist/
coverage/
apps/capacitor/android/
apps/capacitor/ios/
# Exact reviewed shared helper and canonical original custody; NEVER rewrite bytes.
# Exact reviewed shared helper; NEVER rewrite bytes.
scripts/lib/native-publish-bytes.mjs
packages/connect/vendor/native-release/
artifacts/
# Raw signed trust assets: exact hashes are protected environment inputs.
config/next-trust/
16 changes: 10 additions & 6 deletions apps/reader/scripts/audit-annotation-workbench.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,12 @@ export async function auditAnnotationWorkbench(page, { screenshot, open, blockWr
.filter({ has: page.locator(".dv-default-tab-content", { hasText: "Annotations —" }) });
const panelId = await tab.getAttribute("data-panel-id");
const workbench = page.locator(`[data-session-id="${panelId}"]`);
// Promotion deliberately closes the native sidebar and releases its editor lease.
// The memory-only buffer survives; there is no mounted writer to transfer from yet.
// Promotion hides the retained sidebar. Explicitly transfer its editor lease
// to the workbench; the same unsaved buffer must survive that transfer.
await expect(tools).toHaveCount(0);
await workbench
.locator(`[data-annotation-id="${annotationId}"]`)
.getByRole("button", { name: "Resume edits", exact: true })
.getByRole("button", { name: "Edit here", exact: true })
.click();
const otherEditor = workbench.getByRole("textbox", { name: "Comment" });
await expect(otherEditor).toHaveValue(/Dock-safe annotation draft/u);
Expand All @@ -48,8 +48,12 @@ export async function auditAnnotationWorkbench(page, { screenshot, open, blockWr
await tab.click();
if (!(await tools.isVisible()))
await page.getByRole("button", { name: "Toggle Notes panel" }).click();
// Reopening the sidebar remounts its locally selected editor. Establish the workbench's
// ownership explicitly before testing transfer back, rather than assuming a hidden lease.
// Reopening the retained sidebar does not steal the workbench's lease.
await expect(otherEditor).toHaveValue(/Dock-safe annotation draft/u);
await tools
.locator(`[data-annotation-id="${annotationId}"]`)
.getByRole("button", { name: "Edit here", exact: true })
.click();
await expect(editor).toHaveValue(/Dock-safe annotation draft/u);
await expect(otherEditor).toHaveCount(0);
await workbench
Expand Down Expand Up @@ -99,7 +103,7 @@ export async function auditAnnotationWorkbench(page, { screenshot, open, blockWr
await page.reload();
await expect(tools.locator(".annotation-card")).toHaveCount(1);
return [
"Annotation promotion resumes an uncommitted buffer after the sidebar closes; Edit here transfers the single editor back after docking",
"Annotation promotion preserves an uncommitted buffer while the sidebar is hidden; Edit here transfers the single editor after docking",
"Linked note insertion, deletion warning, cancellation, and deletion persistence work",
];
}
116 changes: 111 additions & 5 deletions apps/reader/scripts/audit-annotations.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -139,9 +139,9 @@ export async function auditAnnotations(page, { screenshot, blockWrites }) {
await expect(tools.getByText("No matching annotations", { exact: true })).toBeVisible();
await tools.getByRole("button", { name: "Clear filters", exact: true }).click();
await tools.getByLabel("Filter annotations", { exact: true }).click();
await tools.getByRole("radio", { name: /^With comments/u }).click();
await page.getByRole("radio", { name: /^With comments/u }).click();
await expect(tools.locator(".annotation-card")).toHaveCount(2);
await tools.getByRole("radio", { name: "Newest first", exact: true }).click();
await page.getByRole("radio", { name: "Newest first", exact: true }).click();
await tools.getByLabel("Filter annotations (changed)", { exact: true }).click();
await screenshot("annotation-browsing-desktop");
await tools.getByRole("button", { name: "Show in document" }).first().click();
Expand Down Expand Up @@ -194,12 +194,12 @@ export async function auditAnnotations(page, { screenshot, blockWrites }) {

/** The library's annotations view lists annotations across sources and opens one in place. */
async function auditAnnotationOverview(page, screenshot) {
await page.getByRole("tab", { name: "Library" }).click();
await page.getByRole("tab", { name: "All sources" }).click();
await page.getByRole("button", { name: "Annotations", exact: true }).click();
const grid = page.getByRole("grid", { name: "Annotations" });
const rows = grid.locator(".library-table-body .library-table-row");
await expect.poll(() => rows.count()).toBeGreaterThan(1);
await auditAnnotationColumns(page, grid);
await auditAnnotationColumns(page, grid, screenshot);
await page.getByRole("textbox", { name: "Search annotations" }).fill("Patient attention and");
await expect(rows.filter({ hasText: "Research 0021" }).first()).toBeVisible();
await auditAnnotationViewSaving(page, grid, rows);
Expand All @@ -212,7 +212,7 @@ async function auditAnnotationOverview(page, screenshot) {
}

/** Headers sort, resize from the keyboard, and hide or restore columns, as in the sources table. */
async function auditAnnotationColumns(page, grid) {
async function auditAnnotationColumns(page, grid, screenshot) {
const sourceHeader = grid.getByRole("columnheader").filter({ hasText: /^Source/ });
await grid.getByRole("button", { name: "Source", exact: true }).click();
await expect(sourceHeader).toHaveAttribute("aria-sort", "ascending");
Expand All @@ -224,6 +224,7 @@ async function auditAnnotationColumns(page, grid) {
await expect(resizer).toHaveAttribute("aria-valuenow", String(before + 32));
const headers = grid.getByRole("columnheader");
const count = await headers.count();
await auditColumnMenuPlacement(page, grid, screenshot);
await grid.getByLabel("Type column options").click();
await page.getByRole("button", { name: "Hide column" }).click();
await expect(headers).toHaveCount(count - 1);
Expand All @@ -232,6 +233,111 @@ async function auditAnnotationColumns(page, grid) {
await expect(headers).toHaveCount(count);
}

async function auditColumnMenuPlacement(page, grid, screenshot) {
const viewport = page.viewportSize();
const frame = grid.locator("..");
const originalStyle = await frame.getAttribute("style");
const notes = page.getByRole("button", { name: "Toggle Notes panel", exact: true });
const notesOpen = (await notes.getAttribute("aria-pressed")) === "true";
if (notesOpen) await notes.click();
try {
const width = 1440;
await page.setViewportSize({ width, height: 844 });
for (const edge of [false, true]) {
await frame.evaluate((element, edge) => {
if (edge) {
element.style.cssText =
"position: fixed; right: 0; bottom: 0; width: min(380px, 100vw); height: 120px; z-index: 100";
} else {
element.removeAttribute("style");
}
}, edge);
for (const column of ["Passage", "Type"]) {
const trigger = grid.getByLabel(`${column} column options`);
await trigger.click();
const panel = page.locator(".reader-menu-panel:visible");
const action = panel.getByRole("button", {
name: column === "Passage" ? "Widen column" : "Hide column",
});
await expect(action).toBeVisible();
await action.click({ trial: true });
const bounds = await panel.boundingBox();
const anchor = await trigger.boundingBox();
if (edge) {
const tableBounds = await frame.boundingBox();
expect(tableBounds.x + tableBounds.width).toBe(width);
expect(tableBounds.y + tableBounds.height).toBe(844);
}
expect(bounds.x).toBeGreaterThanOrEqual(8);
expect(bounds.y).toBeGreaterThanOrEqual(8);
expect(bounds.x + bounds.width).toBeLessThanOrEqual(width - 8);
expect(bounds.y + bounds.height).toBeLessThanOrEqual(836);
if (edge) {
expect(bounds.y + bounds.height).toBeLessThanOrEqual(anchor.y - 5);
if (column === "Type") await screenshot(`column-menu-edge-${width}`);
}
const expectedLeft =
column === "Passage" ? anchor.x : anchor.x + anchor.width - bounds.width;
expect(
Math.abs(bounds.x - Math.max(8, Math.min(expectedLeft, width - bounds.width - 8))),
).toBeLessThanOrEqual(1);
await page.keyboard.press("Tab");
await expect(panel.locator("button:not(:disabled)").first()).toBeFocused();
await page.keyboard.press("Shift+Tab");
await expect(trigger).toBeFocused();
await page.keyboard.press("Tab");
await page.keyboard.press("Escape");
await expect(panel).toBeHidden();
await expect(trigger).toBeFocused();
await trigger.click();
await page.getByRole("button", { name: "Annotations", exact: true }).click();
await expect(panel).toBeHidden();
if (column === "Type") {
const headers = grid.getByRole("columnheader");
const count = await headers.count();
await trigger.click();
await action.click();
await expect(headers).toHaveCount(count - 1);
await grid.getByLabel("Add column").click();
await page.getByRole("button", { name: "Type", exact: true }).click();
await expect(headers).toHaveCount(count);
}
}
}
await auditPhoneMenuPlacement(page, grid, screenshot);
} finally {
await frame.evaluate((element, style) => {
if (style === null) element.removeAttribute("style");
else element.setAttribute("style", style);
}, originalStyle);
await page.setViewportSize(viewport);
if (notesOpen) await notes.click();
}
}

async function auditPhoneMenuPlacement(page, grid, screenshot) {
await page.setViewportSize({ width: 390, height: 844 });
await expect(grid.getByLabel("Passage column options")).toBeHidden();
const trigger = page.getByLabel(/^Annotation view options/);
await trigger.click();
const panel = page.locator(".reader-menu-panel:visible");
const refresh = panel.getByRole("button", { name: "Refresh annotations", exact: true });
await refresh.click({ trial: true });
const bounds = await panel.boundingBox();
expect(bounds.x).toBeGreaterThanOrEqual(8);
expect(bounds.y).toBeGreaterThanOrEqual(8);
expect(bounds.x + bounds.width).toBeLessThanOrEqual(382);
expect(bounds.y + bounds.height).toBeLessThanOrEqual(836);
await screenshot("menu-phone-390");
await refresh.focus();
await page.keyboard.press("Escape");
await expect(panel).toBeHidden();
await expect(trigger).toBeFocused();
await trigger.click();
await refresh.click();
await expect(panel).toBeHidden();
}

/** A filtered annotations view saves as an mdbase view and reopens in annotations mode. */
async function auditAnnotationViewSaving(page, grid, rows) {
const options = page.getByLabel(/^Annotation view options/);
Expand Down
24 changes: 19 additions & 5 deletions apps/reader/scripts/audit-dockview.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ export async function auditDockview(page, { screenshot, blockWrites }) {
);
};
const drag = async (target, point) => {
await expect(target).toBeVisible();
const rect = await target.boundingBox();
if (!rect) throw new Error("Drag source is not visible");
await page.mouse.move(rect.x + Math.min(rect.width / 2, 65), rect.y + rect.height / 2);
Expand Down Expand Up @@ -75,9 +76,9 @@ export async function auditDockview(page, { screenshot, blockWrites }) {
const secondId = await tab("[test] Research 0002").last().getAttribute("data-panel-id");
await expect.poll(async () => panelGroup(await state(), secondId)).not.toBeNull();
const originalGroup = panelGroup(await state(), secondId);
// The original central group still contains the Library tab. Drop on its tab strip.
const library = tab("Library").filter({
has: page.locator(".dv-default-tab-content", { hasText: /^Library$/ }),
// The original central group still contains the All sources tab. Drop on its tab strip.
const library = tab("All sources").filter({
has: page.locator(".dv-default-tab-content", { hasText: /^All sources$/ }),
});
const destinationTab =
panelGroup(await state(), firstId) === originalGroup ? library : tab("[test] Research 0001");
Expand Down Expand Up @@ -187,8 +188,19 @@ export async function auditDockview(page, { screenshot, blockWrites }) {
"Unstored note survives docking and cancelled close; local recovery resumes after storage returns",
);

const navigationLayout = JSON.stringify((await state()).layout.grid.root);
const noteBounds = await page.locator(`[data-session-id="${noteId}"]`).boundingBox();
const navigatorActions = page
.locator(".dv-groupview")
.filter({ has: sideTab("reader:navigator") })
.getByRole("button", { name: "Pane actions", exact: true });
await navigatorActions.focus();
await page.keyboard.press("Enter");
await page
.getByRole("group", { name: "Pane actions" })
.getByRole("button", { name: "Merge into another reading pane", exact: true })
.click();
await expect(sideTab("reader:navigator")).toBeVisible();
const navigationLayout = JSON.stringify((await state()).layout.grid.root);
await drag(sideTab("reader:navigator"), {
x: noteBounds.x + noteBounds.width / 2,
y: noteBounds.y + noteBounds.height - 20,
Expand All @@ -203,8 +215,10 @@ export async function auditDockview(page, { screenshot, blockWrites }) {
completed.push("Sources sidebar also docks to document edges");

// Native touch input exercises Dockview's pointer strategy, not DOM-dispatched fake drags.
await menu(noteTab, "Move to Sources");
await expect(sideTab("reader:navigator")).toBeVisible();
const touchSource = await sideTab("reader:navigator").boundingBox();
const touchTarget = await page.locator(`[data-panel-id="${noteId}"]`).boundingBox();
const touchTarget = await page.locator(`[data-panel-id="${firstId}"]`).boundingBox();
const touchStart = { x: touchSource.x + 55, y: touchSource.y + touchSource.height / 2 };
const touchEnd = { x: touchTarget.x + 70, y: touchTarget.y + touchTarget.height / 2 };
const beforeTouchGroup = panelGroup(await state(), "reader:navigator");
Expand Down
5 changes: 3 additions & 2 deletions apps/reader/scripts/audit-reader.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -307,12 +307,13 @@ try {
.locator('.library-table[aria-label="Sources"]')
.getByRole("columnheader", { name: /^Published/u }),
).toHaveAttribute("aria-sort", "descending");
await page.getByLabel("Library actions", { exact: true }).click();
await page.getByLabel("Library actions", { exact: true }).and(page.locator("summary")).click();
await page.getByRole("button", { name: "Reset columns and layout" }).click();
await expect.poll(titleWidth).toBe(before);
completed.push("Column widths and header sorting persist on this device and reset cleanly");

await page.getByRole("button", { name: "Continue reading", exact: false }).first().click();
// Fresh fixtures have no saved reading position, so open the source through search.
await openSource(0);
await expect(page.locator("iframe.html-viewer")).toBeVisible({ timeout: 30000 });
await page.getByLabel("More document actions", { exact: true }).click();
await page.getByRole("button", { name: "Source note", exact: true }).click();
Expand Down
6 changes: 3 additions & 3 deletions apps/reader/scripts/audit-reading-workspace.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,9 @@ export async function auditReadingWorkspace(page, { screenshot }) {
const frame = page.frameLocator("iframe.html-viewer:visible");
await expect(frame.locator("#p0")).toBeVisible({ timeout: 30000 });
await expect(navigator).toBeHidden();
await page.getByRole("tab", { name: "Library" }).click();
await page.getByRole("tab", { name: "All sources" }).click();
await expect(navigator).toBeVisible();
await page.getByRole("tab", { name: /Research 0001/u }).click();
await page.getByRole("tab", { name: "[test] Research 0001", exact: true }).click();
await expect(navigator).toBeHidden();
completed.push("Sources sidebar hides while reading and returns with the library");

Expand Down Expand Up @@ -72,7 +72,7 @@ export async function auditReadingWorkspace(page, { screenshot }) {
await screenshot("margin-marker");
completed.push("A new highlight gets a margin marker that selects its card");

await page.getByRole("tab", { name: "Library" }).click();
await page.getByRole("tab", { name: "All sources" }).click();
await expect(page.getByRole("columnheader", { name: "Annotations" })).toBeVisible();
completed.push("The library counts annotations per source");
return completed;
Expand Down
14 changes: 10 additions & 4 deletions apps/reader/scripts/feedback-browser-test.mjs
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
// Sample-data acceptance only. Intercepts every feedback POST; never sends mail.
/* global window, document, requestAnimationFrame, getComputedStyle */
import assert from "node:assert/strict";
import { writeFile } from "node:fs/promises";
import { mkdtemp, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
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 directory = await mkdtemp(join(tmpdir(), "reader-feedback-"));
const browser = await chromium.launch();
const motion = [];
async function trace(page, action) {
Expand Down Expand Up @@ -108,7 +111,9 @@ try {
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 page.screenshot({
path: join(directory, `shared-feedback-reader-form-${width}.png`),
});
await message.press("Control+k");
assert.equal(await page.locator("dialog[open]").count(), 1);
await message.press("Escape");
Expand All @@ -129,13 +134,14 @@ try {
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 page.screenshot({ path: join(directory, `shared-feedback-reader-${width}.png`) });
await context.close();
}
}
}
await writeFile("/tmp/reader-feedback-motion.json", JSON.stringify(motion, null, 2));
await writeFile(join(directory, "reader-feedback-motion.json"), JSON.stringify(motion, null, 2));
console.log("Reader feedback desktop/mobile acceptance passed; all delivery intercepted.");
console.log("Evidence: " + directory);
} finally {
await browser.close();
}
8 changes: 4 additions & 4 deletions apps/reader/scripts/reader-sdk.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { readerDeployments } from "./deployment-environment.mjs";

const root = fileURLToPath(new URL("../../../", import.meta.url));
const vendor = "packages/connect/vendor/";
const pins = {
export const readerSdkPins = {
lab: ["@mdbase-dev/sdk-lab", "sdk-lab-beta188.json"],
staging: ["@mdbase-dev/sdk-staging", "sdk-staging-beta188.json"],
production: ["@mdbase-dev/sdk", "sdk-production-beta210.json"],
Expand All @@ -23,7 +23,7 @@ export function readerSdkTarget(environment) {
environment.MDBASE_READER_DEPLOY_TARGET,
].filter((value) => value !== undefined);
const target = selectors[0] ?? "production";
assert.ok(Object.hasOwn(pins, target), "Unsupported Reader SDK environment: " + target);
assert.ok(Object.hasOwn(readerSdkPins, target), "Unsupported Reader SDK environment: " + target);
assert.ok(
selectors.every((value) => value === target),
"Conflicting Reader SDK environments",
Expand All @@ -40,8 +40,8 @@ export function readerSdkTarget(environment) {
* originals side by side. The App CD publisher independently authenticates the
* SDK producer and target trust before execution. */
export async function verifiedReaderSdk(target, repoRoot = root, app = "apps/reader") {
assert.ok(Object.hasOwn(pins, target), "Unsupported Reader SDK environment: " + target);
let [name, manifest] = pins[target];
assert.ok(Object.hasOwn(readerSdkPins, target), "Unsupported Reader SDK environment: " + target);
let [name, manifest] = readerSdkPins[target];
let pin = JSON.parse(await readFile(join(repoRoot, vendor, manifest), "utf8"));
let archive = join(repoRoot, vendor, pin.artifact);
// App CD has authenticated this immutable plan before install/build. Normal
Expand Down
Loading