mirror of
https://github.com/backnotprop/plannotator.git
synced 2026-09-14 14:17:26 +08:00
1cab9dd9a8
* feat(review): mark files viewed as you scroll past them Reviewers reading the all-files diff top to bottom had to check every file off by hand. Now a file marks itself viewed when the reviewer MOVES ON from it, after its content was actually on screen long enough to have been read. Arriving at a file never marks it; leaving it downward does. - All-files surface: a file marks when the reader scrolls past it (its successor has reached the viewport top, so it genuinely scrolled out above) and has accumulated at least 1000ms as the reported reading file. Dwell is cumulative per diff snapshot, so bouncing between two files still accrues, while a momentum flick to the bottom marks nothing. The last file, which can never scroll out above, marks on reaching the end of the diff. - Single-file panel: opening a file never marks it; navigating away after the same dwell floor does. Keyboard file navigation drives the same panel switches, so keyboard-only parity is automatic. - Collapsed cards never mark. Generated files seed collapsed, so nobody reviews a lockfile by scrolling past its folded header. - Un-viewing a file suppresses auto-view for it until it is marked viewed by hand again. That set rides the review draft as an additive optional field. - Inert inside the Guided Review takeover and on a commit detour, where the files on screen are not the change under review. - A viewed file whose patch changes under a refresh loses its checkmark, but only while auto-view is on, so the off state stays byte-identical to today. - PR sessions batch the marks into one /api/pr-viewed request rather than one per file. The setting is reviewAutoViewed, cookie-only and on by default, with two off switches: Settings > Git and a row in the file-list gear popover. The first time auto-view actually fires, a toast says so and offers Turn off; using either switch consumes that one-time notice. The decision core is pure and clock-injected (utils/autoViewed.ts), the binding is a hook (hooks/useAutoViewed.ts), and AllFilesCodeView only gains one optional emission callback on the rAF path it already runs. No server changes in either runtime. AI-assisted (Claude) under maintainer direction. * fix(review): scope auto-mark-viewed to the transitions it was meant for Four review findings on the auto-mark-viewed branch. Rule 5 fired on EVERY applied diff switch, not just the staleness refresh. The review app funnels every transition through one apply path, so entering the Commits detour (the rail auto-opens HEAD), switching base branch, and toggling hide-whitespace all un-viewed files whose per-path patch text legitimately differs, which contradicts both Rule 4's "a commit detour is inert" and Rule 5's own rationale. The apply path now goes through resolveDiffSwitchUnviews, which requires the caller to opt in (`contentRefresh`) and re-checks the identity of the diff on top of that: same selection, same base, and never a commit-family type on either side. Only the staleness refresh and the post-fetch base refresh opt in. The pure delta resolver is unchanged. A source-level test pins which call sites may opt in, since that is where the guarantee actually lives. The at-bottom branch fired on the mount tick. A diff shorter than the viewport is at-bottom from the very first report, and that report is the mount seed, so the file on screen marked itself about a second later with zero interaction and fired the first-time toast at a motionless page. It now requires a real scroll event on the current file set. Staging a file marked it viewed without clearing auto-view suppression, unlike v, the header button and the tree row, so a file the reviewer un-viewed and later staged stayed permanently off-limits to auto-view. Dwell accrued while the setting was off, so enabling mid-read could mark the current file instantly on time the reviewer spent with the feature deliberately disabled. Disabled is now fully inert: the clock does not accrue, and enabling starts a fresh one rather than replaying the gap. AI-assisted (Claude) under maintainer direction. * chore: refresh pinned guide viewer manifest after merging main
295 lines
12 KiB
TypeScript
295 lines
12 KiB
TypeScript
/**
|
|
* Draft persistence tests for the code-review annotation autosave
|
|
* (useCodeAnnotationDraft), exercising the REAL stack: the actual hook mounted
|
|
* in React on one side, the actual saveDraft/loadDraft/deleteDraft disk layer
|
|
* (packages/shared/draft.ts) on the other, joined by a fetch shim that mirrors
|
|
* the review server's /api/draft pass-through handlers.
|
|
*
|
|
* Regression guard for #948: deleting every annotation must remove the draft
|
|
* from disk (not leave a stale one that the recovery banner re-offers on
|
|
* refresh). Also guards that a fresh, unengaged session never deletes an
|
|
* unrestored draft sitting on disk at mount.
|
|
*
|
|
* Requires DOM_TESTS=1 (happy-dom preload). Run:
|
|
* DOM_TESTS=1 bun test codeAnnotationDraftPersistence
|
|
*/
|
|
import { describe, test, expect, beforeAll, afterAll, afterEach } from 'bun:test';
|
|
import React from 'react';
|
|
import { createRoot, type Root } from 'react-dom/client';
|
|
import { act } from 'react';
|
|
import { mkdtempSync, rmSync } from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
import { useCodeAnnotationDraft } from './hooks/useCodeAnnotationDraft';
|
|
import type { CodeAnnotation } from './types';
|
|
import { saveDraft, loadDraft, deleteDraft, getDraftGeneration } from '../shared/draft';
|
|
|
|
const hasDom = typeof document !== 'undefined';
|
|
|
|
const DRAFT_KEY = 'code-annotation-draft-test';
|
|
const DEBOUNCE_WAIT_MS = 650; // hook debounce is 500ms
|
|
|
|
const ANNOTATION = {
|
|
id: 'a1',
|
|
filePath: 'src/index.ts',
|
|
lineStart: 10,
|
|
lineEnd: 10,
|
|
side: 'new',
|
|
type: 'comment',
|
|
comment: 'fix this',
|
|
originalText: 'const x = 1;',
|
|
} as unknown as CodeAnnotation;
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Real-disk fetch shim (mirrors the review server's /api/draft handlers)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const realFetch = globalThis.fetch;
|
|
let dataDir = '';
|
|
let prevDataDirEnv: string | undefined;
|
|
// Records every /api/draft request so tests can assert on what the hook actually
|
|
// sent (e.g. that an external-annotation clear issued no DELETE).
|
|
const draftCalls: { method: string; url: string }[] = [];
|
|
|
|
function installFetchShim() {
|
|
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
const url = typeof input === 'string' ? input : input.toString();
|
|
if (url.startsWith('/api/draft')) {
|
|
const parsedUrl = new URL(url, 'http://localhost');
|
|
const method = init?.method ?? 'GET';
|
|
draftCalls.push({ method, url });
|
|
if (method === 'GET') {
|
|
const data = loadDraft(DRAFT_KEY);
|
|
return data
|
|
? new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } })
|
|
: new Response(
|
|
JSON.stringify({
|
|
found: false,
|
|
...(getDraftGeneration(DRAFT_KEY) !== null ? { draftGeneration: getDraftGeneration(DRAFT_KEY) } : {}),
|
|
}),
|
|
{ status: 404, headers: { 'Content-Type': 'application/json' } },
|
|
);
|
|
}
|
|
if (method === 'POST') {
|
|
saveDraft(DRAFT_KEY, JSON.parse(String(init?.body)));
|
|
return new Response(JSON.stringify({ ok: true }), { status: 200 });
|
|
}
|
|
if (method === 'DELETE') {
|
|
const rawGeneration = parsedUrl.searchParams.get('generation');
|
|
const generation = rawGeneration === null ? undefined : Number(rawGeneration);
|
|
deleteDraft(DRAFT_KEY, Number.isFinite(generation) && generation >= 0 ? generation : undefined);
|
|
return new Response(JSON.stringify({ ok: true }), { status: 200 });
|
|
}
|
|
}
|
|
return new Response('Not found', { status: 404 });
|
|
}) as typeof fetch;
|
|
}
|
|
|
|
beforeAll(() => {
|
|
if (!hasDom) return;
|
|
dataDir = mkdtempSync(join(tmpdir(), 'plannotator-code-draft-test-'));
|
|
prevDataDirEnv = process.env.PLANNOTATOR_DATA_DIR;
|
|
process.env.PLANNOTATOR_DATA_DIR = dataDir;
|
|
installFetchShim();
|
|
});
|
|
|
|
afterAll(() => {
|
|
if (!hasDom) return;
|
|
globalThis.fetch = realFetch;
|
|
if (prevDataDirEnv === undefined) delete process.env.PLANNOTATOR_DATA_DIR;
|
|
else process.env.PLANNOTATOR_DATA_DIR = prevDataDirEnv;
|
|
rmSync(dataDir, { recursive: true, force: true });
|
|
});
|
|
|
|
afterEach(() => {
|
|
if (!hasDom) return;
|
|
deleteDraft(DRAFT_KEY);
|
|
draftCalls.length = 0;
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Hook harness (the review hook is reactive — it autosaves on prop change)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
type HookOptions = Parameters<typeof useCodeAnnotationDraft>[0];
|
|
type HookResult = ReturnType<typeof useCodeAnnotationDraft>;
|
|
|
|
const options = (over: Partial<HookOptions> = {}): HookOptions => ({
|
|
annotations: [],
|
|
viewedFiles: new Set<string>(),
|
|
isApiMode: true,
|
|
submitted: false,
|
|
...over,
|
|
});
|
|
|
|
function Harness({ opts, resultRef }: { opts: HookOptions; resultRef: { current: HookResult | null } }) {
|
|
resultRef.current = useCodeAnnotationDraft(opts);
|
|
return null;
|
|
}
|
|
|
|
interface Session {
|
|
result: { current: HookResult | null };
|
|
rerender: (opts: HookOptions) => Promise<void>;
|
|
unmount: () => Promise<void>;
|
|
}
|
|
|
|
const tick = (ms: number) => act(async () => new Promise((r) => setTimeout(r, ms)));
|
|
|
|
async function mountSession(opts: HookOptions): Promise<Session> {
|
|
const host = document.createElement('div');
|
|
document.body.appendChild(host);
|
|
const resultRef: { current: HookResult | null } = { current: null };
|
|
let root: Root;
|
|
await act(async () => {
|
|
root = createRoot(host);
|
|
root.render(<Harness opts={opts} resultRef={resultRef} />);
|
|
});
|
|
await tick(0); // let the on-mount GET .then chain settle (sets hasMountedRef)
|
|
return {
|
|
result: resultRef,
|
|
rerender: async (next: HookOptions) => {
|
|
await act(async () => {
|
|
root.render(<Harness opts={next} resultRef={resultRef} />);
|
|
});
|
|
},
|
|
unmount: async () => {
|
|
await act(async () => {
|
|
root.unmount();
|
|
});
|
|
host.remove();
|
|
},
|
|
};
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('code-review annotation draft persistence', () => {
|
|
test.skipIf(!hasDom)('deleting every annotation removes the draft so it does not resurrect (#948)', async () => {
|
|
// Session 1: mount empty (lets the on-mount GET settle so hasMountedRef is
|
|
// set), then the user adds an annotation -> it autosaves to disk.
|
|
const s1 = await mountSession(options());
|
|
await s1.rerender(options({ annotations: [ANNOTATION] }));
|
|
await tick(DEBOUNCE_WAIT_MS);
|
|
const afterSave = loadDraft(DRAFT_KEY) as { codeAnnotations?: unknown[] } | null;
|
|
expect(afterSave).not.toBeNull();
|
|
expect(afterSave!.codeAnnotations).toHaveLength(1);
|
|
|
|
// User deletes the last annotation -> list empty. Pre-fix the autosave
|
|
// skipped, leaving the stale draft on disk; now it must delete it.
|
|
await s1.rerender(options({ annotations: [] }));
|
|
await tick(DEBOUNCE_WAIT_MS);
|
|
expect(loadDraft(DRAFT_KEY)).toBeNull();
|
|
await s1.unmount();
|
|
|
|
// Session 2: fresh page -> no draft on disk -> no recovery banner.
|
|
const s2 = await mountSession(options());
|
|
expect(s2.result.current!.draftBanner).toBeNull();
|
|
await s2.unmount();
|
|
});
|
|
|
|
test.skipIf(!hasDom)('external (source-tagged) annotation churn does not arm engagement or delete the draft', async () => {
|
|
// External-tool annotations (SSE-sourced) arrive via allAnnotations without
|
|
// user action. They must NOT count as "the user had content", or clearing
|
|
// them would fire a tombstone delete. (Regression guard for the engagement
|
|
// signal being keyed on user-authored annotations only.)
|
|
const EXTERNAL = { ...(ANNOTATION as object), id: 'ext1', source: 'eslint' } as unknown as CodeAnnotation;
|
|
|
|
const s = await mountSession(options());
|
|
// External annotation appears, then disappears — pure external churn.
|
|
await s.rerender(options({ annotations: [EXTERNAL] }));
|
|
await tick(DEBOUNCE_WAIT_MS);
|
|
await s.rerender(options({ annotations: [] }));
|
|
await tick(DEBOUNCE_WAIT_MS);
|
|
|
|
// Engagement is keyed on user-authored annotations, so the external clear must
|
|
// NOT have issued an empty-state DELETE. (If external annotations armed the
|
|
// flag, a draft holding real user work in an interleaved session could be
|
|
// wiped.) Assert directly on the wire: no DELETE was sent.
|
|
expect(draftCalls.filter((c) => c.method === 'DELETE')).toHaveLength(0);
|
|
await s.unmount();
|
|
});
|
|
|
|
test.skipIf(!hasDom)('a fresh, unengaged session does not delete an unrestored draft on disk', async () => {
|
|
// A draft from a previous session sits on disk.
|
|
saveDraft(DRAFT_KEY, {
|
|
codeAnnotations: [ANNOTATION],
|
|
viewedFiles: [],
|
|
draftGeneration: 1,
|
|
ts: Date.now(),
|
|
});
|
|
|
|
// Mount fresh: the user has NOT restored, so annotations are empty. The
|
|
// empty-state autosave must NOT fire a delete (the guard keys on having had
|
|
// annotations this session, which we haven't).
|
|
const s = await mountSession(options());
|
|
expect(s.result.current!.draftBanner).toEqual({ count: 1, viewedCount: 0, timeAgo: 'just now' });
|
|
|
|
// Re-render still-empty (new Set identity) to actually run the autosave
|
|
// effect through the guard path, then wait past the debounce.
|
|
await s.rerender(options({ viewedFiles: new Set<string>() }));
|
|
await tick(DEBOUNCE_WAIT_MS);
|
|
|
|
// The unrestored draft survives — the banner can still offer it.
|
|
const stillThere = loadDraft(DRAFT_KEY) as { codeAnnotations?: unknown[] } | null;
|
|
expect(stillThere).not.toBeNull();
|
|
expect(stillThere!.codeAnnotations).toHaveLength(1);
|
|
await s.unmount();
|
|
});
|
|
|
|
test.skipIf(!hasDom)('auto-view suppression round-trips, and an older draft without it restores empty', async () => {
|
|
// Guards the "come back to this" contract across a reload: a file the
|
|
// reviewer un-viewed must still be off-limits to auto-view after the draft
|
|
// comes back. The second half guards backward compatibility — a draft
|
|
// written before the field must restore, not throw or resurrect state.
|
|
const s1 = await mountSession(options());
|
|
await s1.rerender(options({
|
|
annotations: [ANNOTATION],
|
|
viewedFiles: new Set(['src/a.ts']),
|
|
autoViewSuppressed: new Set(['src/b.ts']),
|
|
}));
|
|
await tick(DEBOUNCE_WAIT_MS);
|
|
const saved = loadDraft(DRAFT_KEY) as { autoViewSuppressed?: string[] } | null;
|
|
expect(saved!.autoViewSuppressed).toEqual(['src/b.ts']);
|
|
await s1.unmount();
|
|
|
|
const s2 = await mountSession(options());
|
|
// restoreDraft clears the banner, so it is a state update like any other.
|
|
let restoredSuppressed: string[] = [];
|
|
await act(async () => { restoredSuppressed = s2.result.current!.restoreDraft().autoViewSuppressed; });
|
|
expect(restoredSuppressed).toEqual(['src/b.ts']);
|
|
await s2.unmount();
|
|
|
|
// A draft written by a build that predates the field.
|
|
deleteDraft(DRAFT_KEY);
|
|
saveDraft(DRAFT_KEY, {
|
|
codeAnnotations: [ANNOTATION],
|
|
viewedFiles: ['src/a.ts'],
|
|
draftGeneration: 1,
|
|
ts: Date.now(),
|
|
});
|
|
const s3 = await mountSession(options());
|
|
let restored = { viewedFiles: [] as string[], autoViewSuppressed: [] as string[] };
|
|
await act(async () => { restored = s3.result.current!.restoreDraft(); });
|
|
expect(restored.viewedFiles).toEqual(['src/a.ts']);
|
|
expect(restored.autoViewSuppressed).toEqual([]);
|
|
await s3.unmount();
|
|
});
|
|
|
|
test.skipIf(!hasDom)('suppression alone does not keep an otherwise empty draft alive', async () => {
|
|
// Deliberate edge (#948 semantics stay untouched): the un-view set is not
|
|
// content. A session with nothing but suppression is still empty and still
|
|
// tombstones, so clear-everything keeps meaning what it means today.
|
|
const s = await mountSession(options());
|
|
await s.rerender(options({ annotations: [ANNOTATION] }));
|
|
await tick(DEBOUNCE_WAIT_MS);
|
|
expect(loadDraft(DRAFT_KEY)).not.toBeNull();
|
|
|
|
await s.rerender(options({ annotations: [], autoViewSuppressed: new Set(['src/b.ts']) }));
|
|
await tick(DEBOUNCE_WAIT_MS);
|
|
expect(loadDraft(DRAFT_KEY)).toBeNull();
|
|
await s.unmount();
|
|
});
|
|
});
|