|
| 1 | +import { existsSync } from 'node:fs'; |
| 2 | +import { lstat, mkdir, mkdtemp, readdir, rm, utimes, writeFile } from 'node:fs/promises'; |
| 3 | +import { tmpdir } from 'node:os'; |
| 4 | +import { dirname, join, resolve } from 'node:path'; |
| 5 | + |
| 6 | +import { afterEach, describe, expect, it } from '@rstest/core'; |
| 7 | + |
| 8 | +import { |
| 9 | + assertFreshDist, |
| 10 | + checkDistFreshness, |
| 11 | + distFreshness, |
| 12 | + formatDistFreshnessFailure, |
| 13 | + isSkippedInputDirectory, |
| 14 | + newestEntry, |
| 15 | + runtimeExampleBuildOutputs, |
| 16 | + workspaceBuildOutputs, |
| 17 | + type DistDescriptor, |
| 18 | + type DistFreshness, |
| 19 | +} from '../../../scripts/dist-freshness.mjs'; |
| 20 | + |
| 21 | +const workspaceRoot = process.cwd(); |
| 22 | + |
| 23 | +/** Fixture clock: sources written first, dist built after, then one later edit. */ |
| 24 | +const sourceTime = new Date('2026-01-01T00:00:00Z'); |
| 25 | +const buildTime = new Date('2026-01-02T00:00:00Z'); |
| 26 | +const editTime = new Date('2026-01-03T00:00:00Z'); |
| 27 | + |
| 28 | +const temporaryRoots: string[] = []; |
| 29 | + |
| 30 | +afterEach(async () => { |
| 31 | + await Promise.all(temporaryRoots.splice(0).map((root) => rm(root, { force: true, recursive: true }))); |
| 32 | +}); |
| 33 | + |
| 34 | +/** Writes empty files at `paths` (creating directories) under `root`. */ |
| 35 | +const writeFiles = async (root: string, paths: readonly string[]): Promise<void> => { |
| 36 | + for (const path of paths) { |
| 37 | + await mkdir(dirname(join(root, path)), { recursive: true }); |
| 38 | + await writeFile(join(root, path), ''); |
| 39 | + } |
| 40 | +}; |
| 41 | + |
| 42 | +/** Sets `mtime` on `path` and, for a directory, everything beneath it — children first, since creating entries had left the directories at "now". */ |
| 43 | +const stampTree = async (path: string, mtime: Date): Promise<void> => { |
| 44 | + if ((await lstat(path)).isDirectory()) { |
| 45 | + for (const entry of await readdir(path)) await stampTree(join(path, entry), mtime); |
| 46 | + } |
| 47 | + await utimes(path, mtime, mtime); |
| 48 | +}; |
| 49 | + |
| 50 | +interface PackageFixture { |
| 51 | + readonly descriptor: DistDescriptor; |
| 52 | + readonly root: string; |
| 53 | +} |
| 54 | + |
| 55 | +/** |
| 56 | + * A package whose sources predate its dist: `src` (two levels), the Rslib |
| 57 | + * config, the manifest, a declared-but-absent `tsconfig.json`, and a dist |
| 58 | + * with a nested chunk directory. It sits one level below the temporary |
| 59 | + * root so a test can add a sibling package (`../shared-lib`). |
| 60 | + */ |
| 61 | +const createPackageFixture = async (): Promise<PackageFixture> => { |
| 62 | + const base = await mkdtemp(join(tmpdir(), 'dist-freshness-')); |
| 63 | + temporaryRoots.push(base); |
| 64 | + const root = join(base, 'pkg'); |
| 65 | + await writeFiles(root, ['src/index.ts', 'src/nested/util.ts', 'rslib.config.ts', 'package.json', 'dist/index.js', 'dist/chunks/shared.js']); |
| 66 | + await stampTree(root, sourceTime); |
| 67 | + await stampTree(join(root, 'dist'), buildTime); |
| 68 | + return { |
| 69 | + descriptor: { |
| 70 | + name: 'fixture', |
| 71 | + root, |
| 72 | + inputs: ['src', 'rslib.config.ts', 'package.json', 'tsconfig.json'], |
| 73 | + output: 'dist', |
| 74 | + }, |
| 75 | + root, |
| 76 | + }; |
| 77 | +}; |
| 78 | + |
| 79 | +const touch = (path: string, mtime: Date): Promise<void> => utimes(path, mtime, mtime); |
| 80 | + |
| 81 | +describe('distFreshness', () => { |
| 82 | + it('is fresh when every input predates the newest built file, ignoring declared inputs that do not exist', async () => { |
| 83 | + const { descriptor, root } = await createPackageFixture(); |
| 84 | + const result = distFreshness(descriptor); |
| 85 | + expect(result.status).toBe('fresh'); |
| 86 | + expect(result.output).toBe(join(root, 'dist')); |
| 87 | + expect(new Date(result.newestInput.mtimeMs)).toEqual(sourceTime); |
| 88 | + expect(new Date(result.newestOutput?.mtimeMs ?? 0)).toEqual(buildTime); |
| 89 | + expect(result.newestOutput?.path.startsWith(join(root, 'dist'))).toBe(true); |
| 90 | + }); |
| 91 | + |
| 92 | + it('is stale when a nested source file is newer than the dist, and names that file', async () => { |
| 93 | + const { descriptor, root } = await createPackageFixture(); |
| 94 | + await touch(join(root, 'src/nested/util.ts'), editTime); |
| 95 | + const result = distFreshness(descriptor); |
| 96 | + expect(result.status).toBe('stale'); |
| 97 | + expect(result.newestInput).toEqual({ mtimeMs: editTime.getTime(), path: join(root, 'src/nested/util.ts') }); |
| 98 | + }); |
| 99 | + |
| 100 | + it('is stale when a build config file input is newer than the dist', async () => { |
| 101 | + const { descriptor, root } = await createPackageFixture(); |
| 102 | + await touch(join(root, 'rslib.config.ts'), editTime); |
| 103 | + expect(distFreshness(descriptor).status).toBe('stale'); |
| 104 | + }); |
| 105 | + |
| 106 | + it('counts input directory mtimes, so a deleted or renamed source is noticed', async () => { |
| 107 | + const { descriptor, root } = await createPackageFixture(); |
| 108 | + await touch(join(root, 'src'), editTime); |
| 109 | + const result = distFreshness(descriptor); |
| 110 | + expect(result.status).toBe('stale'); |
| 111 | + expect(result.newestInput.path).toBe(join(root, 'src')); |
| 112 | + }); |
| 113 | + |
| 114 | + it('is missing when the dist is absent, empty, or holds only empty directories', async () => { |
| 115 | + const { descriptor, root } = await createPackageFixture(); |
| 116 | + await rm(join(root, 'dist'), { recursive: true }); |
| 117 | + expect(distFreshness(descriptor)).toMatchObject({ newestOutput: undefined, status: 'missing' }); |
| 118 | + await mkdir(join(root, 'dist')); |
| 119 | + expect(distFreshness(descriptor).status).toBe('missing'); |
| 120 | + await mkdir(join(root, 'dist/chunks')); |
| 121 | + expect(distFreshness(descriptor).status).toBe('missing'); |
| 122 | + }); |
| 123 | + |
| 124 | + it('skips node_modules, dist, .rstest-temp and dot-directories inside inputs', async () => { |
| 125 | + const { descriptor, root } = await createPackageFixture(); |
| 126 | + const skipped = ['src/node_modules/dep/index.js', 'src/dist/out.js', 'src/.rstest-temp/chunk.js', 'src/.cache/entry.js']; |
| 127 | + await writeFiles(root, skipped); |
| 128 | + for (const path of skipped) await stampTree(join(root, path, '..'), editTime); |
| 129 | + // Creating the directories moved `src` itself to "now"; put it back so |
| 130 | + // only the skipped trees are newer than the dist. |
| 131 | + await touch(join(root, 'src'), sourceTime); |
| 132 | + const result = distFreshness(descriptor); |
| 133 | + expect(result.status).toBe('fresh'); |
| 134 | + expect(new Date(result.newestInput.mtimeMs)).toEqual(sourceTime); |
| 135 | + for (const name of ['node_modules', 'dist', '.rstest-temp', '.git', '.agent-bundle']) { |
| 136 | + expect(isSkippedInputDirectory(name), name).toBe(true); |
| 137 | + } |
| 138 | + for (const name of ['src', 'lib', 'chunks', 'dist-tools']) { |
| 139 | + expect(isSkippedInputDirectory(name), name).toBe(false); |
| 140 | + } |
| 141 | + }); |
| 142 | + |
| 143 | + it('walks an input root that is itself a dist, so a rebuilt workspace dependency flags a bundle that embeds it', async () => { |
| 144 | + const { descriptor, root } = await createPackageFixture(); |
| 145 | + await writeFiles(root, ['../shared-lib/dist/index.js']); |
| 146 | + const sharedDist = resolve(root, '../shared-lib/dist'); |
| 147 | + await stampTree(sharedDist, editTime); |
| 148 | + const result = distFreshness({ ...descriptor, inputs: [...descriptor.inputs, '../shared-lib/dist'] }); |
| 149 | + expect(result.status).toBe('stale'); |
| 150 | + // The directory and its file carry the same stamp; either is the evidence. |
| 151 | + expect(result.newestInput.path.startsWith(sharedDist)).toBe(true); |
| 152 | + expect(new Date(result.newestInput.mtimeMs)).toEqual(editTime); |
| 153 | + }); |
| 154 | + |
| 155 | + it('ignores dot-directories inside the dist, so a temp writer there cannot mask staleness', async () => { |
| 156 | + const { descriptor, root } = await createPackageFixture(); |
| 157 | + await touch(join(root, 'src/index.ts'), editTime); |
| 158 | + await writeFiles(root, ['dist/.rstest-temp/scratch.js']); |
| 159 | + await stampTree(join(root, 'dist/.rstest-temp'), new Date('2027-01-01T00:00:00Z')); |
| 160 | + const result = distFreshness(descriptor); |
| 161 | + expect(result.status).toBe('stale'); |
| 162 | + expect(new Date(result.newestOutput?.mtimeMs ?? 0)).toEqual(buildTime); |
| 163 | + }); |
| 164 | + |
| 165 | + it('rejects a descriptor none of whose inputs exist', async () => { |
| 166 | + const { descriptor } = await createPackageFixture(); |
| 167 | + expect(() => distFreshness({ ...descriptor, inputs: ['nope', 'also-nope.ts'] })).toThrow(/none of the 2 declared inputs of fixture exist/u); |
| 168 | + }); |
| 169 | +}); |
| 170 | + |
| 171 | +describe('newestEntry', () => { |
| 172 | + it('returns the file itself for a file, undefined for a missing path, and the newest file of a tree', async () => { |
| 173 | + const { root } = await createPackageFixture(); |
| 174 | + expect(newestEntry(join(root, 'package.json'))).toEqual({ mtimeMs: sourceTime.getTime(), path: join(root, 'package.json') }); |
| 175 | + expect(newestEntry(join(root, 'missing'))).toBeUndefined(); |
| 176 | + await touch(join(root, 'dist/chunks/shared.js'), editTime); |
| 177 | + expect(newestEntry(join(root, 'dist'))).toEqual({ mtimeMs: editTime.getTime(), path: join(root, 'dist/chunks/shared.js') }); |
| 178 | + }); |
| 179 | +}); |
| 180 | + |
| 181 | +describe('formatDistFreshnessFailure and assertFreshDist', () => { |
| 182 | + const fresh: DistFreshness = { |
| 183 | + name: 'fresh-package', |
| 184 | + newestInput: { mtimeMs: sourceTime.getTime(), path: '/repo/packages/fresh/src/index.ts' }, |
| 185 | + newestOutput: { mtimeMs: buildTime.getTime(), path: '/repo/packages/fresh/dist/index.js' }, |
| 186 | + output: '/repo/packages/fresh/dist', |
| 187 | + status: 'fresh', |
| 188 | + }; |
| 189 | + const stale: DistFreshness = { |
| 190 | + name: '@agent-bundle/runtime', |
| 191 | + newestInput: { mtimeMs: editTime.getTime(), path: '/repo/packages/rsc-runtime/src/index.ts' }, |
| 192 | + newestOutput: { mtimeMs: buildTime.getTime(), path: '/repo/packages/rsc-runtime/dist/index.js' }, |
| 193 | + output: '/repo/packages/rsc-runtime/dist', |
| 194 | + status: 'stale', |
| 195 | + }; |
| 196 | + const missing: DistFreshness = { |
| 197 | + name: 'agent-bundle-workbench', |
| 198 | + newestInput: { mtimeMs: sourceTime.getTime(), path: '/repo/packages/workbench/src/main.tsx' }, |
| 199 | + newestOutput: undefined, |
| 200 | + output: '/repo/packages/workbench/dist', |
| 201 | + status: 'missing', |
| 202 | + }; |
| 203 | + |
| 204 | + it('names every stale or missing output with its evidence and ends with the rebuild instruction', () => { |
| 205 | + const message = formatDistFreshnessFailure([fresh, stale, missing], { relativeTo: '/repo' }); |
| 206 | + expect(message).not.toContain('fresh-package'); |
| 207 | + expect(message).toContain( |
| 208 | + ' @agent-bundle/runtime: stale — packages/rsc-runtime/src/index.ts (2026-01-03T00:00:00.000Z)' |
| 209 | + + ' is newer than packages/rsc-runtime/dist/index.js (2026-01-02T00:00:00.000Z)', |
| 210 | + ); |
| 211 | + expect(message).toContain(' agent-bundle-workbench: missing — packages/workbench/dist has no built files'); |
| 212 | + expect(message.endsWith('run `pnpm build`.')).toBe(true); |
| 213 | + }); |
| 214 | + |
| 215 | + it('prints paths outside relativeTo as they are', () => { |
| 216 | + expect(formatDistFreshnessFailure([stale], { relativeTo: '/elsewhere' })).toContain('/repo/packages/rsc-runtime/src/index.ts'); |
| 217 | + }); |
| 218 | + |
| 219 | + it('formats an all-fresh result set to the empty string', () => { |
| 220 | + expect(formatDistFreshnessFailure([fresh])).toBe(''); |
| 221 | + }); |
| 222 | + |
| 223 | + it('assertFreshDist throws the message for a stale fixture and returns for a fresh one', async () => { |
| 224 | + const { descriptor, root } = await createPackageFixture(); |
| 225 | + expect(() => assertFreshDist([descriptor])).not.toThrow(); |
| 226 | + await touch(join(root, 'src/index.ts'), editTime); |
| 227 | + expect(() => assertFreshDist([descriptor], { relativeTo: root })).toThrow( |
| 228 | + /^Built output is stale or missing[\s\S]*fixture: stale — src\/index\.ts[\s\S]*run `pnpm build`\.$/u, |
| 229 | + ); |
| 230 | + expect(checkDistFreshness([descriptor]).map((result) => result.status)).toEqual(['stale']); |
| 231 | + }); |
| 232 | +}); |
| 233 | + |
| 234 | +describe('workspace descriptors', () => { |
| 235 | + it('lists every dist `pnpm build` produces, in build order, and every declared input exists in the repository', () => { |
| 236 | + const outputs = workspaceBuildOutputs(workspaceRoot); |
| 237 | + expect(outputs.map((output) => output.name)).toEqual([ |
| 238 | + 'rsc-markdown-stream', |
| 239 | + '@agent-bundle/runtime', |
| 240 | + 'agent-bundle-workbench', |
| 241 | + 'agent-bundle', |
| 242 | + 'create-agent-bundle', |
| 243 | + ]); |
| 244 | + for (const output of outputs) { |
| 245 | + expect(output.output).toBe('dist'); |
| 246 | + expect(output.root.startsWith(join(workspaceRoot, 'packages'))).toBe(true); |
| 247 | + for (const input of output.inputs) { |
| 248 | + expect(existsSync(resolve(output.root, input)), `${output.name}: ${input}`).toBe(true); |
| 249 | + } |
| 250 | + expect(output.inputs).toContain('src'); |
| 251 | + expect(output.inputs).toContain('package.json'); |
| 252 | + expect(output.inputs).toContain('../../pnpm-lock.yaml'); |
| 253 | + } |
| 254 | + }); |
| 255 | + |
| 256 | + it('gives agent-bundle the Workbench build inputs its Rslib build copies from', () => { |
| 257 | + const agentBundle = workspaceBuildOutputs(workspaceRoot).find((output) => output.name === 'agent-bundle'); |
| 258 | + expect(agentBundle?.inputs).toEqual(expect.arrayContaining(['../workbench/src', '../workbench/index.html', '../workbench/rsbuild.config.ts', '../workbench/THIRD_PARTY_NOTICES'])); |
| 259 | + expect(agentBundle?.inputs).not.toContain('../workbench/dist'); |
| 260 | + }); |
| 261 | + |
| 262 | + it('describes the runtime example per payload tree, bundling the workspace runtime dists as inputs', () => { |
| 263 | + const outputs = runtimeExampleBuildOutputs(workspaceRoot, ['app', 'runtime']); |
| 264 | + expect(outputs.map((output) => output.output)).toEqual(['dist/app', 'dist/runtime']); |
| 265 | + for (const output of outputs) { |
| 266 | + expect(output.root).toBe(join(workspaceRoot, 'examples/rsc-agent-runtime')); |
| 267 | + expect(output.inputs).toEqual(expect.arrayContaining(['src', 'rsbuild.config.ts', '../../packages/rsc-runtime/dist', '../../packages/rsc-markdown-stream/dist'])); |
| 268 | + // The dist inputs may be absent on a cold tree; every tracked input exists. |
| 269 | + for (const input of output.inputs.filter((path) => !path.endsWith('/dist'))) { |
| 270 | + expect(existsSync(resolve(output.root, input)), input).toBe(true); |
| 271 | + } |
| 272 | + } |
| 273 | + expect(runtimeExampleBuildOutputs(workspaceRoot)).toHaveLength(2); |
| 274 | + }); |
| 275 | +}); |
0 commit comments