Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 70 additions & 3 deletions scripts/__tests__/comparators.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -77,8 +77,6 @@ test('comparePerformance summarizes benchmark differences', async t => {
result,
/\*\*Peak memory:\*\* 50\.0% higher \(1\.00 MB → 1\.50 MB\)/
);
assert.match(result, /single CI run/);
assert.doesNotMatch(result, /CPU time/);
});

test('comparePerformance omits results when an artifact has no benchmark', async t => {
Expand Down Expand Up @@ -139,12 +137,81 @@ test('object comparator treats benchmark data as metadata', async t => {

const result = await runComparator('object-assertion', base, head);

assert.equal(result.match(/## `test` Generator/g)?.length, 1);
assert.doesNotMatch(result, /\*\*Output:/);
assert.match(result, /Performance estimate/);
assert.doesNotMatch(result, /benchmark\.json/);
});

test('comparators pair renamed files that kept their contents', async t => {
const { base, head } = await createDirectories(t);
const baseAssets = path.join(base, 'assets');
const headAssets = path.join(head, 'assets');

await Promise.all([mkdir(baseAssets), mkdir(headAssets)]);
await Promise.all([
writeFile(
path.join(baseAssets, 'compat.module-2nt43HHR.mjs'),
'export const compat = 1;',
'utf8'
),
writeFile(
path.join(headAssets, 'compat.module-CSTB7Jvj.mjs'),
'export const compat = 1;',
'utf8'
),
]);

const result = await runComparator('file-size', base, head);

assert.match(result, /Output size:\*\* 1 file renamed/);
assert.doesNotMatch(result, /changed · net/);
assert.match(
result,
/- `assets\/compat\.module-2nt43HHR\.mjs → assets\/compat\.module-CSTB7Jvj\.mjs`$/m
);
});

test('comparators weigh a renamed file against the one it replaced', async t => {
const { base, head } = await createDirectories(t);
const baseAssets = path.join(base, 'assets');
const headAssets = path.join(head, 'assets');

await Promise.all([mkdir(baseAssets), mkdir(headAssets)]);
await Promise.all([
writeFile(path.join(baseAssets, 'index-2nt43HHR.mjs'), 'a', 'utf8'),
writeFile(
path.join(headAssets, 'index-CSTB7Jvj.mjs'),
'a much longer',
'utf8'
),
]);

const result = await runComparator('file-size', base, head);

assert.match(result, /Output size:\*\* 1 file changed · net \+12\.00 B/);
assert.match(
result,
/`assets\/index-2nt43HHR\.mjs → assets\/index-CSTB7Jvj\.mjs` \| 1\.00 B \| 13\.00 B \| \+12\.00 B/
);
assert.doesNotMatch(result, /—/);
});

test('comparators keep unrelated files apart when only the name looks hashed', async t => {
const { base, head } = await createDirectories(t);

// Both names end in eight characters, but neither reads like a base64 hash.
await Promise.all([
writeFile(path.join(base, 'bundle-longname.mjs'), 'a', 'utf8'),
writeFile(path.join(head, 'bundle-nickname.mjs'), 'a much longer', 'utf8'),
]);

const result = await runComparator('file-size', base, head);

assert.match(result, /2 files changed/);
assert.match(result, /`bundle-longname\.mjs` \| 1\.00 B \| —/);
assert.match(result, /`bundle-nickname\.mjs` \| — \| 13\.00 B/);
});

test('comparators report added and removed output files', async t => {
const { base, head } = await createDirectories(t);
const baseOutput = path.join(base, 'generator');
Expand Down
98 changes: 55 additions & 43 deletions scripts/comparators/file-size.mjs
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { stat } from 'node:fs/promises';
import path from 'node:path';

import { BASE, HEAD, TITLE } from '../constants.mjs';
import { listOutputFiles } from './files.mjs';
import { BASE, HEAD } from '../constants.mjs';
import { pairOutputFiles } from './files.mjs';
import { comparePerformance } from './performance.mjs';
import { count, isRename, pairName, report } from './report.mjs';

const UNITS = ['B', 'KB', 'MB', 'GB'];

Expand All @@ -21,74 +22,85 @@ const formatBytes = bytes => {
return `${(bytes / Math.pow(1024, i)).toFixed(2)} ${UNITS[i]}`;
};

/**
* Gets all files in a directory with their sizes
* @param {string} dir - Directory path to scan
* @returns {Promise<Map<string, number>>} Map of filename to size in bytes
*/
const getStats = async dir => {
const files = await listOutputFiles(dir);
return new Map(
await Promise.all(
files.map(async f => [f, (await stat(path.join(dir, f))).size])
)
);
};
const sizeOf = async (directory, file) =>
file ? (await stat(path.join(directory, file))).size : 0;

// Fetch stats for both directories in parallel
const [baseStats, headStats] = await Promise.all([BASE, HEAD].map(getStats));
const entries = await Promise.all(
(await pairOutputFiles(BASE, HEAD)).map(async pair => {
const [base, head] = await Promise.all([
sizeOf(BASE, pair.base),
sizeOf(HEAD, pair.head),
]);

const didChange = f => baseStats.get(f) !== headStats.get(f);
return { ...pair, baseSize: base, headSize: head, diff: head - base };
})
);

const toDiffObject = f => ({
file: f,
base: baseStats.get(f) ?? 0,
head: headStats.get(f) ?? 0,
diff: (headStats.get(f) ?? 0) - (baseStats.get(f) ?? 0),
});
// A renamed file that weighs the same weighs the same: its hash turned over,
// which earns a line but not a row among the real size movements.
const renamed = entries.filter(entry => isRename(entry) && !entry.diff);

// Find files whose presence or size changed, then show the largest changes first.
const changed = [...new Set([...baseStats.keys(), ...headStats.keys()])]
.filter(didChange)
.map(toDiffObject)
const changed = entries
.filter(({ base, head, diff }) => diff || !base || !head)
.sort((a, b) => Math.abs(b.diff) - Math.abs(a.diff));

const sections = [];

// Output markdown table if there are changes
if (changed.length) {
if (changed.length || renamed.length) {
const totalDiff = changed.reduce((total, { diff }) => total + diff, 0);
const totalSign = totalDiff > 0 ? '+' : '';
const rows = changed.map(({ file, base, head, diff }) => {

const rows = changed.map(entry => {
const { base, head, baseSize, headSize, diff } = entry;
const sign = diff > 0 ? '+' : '';
const percent =
base === 0 ? '' : ` (${sign}${((diff / base) * 100).toFixed(1)}%)`;
baseSize === 0
? ''
: ` (${sign}${((diff / baseSize) * 100).toFixed(1)}%)`;
const diffFormatted = `${sign}${formatBytes(diff)}${percent}`;

return `| \`${file}\` | ${baseStats.has(file) ? formatBytes(base) : '—'} | ${headStats.has(file) ? formatBytes(head) : '—'} | ${diffFormatted} |`;
return `| \`${pairName(entry)}\` | ${base ? formatBytes(baseSize) : '—'} | ${head ? formatBytes(headSize) : '—'} | ${diffFormatted} |`;
});

const summary = [
changed.length &&
`${count(changed.length, 'file', 'files')} changed · net ${totalSign}${formatBytes(totalDiff)}`,
renamed.length && `${count(renamed.length, 'file', 'files')} renamed`,
].filter(Boolean);

const details = [
changed.length &&
[
'| File | Main | PR | Change |',
'| --- | ---: | ---: | ---: |',
rows.join('\n'),
].join('\n'),
renamed.length &&
[
'**Renamed** <sub>(identical contents unless noted)</sub>',
renamed
.map(
entry =>
`- \`${pairName(entry)}\`${entry.identical ? '' : ' <sub>(same size, different contents)</sub>'}`
)
.join('\n'),
].join('\n'),
].filter(Boolean);

sections.push(
[
`**Output size:** ${changed.length} ${changed.length === 1 ? 'file' : 'files'} changed · net ${totalSign}${formatBytes(totalDiff)}`,
`**Output size:** ${summary.join(' · ')}`,
'',
'<details>',
'<summary>File size details</summary>',
'',
'| File | Main | PR | Change |',
'| --- | ---: | ---: | ---: |',
rows.join('\n'),
details.join('\n\n'),
'',
'</details>',
].join('\n')
);
}

const performance = await comparePerformance();
if (performance) {
sections.push(performance);
}

if (sections.length) {
console.log(`${TITLE}\n\n${sections.join('\n\n')}\n`);
}
report(sections, await comparePerformance());
89 changes: 88 additions & 1 deletion scripts/comparators/files.mjs
Original file line number Diff line number Diff line change
@@ -1,9 +1,18 @@
import { glob } from 'node:fs/promises';
import { createHash } from 'node:crypto';
import { glob, readFile } from 'node:fs/promises';
import path from 'node:path';

import { BENCHMARK_FILE, COMPARISON_FILE } from '../constants.mjs';

const METADATA_FILES = new Set([BENCHMARK_FILE, COMPARISON_FILE]);
const HASH_STAMP = /-([A-Za-z0-9_-]{8})(?=\.[^./]+$)/;

const isStamp = value => /\d/.test(value) && /[A-Z]/.test(value);

const withoutStamp = file =>
file.replace(HASH_STAMP, (stamp, hash) =>
isStamp(hash) ? '-[hash]' : stamp
);

export const listOutputFiles = async directory => {
const entries = glob('**/*', {
Expand All @@ -19,3 +28,81 @@ export const listOutputFiles = async directory => {
)
.sort();
};

const hashFiles = async (directory, files) =>
new Map(
await Promise.all(
files.map(async file => [
file,
createHash('sha256')
.update(await readFile(path.join(directory, file)))
.digest('hex'),
])
)
);

const groupBy = (files, key) =>
files.reduce((groups, file) => {
const group = key(file);
return groups.set(group, [...(groups.get(group) ?? []), file]);
}, new Map());

/**
* Takes the first candidate under `key` that nothing else has claimed yet
*/
const claim = (groups, key, unclaimed) => {
const match = (groups.get(key) ?? []).find(file => unclaimed.has(file));
unclaimed.delete(match);
return match;
};

/**
* Pairs base and head outputs into one entry per file
*/
export const pairOutputFiles = async (baseDirectory, headDirectory) => {
const [baseFiles, headFiles] = await Promise.all(
[baseDirectory, headDirectory].map(listOutputFiles)
);

const headNames = new Set(headFiles);
const kept = baseFiles.filter(file => headNames.has(file));
const keptNames = new Set(kept);

const baseOnly = baseFiles.filter(file => !keptNames.has(file));
const headOnly = headFiles.filter(file => !keptNames.has(file));

// Only the leftovers are read; equal names already agree on identity.
const [baseHashes, headHashes] = await Promise.all([
hashFiles(baseDirectory, baseOnly),
hashFiles(headDirectory, headOnly),
]);

const unclaimed = new Set(headOnly);
const byContent = groupBy(headOnly, file => headHashes.get(file));
const bySlot = groupBy(headOnly, withoutStamp);

const matched = baseOnly
// Equal bytes are proof of a match, so every one of them is settled before
// a slot is handed out on the weaker evidence of a name.
.map(base => ({
base,
head: claim(byContent, baseHashes.get(base), unclaimed),
}))
.map(({ base, head }) =>
head
? { base, head, identical: true }
: {
base,
head: claim(bySlot, withoutStamp(base), unclaimed),
identical: false,
}
);

const sortKey = ({ base, head }) => head ?? base;

return [
...kept.map(file => ({ base: file, head: file, identical: false })),
...matched,
...[...unclaimed].map(head => ({ head, identical: false })),
].sort((a, b) => (sortKey(a) < sortKey(b) ? -1 : 1));
};
Loading
Loading