Round 8 found a BLOCKER, and it was my verification that was wrong rather than an argument. The previous commit re-armed `npm test` in the web-build stage and excluded the one file that needs a git CHECKOUT. It did not exclude the file added in round 6 that needs the git BINARY: `trackedSourceFiles`' real-repository tests shell out to `git` to prove the derivation by executing it, and `node:22-bookworm-slim` ships no git. So `docker build` would have failed on every push to `main` and every `v*` tag — no image ships. Why I did not catch it: every "gitless replica" I ran deleted `.git` and left the git binary on `PATH`. That reproduces one of the two preconditions. Re-measured with `PATH` stripped, the previous head gives `spawnSync git ENOENT`, `Test Files 1 failed | 116 passed`. The Dockerfile comment stated the right premise — "no `.git`, AND node:22-bookworm-slim ships no git binary" — two lines above the line that only acted on the first half. The real-git block is now its own file, `trackedSourceFiles.realgit.test.ts`, so excluding it costs two tests rather than the thirteen injected-runner ones that run fine with no git at all. Both exclusions are named in the Dockerfile with their DIFFERENT reasons, and with the instruction to verify any change there with the binary off `PATH`. MEASURED in a replica with neither `.git` nor `git`: 117 files / 1225 tests pass, lint, typecheck and build all exit 0. Also from round 8: - `docs/testing.md`'s row asserted the suite "cannot run against an exported tree with no index" and that the Dockerfile "no longer runs it" — both false at the previous head. It now distinguishes the checkout requirement from the binary requirement, which is the distinction the blocker turned on. - Two clauses this PR added had no proof and MEASURED green when disarmed: the trailing-slash restriction in `unaccountedWalkedPaths` (without it every `others` entry becomes a prefix mask) and the plugin's dedupe. Both now have fixtures, and both mutations redden by name. - The dedupe's stated rationale was wrong: duplicates appear on both sides of the ratio the floor computes, so they cancel. Re-stated as tidiness, with the measurement, and "three times" corrected to two-or-three (a both-added conflict has two stages). - Three JSDoc blocks were stacked above one function, so two of them documented functions defined 15 and 50 lines below and attached to the wrong ones. Pure reordering, plus the blank lines that were detaching them. - `vite-env.d.ts` named the wrong mechanism for its loud-failure claim: a renamed field yields an empty Set, caught by the anti-vacuity floor rather than by a throw at first read. - The Dockerfile no longer hardcodes a test-file count that nothing reddens on. Refs: #819
177 lines
8.1 KiB
TypeScript
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);
|
|
});
|
|
});
|