import { afterAll, beforeAll, expect, test } from 'bun:test'; import { existsSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, utimesSync, writeFileSync, } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { isNetappCurrent } from './netapp-staleness'; let dir: string; let src: string; let netapp: string; const scratch: string[] = []; beforeAll(() => { dir = mkdtempSync(join(tmpdir(), 'netapp-stale-')); scratch.push(dir); src = join(dir, 'src'); netapp = join(dir, 'out.netapp'); mkdirSync(src); }); afterAll(() => { for (const path of scratch) rmSync(path, { recursive: true, force: true }); }); function writeSource(name: string, contents: string): string { const target = join(src, name); writeFileSync(target, contents); return target; } test('no netapp yet: not current, even over an empty tree', () => { expect(existsSync(netapp)).toBe(false); expect(isNetappCurrent(netapp, dir)).toBe(false); }); test('netapp newer than every source file: current (the skip case)', () => { writeSource('main.sh', 'echo hello'); writeFileSync(netapp, 'netapp-bytes'); // The netapp was written after the source, so repackaging would produce // the same bytes. build-infra must skip this module. expect(isNetappCurrent(netapp, dir)).toBe(true); }); test('recurrence gate (celilo#1258): touching a source file makes the netapp stale', () => { // Touch, don't rewrite: proves the check reads mtimes, not contents. const touched = writeSource('touched.txt', 'x'); const later = new Date(Date.now() + 60_000); utimesSync(touched, later, later); expect(isNetappCurrent(netapp, dir)).toBe(false); }); test('netapp older than a source edit: stale', () => { const older = new Date(Date.now() - 120_000); utimesSync(netapp, older, older); expect(isNetappCurrent(netapp, dir)).toBe(false); }); test('a rebuilt binlink dir never marks the netapp stale (the bun install shape)', () => { // Fresh tree: shared state above would leak src/'s own dir mtime into this // assertion, and src/ is a shipped path whose mtime must count. const tree = mkdtempSync(join(tmpdir(), 'netapp-binlinks-')); scratch.push(tree); const moduleDir = join(tree, 'mod'); const binDir = join(moduleDir, 'e2e', 'node_modules', '.bin'); mkdirSync(binDir, { recursive: true }); writeFileSync(join(moduleDir, 'main.sh'), 'echo hello'); symlinkSync('../real-tool', join(binDir, 'tool')); const staged = join(tree, 'staged.netapp'); writeFileSync(staged, 'netapp-bytes'); expect(isNetappCurrent(staged, moduleDir)).toBe(true); // bun install recreates the binlink: the symlink file AND the .bin // directory move past the netapp. Neither ships (see classifyModulePath // and includeNodeModulesPath), so the netapp stays current. This is the // dir-level case the file-level exclusion alone misses, probed 2026-09-08: // `-not -path '*/node_modules/.bin/*'` does not match `.bin` itself, and // creating an entry moves the dir's mtime. rmSync(join(binDir, 'tool')); symlinkSync('../real-tool', join(binDir, 'tool')); expect(isNetappCurrent(staged, moduleDir)).toBe(true); }); test('a change confined to the module e2e/ tree never marks the netapp stale', () => { const tree = mkdtempSync(join(tmpdir(), 'netapp-e2e-')); scratch.push(tree); const moduleDir = join(tree, 'mod'); mkdirSync(join(moduleDir, 'e2e'), { recursive: true }); writeFileSync(join(moduleDir, 'main.sh'), 'echo hello'); writeFileSync(join(moduleDir, 'e2e', 'suite.test.ts'), 'test("x", () => {});'); const staged = join(tree, 'staged.netapp'); writeFileSync(staged, 'netapp-bytes'); expect(isNetappCurrent(staged, moduleDir)).toBe(true); // An e2e-only edit: the file and its parent dir are both unshipped, so // the netapp stays current. A change to main.sh would NOT be excluded — // covered by the recurrence gate above. const later = new Date(Date.now() + 60_000); const e2eFile = join(moduleDir, 'e2e', 'new.test.ts'); writeFileSync(e2eFile, 'test("y", () => {});'); utimesSync(e2eFile, later, later); expect(isNetappCurrent(staged, moduleDir)).toBe(true); }); test('a change to a SHIPPED bin-closure path marks the netapp stale', () => { // scripts/node_modules minus .bin is the hook runtime closure the packager // ships in full (ISS-0046). A change there must repackage, so the // exclusion cannot be as broad as "anything under node_modules". const tree = mkdtempSync(join(tmpdir(), 'netapp-hookrt-')); scratch.push(tree); const moduleDir = join(tree, 'mod'); const hookDep = join(moduleDir, 'scripts', 'node_modules', 'tldts'); mkdirSync(hookDep, { recursive: true }); writeFileSync(join(moduleDir, 'main.sh'), 'echo hello'); writeFileSync(join(hookDep, 'index.js'), 'module.exports = {};'); const staged = join(tree, 'staged.netapp'); writeFileSync(staged, 'netapp-bytes'); expect(isNetappCurrent(staged, moduleDir)).toBe(true); const later = new Date(Date.now() + 60_000); const depFile = join(hookDep, 'index.js'); writeFileSync(depFile, 'module.exports = { changed: true };'); utimesSync(depFile, later, later); expect(isNetappCurrent(staged, moduleDir)).toBe(false); }); test('a nonexistent source tree repackages (a check that cannot answer never reuses)', () => { const tree = mkdtempSync(join(tmpdir(), 'netapp-missing-')); scratch.push(tree); const staged = join(tree, 'staged.netapp'); writeFileSync(staged, 'netapp-bytes'); expect(isNetappCurrent(staged, join(tree, 'does-not-exist'))).toBe(false); });