testBuildRepro.ts
1 import crypto from 'node:crypto'; 2 import fsPromises from 'node:fs/promises'; 3 import path from 'node:path'; 4 import { text } from 'node:stream/consumers'; 5 import { fileURLToPath } from 'node:url'; 6 import util from 'node:util'; 7 import { type Options as ExecaOptions, execaCommand } from 'execa'; 8 import extractZip from 'extract-zip'; 9 import JSZip, { type JSZipObject } from 'jszip'; 10 import { type DefaultRenderer, Listr, type ListrTaskWrapper, type SimpleRenderer } from 'listr2'; 11 import { 12 type DirectoryOptions, 13 type TaskCallback, 14 temporaryDirectory, 15 temporaryDirectoryTask, 16 } from 'tempy'; 17 18 import { name, version } from '../package.json' with { type: 'json' }; 19 20 const ROOT_PATH = path.dirname(path.dirname(fileURLToPath(import.meta.url))); 21 const OUTPUT_DIR = path.join(ROOT_PATH, '.output'); 22 const EXT_ZIP_FILE_NAME = `${name}-${version}-firefox.zip`; 23 24 const BUILD_COMMAND = 'pnpm run release-build'; 25 26 type Ctx = Record<never, never>; 27 28 const isStdoutTty = process.stdout.isTTY; 29 30 tmpdir( 31 async dir => { 32 console.log('Output dir:', OUTPUT_DIR); 33 console.log('Temporary dir:', dir); 34 console.log(); 35 36 const extZipPath = path.join(OUTPUT_DIR, EXT_ZIP_FILE_NAME), 37 sourcesZipPath = path.join(OUTPUT_DIR, `${name}-${version}-sources.zip`); 38 39 const exec = ( 40 task: ListrTaskWrapper<Ctx, typeof DefaultRenderer, typeof SimpleRenderer>, 41 command: string, 42 options?: Omit<ExecaOptions, 'stdio' | 'stdin' | 'stdout' | 'stderr'>, 43 ) => { 44 const execute = execaCommand(command, options); 45 46 execute.stdout.pipe(task.stdout()); 47 execute.stderr.pipe(task.stdout()); 48 49 return execute; 50 }; 51 52 await new Listr<Ctx>([ 53 { 54 title: 'Building reference package', 55 task: (_, task) => exec(task, BUILD_COMMAND), 56 rendererOptions: { outputBar: 10 }, 57 }, 58 { 59 title: `Extracting ${inspect(relPath(extZipPath))} to ${inspect(dir)}`, 60 task: () => extractZip(sourcesZipPath, { dir }), 61 }, 62 { 63 title: 'Installing dependencies', 64 task: (_, task) => exec(task, 'pnpm i -P --frozen-lockfile', { cwd: dir }), 65 rendererOptions: { outputBar: 10 }, 66 }, 67 { 68 title: 'Building package from sources', 69 task: (_, task) => exec(task, BUILD_COMMAND, { cwd: dir }), 70 rendererOptions: { outputBar: 10 }, 71 }, 72 { 73 title: "Comparing extension's files", 74 task: async (_, task) => { 75 const builtZipPath = path.join(dir, '.output', EXT_ZIP_FILE_NAME); 76 77 task.output = `Reading zips ${inspect(relPath(extZipPath))} and ${inspect(builtZipPath)}`; 78 79 const [refZip, builtZip] = await Promise.all([ 80 fsPromises.readFile(extZipPath).then(buf => JSZip.loadAsync(buf)), 81 fsPromises.readFile(builtZipPath).then(buf => JSZip.loadAsync(buf)), 82 ]); 83 84 const errors: string[] = []; 85 86 for (const [path, refObj, builtObj] of iterZipFiles(refZip, builtZip)) { 87 const coloredPath = inspect(path); 88 89 task.output = `Checking ${coloredPath}`; 90 91 if (!builtObj) { 92 errors.push(`${coloredPath} exists in reference, but is missing in built`); 93 continue; 94 } 95 96 if (!refObj) { 97 errors.push(`${coloredPath} exists in built, but is missing in reference`); 98 continue; 99 } 100 101 if (refObj.dir !== builtObj.dir) { 102 errors.push( 103 `${coloredPath} ref.dir = ${refObj.dir}, but built.dir = ${builtObj.dir}`, 104 ); 105 continue; 106 } 107 108 if (refObj.dir) { 109 continue; 110 } 111 112 const [refHash, builtHash] = await Promise.all([ 113 zipObjectHash(refObj), 114 zipObjectHash(builtObj), 115 ]); 116 117 if (refHash !== builtHash) { 118 errors.push( 119 `${coloredPath} hashes mismatch: ${refHash} (ref) != ${builtHash} (built)`, 120 ); 121 } 122 } 123 124 if (errors.length) { 125 throw new Error(errors.map(err => `\n- ${err}`).join('')); 126 } 127 }, 128 }, 129 ]).run(); 130 }, 131 { prefix: `${name}-${version}-build-repro-` }, 132 ); 133 134 function tmpdir(callback: TaskCallback<void>, options: DirectoryOptions) { 135 const isDelete = true; 136 137 if (isDelete) { 138 temporaryDirectoryTask(callback, options); 139 } else { 140 callback(temporaryDirectory(options)); 141 } 142 } 143 144 function inspect(object: unknown) { 145 return util.inspect(object, { colors: isStdoutTty }); 146 } 147 148 function relPath(p: string) { 149 return path.relative(ROOT_PATH, p); 150 } 151 152 function zipObjectHash(obj: JSZipObject) { 153 return text(obj.nodeStream().pipe(crypto.createHash('sha256').setEncoding('hex'), { end: true })); 154 } 155 156 function* iterZipFiles( 157 ref: JSZip, 158 built: JSZip, 159 ): Generator< 160 | [path: string, ref: JSZipObject, built: JSZipObject] 161 | [path: string, ref: null, built: JSZipObject] 162 | [path: string, ref: JSZipObject, built: null], 163 void, 164 undefined 165 > { 166 const refFiles = new Set(Object.keys(ref.files)), 167 builtFiles = new Set(Object.keys(built.files)), 168 allFiles = new Set([...refFiles, ...builtFiles]); 169 170 for (const path of allFiles) { 171 yield [path, ref.files[path], built.files[path]]; 172 } 173 }