/ utils / testBuildRepro.ts
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  }