Files
ersatztv/web/src/api/pageSizeScan.test.ts
T
timothy 1c86a1c1fc fix(650): enforce single-flight in useLibraryBrowse; close scanner false negatives
Third cold cross-family review (BLOCKED) found the append/page-0-refresh
races were being fixed one interleaving at a time — round 1 fixed
page-0-settles-first, round 2's compare-and-set rollback fixed the
duplicate-append case but introduced a permanently-skipped page, and the
reviewer found the exact mirror of round 1's fix (page-1-settles-first,
erasing page 1 with no cursor reset). Direction from the review: stop
enumerating orderings, make the overlap structurally impossible.

SINGLE-FLIGHT (web/src/builder/libraryBrowse.ts): a new `busyRef` guard is
true from the moment ANY fetch (a page-0 refresh OR an append) for the
current query generation is issued until it settles. `loadMore` checks it
SYNCHRONOUSLY and returns immediately (ignored, not queued) if a fetch is
already in flight — including a page-0 refresh, not just a prior append, so
a "Load more" click that lands while a query change is still resolving is a
no-op rather than starting a second, overlapping request. With overlapping
fetches eliminated by construction, the append-failure rollback no longer
needs the round-2 compare-and-set: single-flight guarantees nothing else
could have moved `pageRef` since a given fetch started, so it now always
rolls back and retries the exact page that failed, unconditionally.

Visual feedback (the button showing loading/disabled during a page-0
refresh, not just an append) is set via `queueMicrotask(() => setLoadingMore
(true))` rather than a bare synchronous call in the generation-change effect
— `react-hooks/set-state-in-effect` flags the latter; a microtask-deferred
call resolves before any human-perceptible input, satisfies the lint rule
(the same reason `.then()` callbacks elsewhwere in this hook aren't flagged),
and keeps the actual correctness guarantee (the ref check) perfectly
synchronous regardless.

TESTS REWRITTEN, not just added — the round-2 "HIGH-2" hook test explicitly
asserted the NEXT request after a failed page 1 (following an overlapping
page 2 success) should be page 3, i.e. it blessed page 1's permanent loss.
Replaced with two hook-level tests: single-flight ignores a synchronous
double `loadMore()` call (only one fetch issued), and a failed page is
retried as the SAME page number. Replaced the round-2 component-level
"HIGH-1" test (which drove the now-impossible overlap through the DOM) with
one asserting the click during a pending page-0 refresh is ignored, and that
once free, the correct page-1-then-page-2 sequence completes with both
pages' rows present. Verified all three new/rewritten tests against the
prior committed hook (7b1ae48b0): the two single-flight-specific tests fail
as expected (`[0, 1]` requested when only `[0]` should have been); the
retry-semantics test happens to pass against 7b1ae48b0 too (compare-and-set
and unconditional rollback coincide in the non-overlapping case) but is kept
because it is the correct "retry as page 1, not page 3" pin the review asked
for, replacing the one that asserted the wrong thing.

SCANNER (pageSizeScan.ts) — closed three documented false-negative classes:
- Transparent TS wrappers around the initializer (`pageSize: 100 as const`,
  `100 satisfies number`, parenthesized) are now unwrapped before the
  NumericLiteral/Identifier check.
- Non-Identifier property names: a quoted string key (`'pageSize': 100`) or
  a statically-resolvable computed key (`['pageSize']: 100`) are now
  accepted; a computed key that isn't a literal correctly stays unresolved.
- `.mts`/`.cts` are no longer silently excluded from the guard's file
  discovery glob (tsconfig.app.json's `include` covers all of `src`; no such
  files exist in the repo today, but the glob shouldn't hide one if it ever
  does).
10 new fixture tests in pageSizeScan.test.ts pin each case (plus a rejection
test confirming a forwarded call wrapped in `as` still doesn't match, and
one confirming an unresolvable computed key stays unmatched).

TEST LABELLING: relabeled the URL/M-3 and `??`/M-4 fixtures as CONTRACT
fixtures rather than regression pins — a round-3 review found round 1's
plain literal regex already handled those two exact inputs correctly on its
own; only the combined multi-case fixture (and the string-contains-text,
template-interpolation, same-line-identity, JSX, and destructuring fixtures)
actually fail against round 1. Labeled the guard test's 4 tests as BASELINE
assertions (they all pass on clean b90f8a3b) rather than implying they prove
this round's specific fixes — pageSizeScan.test.ts's fixtures are what
actually regression-pin the scanner.

No server-side/C# change. Full local gate: lint clean, tsc clean, full
vitest run 110 files / 1063 tests passed (re-run twice, stable).
2026-07-27 01:29:15 +02:00

248 lines
13 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import { pageSizeSiteId, scanPageSizeSites, type PageSizeSite } from './pageSizeScan';
/**
* Fixture test for `scanPageSizeSites` itself — NOT a scan of the real repo (that's
* `pageSizeCallSites.guard.test.ts`). This is what actually protects the SCANNER going forward:
* a prior hand-rolled regex/bracket-tracking version passed the guard test against unmodified
* source at both #650 commits while still being defeated by every case below, because the guard
* only ever exercised today's snapshot of real call sites — it never proved the scanner handles
* the INPUT CLASSES that expose a text-level scanner's blind spots. Pinning the exact discovered
* set against synthetic source strings closes that gap.
*
* Not every fixture here is a REGRESSION pin against the prior (round-1, `b90f8a3b`) bracket-
* tracking scanner — a round-3 review found that round 1's simple `pageSize:\s*value` regex
* already handled a bare URL-string or a bare `??` context correctly on its own (a `//` inside a
* string, or the token immediately before `{`, only mattered to round 1's OWN heuristics, not to
* a plain regex match). Those two are labelled CONTRACT fixtures below — they pin the documented
* behavior going forward, not a fix. The fixtures that genuinely fail against round 1 (verified)
* are: the string CONTAINING the literal text `pageSize: 100`, the template-literal
* interpolation, the same-line ternary identity/multiplicity, the JSX shorthand container,
* parameter destructuring, nested destructuring, and the type-literal declaration — plus the
* combined multi-case fixture, which fails round 1 for several of those reasons at once.
*/
function ids(sites: PageSizeSite[]): string[] {
return sites.map(pageSizeSiteId);
}
describe('scanPageSizeSites', () => {
it('finds a literal pageSize: property in a plain object-literal call argument', () => {
const source = `getFoo({ pageSize: 100, query });\n`;
const sites = scanPageSizeSites(source, 'fixture.ts');
expect(ids(sites)).toEqual(['1:10:literal:100']);
});
it('finds the ES6 shorthand pageSize property in a plain object-literal call argument', () => {
const source = `getFoo({ pageSize, query });\n`;
const sites = scanPageSizeSites(source, 'fixture.ts');
expect(ids(sites)).toEqual(['1:10:shorthand:pageSize']);
});
it('is NOT fooled by a "//" inside a string literal — CONTRACT fixture, not a round-1 regression pin (M-3)', () => {
// NOTE: round 1's unconditional literal regex (`pageSize:\s*(\d+|identifier)`) already
// matched this exact input correctly on its own — a `//` inside a string never confused THAT
// narrower pattern. This pins the AST scanner's documented contract going forward; it is the
// COMBINED multi-case fixture below (and the M-3-shaped case buried inside it — a literal
// `//` immediately preceding a real call site on the SAME conceptual scan) that actually
// fails against round 1's comment-stripping step, not this input in isolation.
const source = [`const endpoint = 'https://example.test';`, `getFoo({ pageSize: 100 });`, ''].join('\n');
const sites = scanPageSizeSites(source, 'fixture.ts');
expect(ids(sites)).toEqual(['2:10:literal:100']);
});
it('does NOT match a string literal that merely CONTAINS the text "pageSize: 100" (L-7)', () => {
const source = `const label = "pageSize: 100";\n`;
expect(scanPageSizeSites(source, 'fixture.ts')).toEqual([]);
});
it('finds an object literal passed inside a template-literal interpolation (M-5)', () => {
const source = 'const url = `${await getFoo({ pageSize })}`;\n';
const sites = scanPageSizeSites(source, 'fixture.ts');
expect(ids(sites)).toEqual(['1:31:shorthand:pageSize']);
});
it('finds an object literal in each branch of a ternary, even on the SAME line (M-4, M-6)', () => {
const source = `return ok ? getA({ pageSize }) : getB({ pageSize });\n`;
const sites = scanPageSizeSites(source, 'fixture.ts');
// Two distinct occurrences on one line get distinct identities (different columns) — a
// single registry entry cannot silently cover both.
expect(ids(sites)).toEqual(['1:20:shorthand:pageSize', '1:41:shorthand:pageSize']);
expect(sites[0].column).not.toBe(sites[1].column);
});
it('finds an object literal on the right-hand side of ?? — CONTRACT fixture, not a round-1 regression pin (M-4)', () => {
// NOTE: like the URL fixture above, round 1's literal-form regex already matched this exact
// `pageSize: 50` text correctly on its own — `??` doesn't change what characters precede the
// match on the line. This pins the documented contract, not a round-1 regression.
const source = `getFoo(options ?? { pageSize: 50 });\n`;
const sites = scanPageSizeSites(source, 'fixture.ts');
expect(ids(sites)).toEqual(['1:21:literal:50']);
});
it('finds an object literal inside a JSX expression container attribute (M-4)', () => {
const source = `const el = <Component options={{ pageSize }} />;\n`;
const sites = scanPageSizeSites(source, 'fixture.tsx');
expect(ids(sites)).toEqual(['1:34:shorthand:pageSize']);
});
it('does NOT match a parameter destructuring pattern (L-7)', () => {
const source = `function f({ pageSize }: { pageSize: number }) {}\n`;
// The destructured PARAMETER `{ pageSize }` is an ObjectBindingPattern, not an
// ObjectLiteralExpression — excluded by node kind. Its TYPE annotation `{ pageSize: number }`
// is a TypeLiteral (PropertySignature), also excluded by node kind — never an object literal.
expect(scanPageSizeSites(source, 'fixture.ts')).toEqual([]);
});
it('does NOT match a nested destructuring pattern (L-7)', () => {
const source = `const { nested: { pageSize } } = input;\n`;
expect(scanPageSizeSites(source, 'fixture.ts')).toEqual([]);
});
it('does NOT match a type-literal declaration (L-7)', () => {
const source = `type P = { pageSize: 100 };\n`;
expect(scanPageSizeSites(source, 'fixture.ts')).toEqual([]);
});
it('does NOT match an interface property declaration', () => {
const source = `interface Params {\n pageSize?: number;\n}\n`;
expect(scanPageSizeSites(source, 'fixture.ts')).toEqual([]);
});
it('does NOT match a forwarded call expression (a dynamic passthrough, not a fixed value)', () => {
const source = `getFoo({ pageSize: String(pageSize) });\n`;
expect(scanPageSizeSites(source, 'fixture.ts')).toEqual([]);
});
it('is not confused by a pageSize reference inside a comment', () => {
const source = [`// pageSize: 999 — this is just prose, not code`, `getFoo({ query });`, ''].join('\n');
expect(scanPageSizeSites(source, 'fixture.ts')).toEqual([]);
});
it('is not confused by a pageSize reference inside a block/JSDoc comment', () => {
const source = ['/**', ' * Uses `pageSize` under the hood — see also `{ pageSize: 100 }`.', ' */', 'getFoo({ query });', ''].join(
'\n'
);
expect(scanPageSizeSites(source, 'fixture.ts')).toEqual([]);
});
it('does NOT match a React dependency array containing pageSize', () => {
const source = `useCallback(load, [pageNum, pageSize, sortField]);\n`;
expect(scanPageSizeSites(source, 'fixture.ts')).toEqual([]);
});
it('finds a const-identifier literal value (not just a numeric literal)', () => {
const source = `getFoo({ pageSize: LIBRARY_BROWSE_PAGE_CAP });\n`;
const sites = scanPageSizeSites(source, 'fixture.ts');
expect(ids(sites)).toEqual(['1:10:literal:LIBRARY_BROWSE_PAGE_CAP']);
});
it('covers every case above together in one multi-line fixture and pins the exact discovered set', () => {
const source = [
`const endpoint = 'https://example.test';`, // M-3: not a comment
`const label = "pageSize: 100";`, // L-7: string contents, not code
`// pageSize: 999 in a line comment`, // not code
`/** block comment mentioning \`pageSize\` */`, // not code
`type P = { pageSize: 100 };`, // L-7: type literal, not a value
`interface Q { pageSize?: number; }`, // not a value
`function f({ pageSize }: { pageSize: number }) {}`, // L-7: destructuring + its type
`const { nested: { pageSize } } = input;`, // L-7: nested destructuring
`useCallback(load, [pageNum, pageSize]);`, // dependency array, not an object literal
`getFoo({ pageSize: String(pageSize) });`, // forwarded call, not a fixed value
`getFoo({ pageSize: 100 });`, // REAL: literal
`getBar({ pageSize });`, // REAL: shorthand
`getBaz(options ?? { pageSize: 50 });`, // REAL: ?? context (M-4)
`const url = \`\${await getQux({ pageSize })}\`;`, // REAL: template interpolation (M-5)
`return ok ? getA({ pageSize }) : getB({ pageSize });` // REAL x2: ternary, same line (M-4/M-6)
].join('\n');
const sites = scanPageSizeSites(source, 'fixture.ts');
expect(ids(sites)).toEqual([
'11:10:literal:100',
'12:10:shorthand:pageSize',
'13:21:literal:50',
'14:31:shorthand:pageSize',
'15:20:shorthand:pageSize',
'15:41:shorthand:pageSize'
]);
// Anti-vacuity: the fixture packs in 10 non-matching traps ahead of the 6 real sites — a
// scanner that matched everything (or nothing) would fail this count, not just the ids above.
expect(sites.length).toBe(6);
});
it('scans .tsx source using the TSX script kind (JSX does not parse under plain .ts rules)', () => {
const source = `export function C() {\n return <div data={{ pageSize: 10 }} />;\n}\n`;
const sites = scanPageSizeSites(source, 'fixture.tsx');
expect(ids(sites)).toEqual(['2:23:literal:10']);
});
// ---- round-3 MEDIUM finding: transparent TS wrappers around the initializer -----------------
it('finds a literal wrapped in "as const" (transparent to the runtime value)', () => {
const source = `getFoo({ pageSize: 100 as const });\n`;
const sites = scanPageSizeSites(source, 'fixture.ts');
expect(ids(sites)).toEqual(['1:10:literal:100']);
});
it('finds a literal wrapped in "satisfies number" (transparent to the runtime value)', () => {
const source = `getFoo({ pageSize: 100 satisfies number });\n`;
const sites = scanPageSizeSites(source, 'fixture.ts');
expect(ids(sites)).toEqual(['1:10:literal:100']);
});
it('finds a parenthesized literal', () => {
const source = `getFoo({ pageSize: (100) });\n`;
const sites = scanPageSizeSites(source, 'fixture.ts');
expect(ids(sites)).toEqual(['1:10:literal:100']);
});
it('finds a const identifier through a chain of "as"/"satisfies"/parens wrappers', () => {
const source = `getFoo({ pageSize: ((LIBRARY_BROWSE_PAGE_CAP as number) satisfies number) });\n`;
const sites = scanPageSizeSites(source, 'fixture.ts');
expect(ids(sites)).toEqual(['1:10:literal:LIBRARY_BROWSE_PAGE_CAP']);
});
it('still rejects a forwarded call expression even when wrapped in "as"', () => {
const source = `getFoo({ pageSize: String(pageSize) as string });\n`;
expect(scanPageSizeSites(source, 'fixture.ts')).toEqual([]);
});
// ---- round-3 MEDIUM finding: non-Identifier property names -----------------------------------
it('finds a quoted string property key ("pageSize": 100)', () => {
const source = `getFoo({ 'pageSize': 100 });\n`;
const sites = scanPageSizeSites(source, 'fixture.ts');
expect(ids(sites)).toEqual(['1:10:literal:100']);
});
it('finds a statically-resolvable computed property key (["pageSize"]: 100)', () => {
const source = `getFoo({ ['pageSize']: 100 });\n`;
const sites = scanPageSizeSites(source, 'fixture.ts');
expect(ids(sites)).toEqual(['1:10:literal:100']);
});
it('does NOT match a computed property key that cannot be resolved statically', () => {
const source = `const key = getKey();\ngetFoo({ [key]: 100 });\n`;
expect(scanPageSizeSites(source, 'fixture.ts')).toEqual([]);
});
it('does NOT match a quoted string key for a DIFFERENT property name', () => {
const source = `getFoo({ 'pageSizeLimit': 100 });\n`;
expect(scanPageSizeSites(source, 'fixture.ts')).toEqual([]);
});
// ---- round-3 MEDIUM finding: .mts/.cts are never silently skipped -----------------------------
it('scans .mts source (parses as plain TS, no JSX grammar)', () => {
const source = `export function loadPage() {\n return getFoo({ pageSize: 100 });\n}\n`;
const sites = scanPageSizeSites(source, 'fixture.mts');
expect(ids(sites)).toEqual(['2:19:literal:100']);
});
it('scans .cts source (parses as plain TS, no JSX grammar)', () => {
const source = `getFoo({ pageSize });\n`;
const sites = scanPageSizeSites(source, 'fixture.cts');
expect(ids(sites)).toEqual(['1:10:shorthand:pageSize']);
});
});