Files
backnotprop__plannotator/packages/editor/App.permissionModeSetup.test.tsx
Michael Ramos 8e88dcec8c fix: v0.27.2 pre-release QA batch (mobile TOC, dialog bounds, seed guard) (#1311)
* fix(plan): make the compact TOC scroll the document again

The compact navigator overlay rendered outside App's ScrollViewportProvider,
so the TableOfContents it hosts resolved a null viewport and every "jump to
heading" tap was a silent no-op on phones. The provider is context-only, so
hoisting it above the overlay fixes the lookup without touching desktop DOM
structure or order.

* fix(plan): scope the permission-mode chooser to plan review and bound its card

The one-time chooser fired in every non-goal-setup Claude Code session, so
annotate, annotate-last, annotate-folder and archive reviewers got a blocking
dialog about what happens after plan approval. Gate it on plan review, which
is the absence of a mode field in the /api/plan payload.

The card itself was hand-rolled with no height cap and no internal scroll, so
on a short landscape phone it overflowed both edges of a modal that has no
dismiss control. Give it the same bounded shell the sibling one-time dialogs
use: safe-area padding, a visible-viewport max height, and the option list as
the only scrolling region. Content and cookie behavior are unchanged.

* fix(review): never seed Tree over a persisted panel view

The first-run initializer gated only on the setup-seen cookie, but sessions
that never reach it (non-git, workspace, PR, no since-base) still let Settings
persist a panel view. A reviewer could hold an explicit Git status choice with
"seen" unset, and the next plain git session seeded Tree over it. Treat a
persisted view as the decision: consume the one-time setup and write nothing.

* fix(comments): give the geometry-forced composer a working Escape

When the anchor has no room the position tracker forces dialog mode. On a
fine-pointer viewport Escape took the collapse branch, the tracker instantly
re-forced the dialog, and the keystroke was eaten; the Collapse button bounced
the same way. Track forced expansion separately from the preferred kind: in
that state Escape closes (draft-preserving) and Collapse is hidden, because
collapsing is geometrically impossible.

* fix(review): stop the compact Editor tab editing the desktop diff style

The dock's Split/Unified control returns null under the compact touch layout,
but the Settings copy of it kept rendering while the phone showed the
session-only unified diff. It looked dead and silently rewrote the persisted
desktop preference. Hide it on compact and state what the session is doing;
the prop defaults to false, so the plan editor and desktop are untouched.

* fix(portal): give the share portal the mobile app shell

The portal mounts the same plan editor App as the hook but kept the pre-mobile
entry document: no viewport-fit=cover (so every safe-area token was inert) and
a min-h-screen body without the shell's scroll ownership. Mirror the hook's
body class, root class, and viewport meta, and extend the entry-asset pin to
cover the portal alongside them.

* fix(plan): keep compact overlays out of the printed document

The compact plan stage and the compact navigator are full-viewport transient
surfaces with no print-hide marker, so printing on a touch device with
Annotations, Ask AI, Versions or Archive open clipped the document behind
them. Mark both with data-print-hide, which print.css already hides. The
desktop rail is untouched.

* fix(plan): give the selection toolbar real touch targets

Copy / Delete / Comment / quick label / looks-good / Cancel measured 28x28
with 2px gaps on a phone because the toolbar never got the touch-target
markers the rest of the stack uses. Stamp them on its buttons and add a
compact-scoped gap so adjacent destructive and comment actions are not a
mis-tap apart. Both are inert outside the compact scope, so desktop geometry
is unchanged.

* docs: keep the new QA-batch comments free of em dashes
2026-08-13 11:35:00 -07:00

152 lines
4.9 KiB
TypeScript

/**
* Permission-mode first-run scope (DOM_TESTS=1)
*
* The permission mode decides what happens to Claude Code's permissions after
* a plan is APPROVED, so the one-time chooser belongs to plan review only. It
* used to fire in every non-goal-setup Claude Code session, which put an
* unrelated blocking dialog in front of annotate and archive reviewers.
*/
import { afterAll, afterEach, describe, expect, test } from "bun:test";
import React, { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import {
resetStorageBackend,
setStorageBackend,
type StorageBackend,
} from "@plannotator/ui/utils/storage";
const hasDom = typeof document !== "undefined";
const appModule = hasDom ? await import("./App") : null;
const App = appModule?.default as typeof import("./App")["default"];
const originalFetch = globalThis.fetch;
const originalEventSource = globalThis.EventSource;
// Deliberate copy pin: the dialog's own heading is how this test identifies it.
const DIALOG_HEADING = "New: Permission Mode Preservation";
const memory = new Map<string, string>();
const memoryBackend: StorageBackend = {
getItem: (key) => memory.get(key) ?? null,
setItem: (key, value) => void memory.set(key, value),
removeItem: (key) => void memory.delete(key),
};
function seedAnnouncementsSeen(): void {
memory.set("plannotator-look-feel-announcement-seen", "2");
memory.set("plannotator-vim-mode-announcement-seen", "2");
memory.set("plannotator-plan-ai-announcement-seen", "1");
}
class SilentEventSource {
static readonly CONNECTING = 0;
static readonly OPEN = 1;
static readonly CLOSED = 2;
readonly CONNECTING = 0;
readonly OPEN = 1;
readonly CLOSED = 2;
readonly readyState = SilentEventSource.OPEN;
readonly url: string;
readonly withCredentials = false;
onerror: ((event: Event) => void) | null = null;
onmessage: ((event: MessageEvent) => void) | null = null;
onopen: ((event: Event) => void) | null = null;
constructor(url: string | URL) {
this.url = String(url);
}
addEventListener(): void {}
close(): void {}
dispatchEvent(): boolean { return true; }
removeEventListener(): void {}
}
function fetchForPlan(plan: Record<string, unknown>): typeof fetch {
return async (input) => {
const rawUrl = input instanceof Request ? input.url : String(input);
if (rawUrl.startsWith("https://api.github.com/")) return new Response(null, { status: 404 });
const url = new URL(rawUrl, "http://localhost");
if (url.pathname === "/api/plan") return Response.json(plan);
if (url.pathname === "/api/archive/plans") return Response.json({ plans: [] });
if (url.pathname === "/api/ai/capabilities") return Response.json({ available: false, providers: [] });
if (url.pathname === "/api/draft") return Response.json({ error: "Not found" }, { status: 404 });
return Response.json({});
};
}
let root: Root | null = null;
let host: HTMLElement | null = null;
async function settle(): Promise<void> {
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0));
});
}
async function mount(plan: Record<string, unknown>): Promise<void> {
setStorageBackend(memoryBackend);
seedAnnouncementsSeen();
globalThis.fetch = fetchForPlan(plan);
// SAFETY: the App only uses EventSource's constructor, handlers, and close.
globalThis.EventSource = SilentEventSource as unknown as typeof EventSource;
host = document.createElement("div");
document.body.appendChild(host);
root = createRoot(host);
await act(async () => { root?.render(<App />); });
for (let attempt = 0; attempt < 10; attempt += 1) await settle();
}
afterEach(async () => {
if (root) await act(async () => root?.unmount());
root = null;
host?.remove();
host = null;
globalThis.fetch = originalFetch;
globalThis.EventSource = originalEventSource;
if (hasDom) document.body.replaceChildren();
memory.clear();
resetStorageBackend();
});
afterAll(() => {
resetStorageBackend();
});
describe.if(hasDom)("permission mode first-run chooser", () => {
test("an unconfigured Claude Code plan review still offers it", async () => {
await mount({ plan: "# Plan\n\nBody.\n", origin: "claude-code", sharingEnabled: false, serverConfig: {} });
expect(document.body.textContent).toContain(DIALOG_HEADING);
});
test("an annotate session never offers it", async () => {
await mount({
plan: "# Notes\n\nBody.\n",
origin: "claude-code",
mode: "annotate",
filePath: "/tmp/notes.md",
sharingEnabled: false,
serverConfig: {},
});
expect(document.body.textContent).not.toContain(DIALOG_HEADING);
});
test("an archive session never offers it", async () => {
await mount({
plan: "",
origin: "claude-code",
mode: "archive",
archivePlans: [],
sharingEnabled: false,
serverConfig: {},
});
expect(document.body.textContent).not.toContain(DIALOG_HEADING);
});
});