mirror of
https://github.com/github/codeql-action.git
synced 2025-12-29 18:50:25 +08:00
508 lines
13 KiB
JavaScript
508 lines
13 KiB
JavaScript
import {Buffer} from 'node:buffer';
|
|
import crypto from 'node:crypto';
|
|
import fs from 'node:fs';
|
|
import {findSourceMap} from 'node:module';
|
|
import path from 'node:path';
|
|
import {fileURLToPath} from 'node:url';
|
|
import zlib from 'node:zlib';
|
|
|
|
import cbor from 'cbor';
|
|
import concordance from 'concordance';
|
|
import indentString from 'indent-string';
|
|
import mem from 'mem';
|
|
import writeFileAtomic from 'write-file-atomic';
|
|
|
|
import {snapshotManager as concordanceOptions} from './concordance-options.js';
|
|
import slash from './slash.cjs';
|
|
|
|
// Increment if encoding layout or Concordance serialization versions change. Previous AVA versions will not be able to
|
|
// decode buffers generated by a newer version, so changing this value will require a major version bump of AVA itself.
|
|
// The version is encoded as an unsigned 16 bit integer.
|
|
const VERSION = 3;
|
|
|
|
const VERSION_HEADER = Buffer.alloc(2);
|
|
VERSION_HEADER.writeUInt16LE(VERSION);
|
|
|
|
// The decoder matches on the trailing newline byte (0x0A).
|
|
const READABLE_PREFIX = Buffer.from(`AVA Snapshot v${VERSION}\n`, 'ascii');
|
|
const REPORT_SEPARATOR = Buffer.from('\n\n', 'ascii');
|
|
const REPORT_TRAILING_NEWLINE = Buffer.from('\n', 'ascii');
|
|
|
|
const SHA_256_HASH_LENGTH = 32;
|
|
|
|
export class SnapshotError extends Error {
|
|
constructor(message, snapPath) {
|
|
super(message);
|
|
this.name = 'SnapshotError';
|
|
this.snapPath = snapPath;
|
|
}
|
|
}
|
|
|
|
export class ChecksumError extends SnapshotError {
|
|
constructor(snapPath) {
|
|
super('Checksum mismatch', snapPath);
|
|
this.name = 'ChecksumError';
|
|
}
|
|
}
|
|
|
|
export class VersionMismatchError extends SnapshotError {
|
|
constructor(snapPath, version) {
|
|
super('Unexpected snapshot version', snapPath);
|
|
this.name = 'VersionMismatchError';
|
|
this.snapVersion = version;
|
|
this.expectedVersion = VERSION;
|
|
}
|
|
}
|
|
|
|
export class InvalidSnapshotError extends SnapshotError {
|
|
constructor(snapPath) {
|
|
super('Invalid snapshot file', snapPath);
|
|
this.name = 'InvalidSnapshotError';
|
|
}
|
|
}
|
|
|
|
const LEGACY_SNAPSHOT_HEADER = Buffer.from('// Jest Snapshot v1');
|
|
function isLegacySnapshot(buffer) {
|
|
return LEGACY_SNAPSHOT_HEADER.equals(buffer.slice(0, LEGACY_SNAPSHOT_HEADER.byteLength));
|
|
}
|
|
|
|
export class LegacyError extends SnapshotError {
|
|
constructor(snapPath) {
|
|
super('Legacy snapshot file', snapPath);
|
|
this.name = 'LegacyError';
|
|
}
|
|
}
|
|
|
|
function tryRead(file) {
|
|
try {
|
|
return fs.readFileSync(file);
|
|
} catch (error) {
|
|
if (error.code === 'ENOENT') {
|
|
return null;
|
|
}
|
|
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
function formatEntry(snapshot, index) {
|
|
const {
|
|
data,
|
|
label = `Snapshot ${index + 1}`, // Human-readable labels start counting at 1.
|
|
} = snapshot;
|
|
|
|
const description = data
|
|
? concordance.formatDescriptor(concordance.deserialize(data), concordanceOptions)
|
|
: '<No Data>';
|
|
|
|
const blockquote = label.split(/\n/).map(line => '> ' + line).join('\n');
|
|
|
|
return `${blockquote}\n\n${indentString(description, 4)}`;
|
|
}
|
|
|
|
function combineEntries({blocks}) {
|
|
const combined = new BufferBuilder();
|
|
|
|
for (const {title, snapshots} of blocks) {
|
|
const last = snapshots[snapshots.length - 1];
|
|
combined.write(`\n\n## ${title}\n\n`);
|
|
|
|
for (const [index, snapshot] of snapshots.entries()) {
|
|
combined.write(formatEntry(snapshot, index));
|
|
|
|
if (snapshot !== last) {
|
|
combined.write(REPORT_SEPARATOR);
|
|
}
|
|
}
|
|
}
|
|
|
|
return combined;
|
|
}
|
|
|
|
function generateReport(relFile, snapFile, snapshots) {
|
|
return new BufferBuilder()
|
|
.write(`# Snapshot report for \`${slash(relFile)}\`
|
|
|
|
The actual snapshot is saved in \`${snapFile}\`.
|
|
|
|
Generated by [AVA](https://avajs.dev).`)
|
|
.append(combineEntries(snapshots))
|
|
.write(REPORT_TRAILING_NEWLINE)
|
|
.toBuffer();
|
|
}
|
|
|
|
class BufferBuilder {
|
|
constructor() {
|
|
this.buffers = [];
|
|
this.byteOffset = 0;
|
|
}
|
|
|
|
append(builder) {
|
|
this.buffers.push(...builder.buffers);
|
|
this.byteOffset += builder.byteOffset;
|
|
return this;
|
|
}
|
|
|
|
write(data) {
|
|
if (typeof data === 'string') {
|
|
this.write(Buffer.from(data, 'utf8'));
|
|
} else {
|
|
this.buffers.push(data);
|
|
this.byteOffset += data.byteLength;
|
|
}
|
|
|
|
return this;
|
|
}
|
|
|
|
toBuffer() {
|
|
return Buffer.concat(this.buffers, this.byteOffset);
|
|
}
|
|
}
|
|
|
|
function sortBlocks(blocksByTitle, blockIndices) {
|
|
return [...blocksByTitle].sort(
|
|
([aTitle], [bTitle]) => {
|
|
const a = blockIndices.get(aTitle);
|
|
const b = blockIndices.get(bTitle);
|
|
|
|
if (a === undefined) {
|
|
if (b === undefined) {
|
|
return 0;
|
|
}
|
|
|
|
return 1;
|
|
}
|
|
|
|
if (b === undefined) {
|
|
return -1;
|
|
}
|
|
|
|
return a - b;
|
|
},
|
|
);
|
|
}
|
|
|
|
async function encodeSnapshots(snapshotData) {
|
|
const encoded = await cbor.encodeAsync(snapshotData, {
|
|
omitUndefinedProperties: true,
|
|
canonical: true,
|
|
});
|
|
const compressed = zlib.gzipSync(encoded);
|
|
compressed[9] = 0x03; // Override the GZip header containing the OS to always be Linux
|
|
const sha256sum = crypto.createHash('sha256').update(compressed).digest();
|
|
return Buffer.concat([
|
|
READABLE_PREFIX,
|
|
VERSION_HEADER,
|
|
sha256sum,
|
|
compressed,
|
|
], READABLE_PREFIX.byteLength + VERSION_HEADER.byteLength + SHA_256_HASH_LENGTH + compressed.byteLength);
|
|
}
|
|
|
|
function decodeSnapshots(buffer, snapPath) {
|
|
if (isLegacySnapshot(buffer)) {
|
|
throw new LegacyError(snapPath);
|
|
}
|
|
|
|
// The version starts after the readable prefix, which is ended by a newline
|
|
// byte (0x0A).
|
|
const newline = buffer.indexOf(0x0A);
|
|
if (newline === -1) {
|
|
throw new InvalidSnapshotError(snapPath);
|
|
}
|
|
|
|
const versionOffset = newline + 1;
|
|
const version = buffer.readUInt16LE(versionOffset);
|
|
if (version !== VERSION) {
|
|
throw new VersionMismatchError(snapPath, version);
|
|
}
|
|
|
|
const sha256sumOffset = versionOffset + 2;
|
|
const compressedOffset = sha256sumOffset + SHA_256_HASH_LENGTH;
|
|
const compressed = buffer.slice(compressedOffset);
|
|
|
|
const sha256sum = crypto.createHash('sha256').update(compressed).digest();
|
|
const expectedSum = buffer.slice(sha256sumOffset, compressedOffset);
|
|
if (!sha256sum.equals(expectedSum)) {
|
|
throw new ChecksumError(snapPath);
|
|
}
|
|
|
|
const decompressed = zlib.gunzipSync(compressed);
|
|
return cbor.decode(decompressed);
|
|
}
|
|
|
|
class Manager {
|
|
constructor(options) {
|
|
this.dir = options.dir;
|
|
this.recordNewSnapshots = options.recordNewSnapshots;
|
|
this.updating = options.updating;
|
|
this.relFile = options.relFile;
|
|
this.reportFile = options.reportFile;
|
|
this.reportPath = options.reportPath;
|
|
this.snapFile = options.snapFile;
|
|
this.snapPath = options.snapPath;
|
|
this.oldBlocksByTitle = options.oldBlocksByTitle;
|
|
this.newBlocksByTitle = options.newBlocksByTitle;
|
|
this.blockIndices = new Map();
|
|
this.error = options.error;
|
|
|
|
this.hasChanges = false;
|
|
}
|
|
|
|
touch(title, taskIndex) {
|
|
this.blockIndices.set(title, taskIndex);
|
|
}
|
|
|
|
compare(options) {
|
|
if (this.error) {
|
|
throw this.error;
|
|
}
|
|
|
|
const block = this.newBlocksByTitle.get(options.belongsTo);
|
|
|
|
const snapshot = block && block.snapshots[options.index];
|
|
const data = snapshot && snapshot.data;
|
|
|
|
if (!data) {
|
|
if (!this.recordNewSnapshots) {
|
|
return {pass: false};
|
|
}
|
|
|
|
if (options.deferRecording) {
|
|
const record = this.deferRecord(options);
|
|
return {pass: true, record};
|
|
}
|
|
|
|
this.record(options);
|
|
return {pass: true};
|
|
}
|
|
|
|
const actual = concordance.deserialize(data, concordanceOptions);
|
|
const expected = concordance.describe(options.expected, concordanceOptions);
|
|
const pass = concordance.compareDescriptors(actual, expected);
|
|
|
|
return {actual, expected, pass};
|
|
}
|
|
|
|
recordSerialized({data, label, belongsTo, index}) {
|
|
let block = this.newBlocksByTitle.get(belongsTo);
|
|
if (!block) {
|
|
block = {snapshots: []};
|
|
}
|
|
|
|
const {snapshots} = block;
|
|
|
|
if (index > snapshots.length) {
|
|
throw new RangeError(`Cannot record snapshot ${index} for ${JSON.stringify(belongsTo)}, exceeds expected index of ${snapshots.length}`);
|
|
} else if (index < snapshots.length) {
|
|
if (snapshots[index].data) {
|
|
throw new RangeError(`Cannot record snapshot ${index} for ${JSON.stringify(belongsTo)}, already exists`);
|
|
}
|
|
|
|
snapshots[index] = {data, label};
|
|
} else {
|
|
snapshots.push({data, label});
|
|
}
|
|
|
|
this.newBlocksByTitle.set(belongsTo, block);
|
|
}
|
|
|
|
deferRecord(options) {
|
|
const {expected, belongsTo, label, index} = options;
|
|
const descriptor = concordance.describe(expected, concordanceOptions);
|
|
const data = concordance.serialize(descriptor);
|
|
|
|
return () => { // Must be called in order!
|
|
this.hasChanges = true;
|
|
this.recordSerialized({data, label, belongsTo, index});
|
|
};
|
|
}
|
|
|
|
record(options) {
|
|
const record = this.deferRecord(options);
|
|
record();
|
|
}
|
|
|
|
skipBlock(title) {
|
|
const block = this.oldBlocksByTitle.get(title);
|
|
|
|
if (block) {
|
|
this.newBlocksByTitle.set(title, block);
|
|
}
|
|
}
|
|
|
|
skipSnapshot({belongsTo, index, deferRecording}) {
|
|
const oldBlock = this.oldBlocksByTitle.get(belongsTo);
|
|
let snapshot = oldBlock && oldBlock.snapshots[index];
|
|
|
|
if (!snapshot) {
|
|
snapshot = {};
|
|
}
|
|
|
|
// Retain the label from the old snapshot, so as not to assume that the
|
|
// snapshot.skip() arguments are well-formed.
|
|
|
|
// Defer recording if called in a try().
|
|
if (deferRecording) {
|
|
return () => { // Must be called in order!
|
|
this.recordSerialized({belongsTo, index, ...snapshot});
|
|
};
|
|
}
|
|
|
|
this.recordSerialized({belongsTo, index, ...snapshot});
|
|
}
|
|
|
|
async save() {
|
|
const {dir, relFile, snapFile, snapPath, reportPath} = this;
|
|
|
|
if (this.updating && this.newBlocksByTitle.size === 0) {
|
|
return {
|
|
changedFiles: [cleanFile(snapPath), cleanFile(reportPath)].flat(),
|
|
temporaryFiles: [],
|
|
};
|
|
}
|
|
|
|
if (!this.hasChanges) {
|
|
return null;
|
|
}
|
|
|
|
const snapshots = {
|
|
blocks: sortBlocks(this.newBlocksByTitle, this.blockIndices).map(
|
|
([title, block]) => ({title, ...block}),
|
|
),
|
|
};
|
|
|
|
const buffer = await encodeSnapshots(snapshots);
|
|
const reportBuffer = generateReport(relFile, snapFile, snapshots);
|
|
|
|
await fs.promises.mkdir(dir, {recursive: true});
|
|
|
|
const temporaryFiles = [];
|
|
const tmpfileCreated = file => temporaryFiles.push(file);
|
|
await Promise.all([
|
|
writeFileAtomic(snapPath, buffer, {tmpfileCreated}),
|
|
writeFileAtomic(reportPath, reportBuffer, {tmpfileCreated}),
|
|
]);
|
|
return {
|
|
changedFiles: [snapPath, reportPath],
|
|
temporaryFiles,
|
|
};
|
|
}
|
|
}
|
|
|
|
const resolveSourceFile = mem(file => {
|
|
const sourceMap = findSourceMap(file);
|
|
// Prior to Node.js 18.8.0, the value when a source map could not be found was `undefined`.
|
|
// This changed to `null` in <https://github.com/nodejs/node/pull/43875>. Check both.
|
|
if (sourceMap === undefined || sourceMap === null) {
|
|
return file;
|
|
}
|
|
|
|
const {payload} = sourceMap;
|
|
if (payload.sources.length === 0) { // Hypothetical?
|
|
return file;
|
|
}
|
|
|
|
return payload.sources[0].startsWith('file://')
|
|
? fileURLToPath(payload.sources[0])
|
|
: payload.sources[0];
|
|
});
|
|
|
|
export const determineSnapshotDir = mem(({file, fixedLocation, projectDir}) => {
|
|
const testDir = path.dirname(resolveSourceFile(file));
|
|
if (fixedLocation) {
|
|
const relativeTestLocation = path.relative(projectDir, testDir);
|
|
return path.join(fixedLocation, relativeTestLocation);
|
|
}
|
|
|
|
const parts = new Set(path.relative(projectDir, testDir).split(path.sep));
|
|
if (parts.has('__tests__')) {
|
|
return path.join(testDir, '__snapshots__');
|
|
}
|
|
|
|
if (parts.has('test') || parts.has('tests')) { // Accept tests, even though it's not in the default test patterns
|
|
return path.join(testDir, 'snapshots');
|
|
}
|
|
|
|
return testDir;
|
|
}, {cacheKey: ([{file}]) => file});
|
|
|
|
function determineSnapshotPaths({file, fixedLocation, projectDir}) {
|
|
const dir = determineSnapshotDir({file, fixedLocation, projectDir});
|
|
const relFile = path.relative(projectDir, resolveSourceFile(file));
|
|
const name = path.basename(relFile);
|
|
const reportFile = `${name}.md`;
|
|
const snapFile = `${name}.snap`;
|
|
|
|
return {
|
|
dir,
|
|
relFile,
|
|
snapFile,
|
|
reportFile,
|
|
snapPath: path.join(dir, snapFile),
|
|
reportPath: path.join(dir, reportFile),
|
|
};
|
|
}
|
|
|
|
function cleanFile(file) {
|
|
try {
|
|
fs.unlinkSync(file);
|
|
return [file];
|
|
} catch (error) {
|
|
if (error.code === 'ENOENT') {
|
|
return [];
|
|
}
|
|
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
export function load({file, fixedLocation, projectDir, recordNewSnapshots, updating}) {
|
|
// Keep runner unit tests that use `new Runner()` happy
|
|
if (file === undefined || projectDir === undefined) {
|
|
return new Manager({
|
|
recordNewSnapshots,
|
|
updating,
|
|
oldBlocksByTitle: new Map(),
|
|
newBlocksByTitle: new Map(),
|
|
});
|
|
}
|
|
|
|
const paths = determineSnapshotPaths({file, fixedLocation, projectDir});
|
|
const buffer = tryRead(paths.snapPath);
|
|
|
|
if (!buffer) {
|
|
return new Manager({
|
|
recordNewSnapshots,
|
|
updating,
|
|
...paths,
|
|
oldBlocksByTitle: new Map(),
|
|
newBlocksByTitle: new Map(),
|
|
});
|
|
}
|
|
|
|
let blocksByTitle;
|
|
let snapshotError;
|
|
|
|
try {
|
|
const data = decodeSnapshots(buffer, paths.snapPath);
|
|
blocksByTitle = new Map(data.blocks.map(({title, ...block}) => [title, block]));
|
|
} catch (error) {
|
|
blocksByTitle = new Map();
|
|
|
|
if (!updating) { // Discard all decoding errors when updating snapshots
|
|
snapshotError = error instanceof SnapshotError
|
|
? error
|
|
: new InvalidSnapshotError(paths.snapPath);
|
|
}
|
|
}
|
|
|
|
return new Manager({
|
|
recordNewSnapshots,
|
|
updating,
|
|
...paths,
|
|
oldBlocksByTitle: blocksByTitle,
|
|
newBlocksByTitle: updating ? new Map() : blocksByTitle,
|
|
error: snapshotError,
|
|
});
|
|
}
|