Files
ersatztv/web/vite-plugins/trackedSourceFiles.test.ts
T
timothyandtimothy 8aeacd534a
Build ErsatzTV Image / CI toolchain image resolves (push) Successful in 9s
Build ErsatzTV Image / Delimiter ban (release path) (push) Successful in 21s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 8m52s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 6m23s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (push) Successful in 5m50s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Skipped
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 5m5s
fix(819): derive the SPA page-size guard population from the git index (#875)
The guard asserted EXACT completeness over a population enumerated by a directory
walk, so an untracked .ts/.tsx under web/src/ entered it and failed as unregistered
on that developer's checkout while CI — which only ever checks out tracked files —
stayed green.

The glob still supplies file CONTENT; the POPULATION is now the git index, read by
web/vite-plugins/trackedSourceFiles.ts in Vite's own Node context and handed to the
app project as a virtual module. That reaches the index without admitting
@types/node to tsconfig.app.json, the obstacle that deferred this in #818.

Three mechanisms carry the proof, each added because the previous was measured
insufficient: a closed-form restatement of the shared scope predicate (sharing no
helper at any depth with what it checks); a second independent `ls-files --others`
query cross-checking the population; and real-git tests that execute the derivation
against a temp repository.

Six residuals are stated with their MEASURED fail-directions, and
testing.guard-derives-population-from-source gains a bounded exception plus the
closed-form criterion.

fixes #819

Co-authored-by: Timothy <timothy@noreply.gitea.tblindustries.be>
2026-08-29 09:14:15 +00:00

177 lines
8.1 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import {
TRACKED_SOURCE_FILES_ID,
type FileExists,
type GitRunner,
resolveTrackedSourceFiles,
trackedSourceFilesPlugin
} from './trackedSourceFiles';
/**
* The population this plugin supplies is what `web/src/api/pageSizeCallSites.guard.test.ts` asserts
* completeness over (#819), so the plugin is verification code and carries its own proof rather
* than inheriting the guard's — `testing.verification-code-needs-its-own-proof`.
*/
const RESOLVED_ID = `\0${TRACKED_SOURCE_FILES_ID}`;
const allPresent: FileExists = () => true;
/** `ls-files` answers the tracked query; `--others` answers the untracked one. */
function runnerReturning(tracked: string, others = ''): { run: GitRunner; calls: (readonly string[])[] } {
const calls: (readonly string[])[] = [];
return {
calls,
run: (args) => {
calls.push(args);
return args.includes('--others') ? others : tracked;
}
};
}
/** Drives `load()` through the plugin's own `configResolved`, as Vite does. */
function loadedBy(plugin: ReturnType<typeof trackedSourceFilesPlugin>, root = '/repo/web'): string | undefined {
const configResolved = plugin.configResolved as (config: { root: string }) => void;
configResolved.call(plugin, { root });
return (plugin.load as (id: string) => string | undefined).call(plugin, RESOLVED_ID);
}
describe('resolveTrackedSourceFiles (#819)', () => {
it("splits git's NUL-separated output into paths, dropping the trailing empty field", () => {
const { run } = runnerReturning('src/a.ts\0src/b.tsx\0');
expect(resolveTrackedSourceFiles('/repo/web', run, allPresent).tracked).toEqual(['src/a.ts', 'src/b.tsx']);
});
it('reports UNTRACKED paths from a SEPARATE query, so a narrowed index cannot hide behind them', () => {
// #819 round 6: every other comparison in the guard is between two things derived from
// `tracked`, so a filter applied to `tracked` cancels out of all of them. `others` comes from a
// different git query and is what makes that filter visible — narrowing `tracked` adds nothing
// here, so the walk finds a file in neither list.
const { run } = runnerReturning('src/a.ts\0', 'src/scratch.ts\0src/notes.ts\0');
const result = resolveTrackedSourceFiles('/repo/web', run, allPresent);
expect(result.tracked).toEqual(['src/a.ts']);
expect(result.others).toEqual(['src/scratch.ts', 'src/notes.ts']);
});
it('THROWS when the untracked query fails, rather than degrading to an empty set', () => {
// An empty `others` would make a narrowed index look like a real hole AND, worse, is the
// direction that could be used to silence the cross-check.
const run: GitRunner = (args) => {
if (args.includes('--others')) throw new Error('git exploded');
return 'src/a.ts\0';
};
expect(() => resolveTrackedSourceFiles('/repo/web', run, allPresent)).toThrow(
/could not enumerate untracked files/
);
});
it('asks git for NUL-separated output, scoped to src/, in the given root', () => {
// Pins `-z` specifically. Without it git applies `core.quotePath` to a non-ASCII name and
// returns a C-quoted string that no longer equals the path Vite reports, which would drop that
// file from the population silently — the guard would then under-enumerate while looking green.
const { run, calls } = runnerReturning('src/a.ts\0');
resolveTrackedSourceFiles('/repo/web', run, allPresent);
expect(calls).toEqual([
['-C', '/repo/web', 'ls-files', '-z', '--', 'src'],
['-C', '/repo/web', 'ls-files', '-z', '--others', '--', 'src']
]);
});
it('keeps a path containing spaces intact rather than splitting on whitespace', () => {
const { run } = runnerReturning('src/screens/My Screen.tsx\0src/b.ts\0');
expect(resolveTrackedSourceFiles('/repo/web', run, allPresent).tracked).toEqual([
'src/screens/My Screen.tsx',
'src/b.ts'
]);
});
it('REPORTS tracked paths with no file on disk rather than dropping them', () => {
// The consumer needs a mid-edit deletion (transient, tolerated) told apart from a tracked file
// the walk cannot see (a real hole). Collapsing them is how a population silently shrinks.
const { run } = runnerReturning('src/present.ts\0src/deleted.ts\0');
const seen: string[] = [];
const exists: FileExists = (path) => {
seen.push(path);
return !path.endsWith('deleted.ts');
};
const result = resolveTrackedSourceFiles('/repo/web', run, exists);
expect(result.tracked).toEqual(['src/present.ts', 'src/deleted.ts']);
expect(result.absentFromDisk).toEqual(['src/deleted.ts']);
// Existence is checked against the ROOT-joined path, not the repo-relative one.
expect(seen).toEqual(['/repo/web/src/present.ts', '/repo/web/src/deleted.ts']);
});
it('deduplicates a path git emits once per merge-conflict stage', () => {
// `git ls-files` lists an unmerged path once per stage. Nothing downstream needs the repetition.
const { run } = runnerReturning('src/a.ts\0src/a.ts\0src/b.ts\0');
expect(resolveTrackedSourceFiles('/repo/web', run, allPresent).tracked).toEqual([
'src/a.ts',
'src/b.ts'
]);
});
it('THROWS when git fails, instead of degrading to an unfiltered walk', () => {
// The failure this rejects is the fail-open one: answering "could not tell" with the very
// population the index was brought in to replace is a permanent no-op that reports green.
const boom: GitRunner = () => {
throw new Error('not a git repository');
};
expect(() => resolveTrackedSourceFiles('/repo/web', boom, allPresent)).toThrow(/could not read the git index/);
});
it('THROWS when git reports zero tracked files (anti-vacuity at the source)', () => {
const { run } = runnerReturning('');
expect(() => resolveTrackedSourceFiles('/repo/web', run, allPresent)).toThrow(/ZERO tracked files/);
});
});
describe('trackedSourceFilesPlugin (#819)', () => {
it('resolves only its own virtual id', () => {
const plugin = trackedSourceFilesPlugin(runnerReturning('src/a.ts\0').run, allPresent);
const resolveId = plugin.resolveId as (id: string) => string | undefined;
expect(resolveId.call(plugin, TRACKED_SOURCE_FILES_ID)).toBe(RESOLVED_ID);
expect(resolveId.call(plugin, 'react')).toBeUndefined();
expect(resolveId.call(plugin, './pageSizeScan')).toBeUndefined();
});
it('emits the tracked list and the absent set as a default-exported module', () => {
const plugin = trackedSourceFilesPlugin(runnerReturning('src/a.ts\0src/b.tsx\0').run, allPresent);
expect(loadedBy(plugin)).toBe(
'export default {"tracked":["src/a.ts","src/b.tsx"],"absentFromDisk":[],"others":[]};\n'
);
});
it('REFUSES rather than guessing when configResolved never ran', () => {
// A wrong root is a silent wrong answer. Seeding it with `process.cwd()` would happen to work
// in this repo's layout and quietly derive the population from the wrong tree elsewhere.
const { run, calls } = runnerReturning('src/a.ts\0');
const plugin = trackedSourceFilesPlugin(run, allPresent);
const load = plugin.load as (id: string) => string | undefined;
expect(() => load.call(plugin, RESOLVED_ID)).toThrow(/configResolved` hook never ran/);
expect(calls).toHaveLength(0);
});
it('does NOT shell out to git unless the virtual module is actually loaded', () => {
// Constructing the plugin, or loading anything else, must not run git — otherwise registering
// it in vite.config.ts would break every build in a context without a repository.
const { run, calls } = runnerReturning('src/a.ts\0');
const plugin = trackedSourceFilesPlugin(run, allPresent);
const configResolved = plugin.configResolved as (config: { root: string }) => void;
const load = plugin.load as (id: string) => string | undefined;
configResolved.call(plugin, { root: '/repo/web' });
expect(calls).toHaveLength(0);
expect(load.call(plugin, '\0some-other-virtual-module')).toBeUndefined();
expect(calls).toHaveLength(0);
load.call(plugin, RESOLVED_ID);
// Two: the tracked query and the separate untracked one.
expect(calls).toHaveLength(2);
});
});