Pete Bacon Darwin 29f9888a98 test(compiler-cli): clarify source-map expectations in compliance tests (#42062)
The compliance tests can check source-map segments against expectations
encoded into the expectation files. Previously, the encoding of the expected
segment was only delimited by whitespace, but this made it difficult to identify
segments that started or ended with whitespace.

Now these segment expectations are wrapped in double-quotes which makes
it easier to read and understand the expectation files.

PR Close #42062
2021-08-02 09:53:12 -07:00

214 lines
8.2 KiB
TypeScript

/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/
import {AbsoluteFsPath, ReadonlyFileSystem} from '../../../src/ngtsc/file_system';
import {ConsoleLogger, LogLevel} from '../../../src/ngtsc/logging';
import {SourceFileLoader} from '../../../src/ngtsc/sourcemaps';
/**
* Check the source-mappings of the generated source file against mappings stored in the expected
* source file.
*
* The source-mappings are encoded into the expected source file in the form of an end-of-line
* comment that has the following syntax:
*
* ```
* <generated code> // SOURCE: "</path/to/original>" "<original source>"
* ```
*
* The `path/to/original` path will be absolute within the mock file-system, where the root is the
* directory containing the `TEST_CASES.json` file. The `generated code` and the `original source`
* are not trimmed of whitespace - but there is a single space after the generated and a single
* space before the original source.
*
* @param fs The test file-system where the source, generated and expected files are stored.
* @param generated The content of the generated source file.
* @param generatedPath The absolute path, within the test file-system, of the generated source
* file.
* @param expectedSource The content of the expected source file, containing mapping information.
* @returns The content of the expected source file, stripped of the mapping information.
*/
export function checkMappings(
fs: ReadonlyFileSystem, generated: string, generatedPath: AbsoluteFsPath,
expectedSource: string, expectedPath: AbsoluteFsPath): string {
const actualMappings = getMappedSegments(fs, generatedPath, generated);
const {expected, mappings} = extractMappings(fs, expectedSource);
const failures: string[] = [];
for (const expectedMapping of mappings) {
const failure = checkMapping(actualMappings, expectedMapping);
if (failure !== null) {
failures.push(failure);
}
}
if (failures.length > 0) {
throw new Error(
`When checking mappings for ${generatedPath} against ${expectedPath} expected...\n\n` +
`${failures.join('\n\n')}\n\n` +
`All the mappings:\n\n${dumpMappings(actualMappings)}`);
}
return expected;
}
/**
* A mapping of a segment of generated text to a segment of source text.
*/
interface SegmentMapping {
/** The generated text in this segment. */
generated: string;
/** The source text in this segment. */
source: string;
/** The URL of the source file for this segment. */
sourceUrl: string;
}
/**
* Extract the source-map information (encoded in comments - see `checkMappings()`) from the given
* `expected` source content, returning both the `mappings` and the `expected` source code, stripped
* of the source-mapping comments.
*
* @param expected The content of the expected file containing source-map information.
*/
function extractMappings(
fs: ReadonlyFileSystem, expected: string): {expected: string, mappings: SegmentMapping[]} {
const mappings: SegmentMapping[] = [];
// capture and remove source mapping info
expected = expected.replace(
/^(.*?) \/\/ SOURCE: "([^"]*?)" "(.*?)"$/gm,
(_, rawGenerated: string, rawSourceUrl: string, rawSource: string) => {
// Since segments need to appear on a single line in the expected file, any newlines in the
// segment being checked must be escaped in the expected file and then unescaped here before
// being checked.
const generated = unescape(rawGenerated);
const source = unescape(rawSource);
const sourceUrl = fs.resolve(rawSourceUrl);
mappings.push({generated, sourceUrl, source});
return generated;
});
return {expected, mappings};
}
function unescape(str: string): string {
const replacements: Record<any, string> = {'\\n': '\n', '\\r': '\r', '\\\\': '\\', '\\"': '\"'};
return str.replace(/\\[rn"\\]/g, match => replacements[match]);
}
/**
* Process a generated file to extract human understandable segment mappings.
*
* These mappings are easier to compare in unit tests than the raw SourceMap mappings.
*
* @param fs the test file-system that holds the source and generated files.
* @param generatedPath The path of the generated file to process.
* @param generatedContents The contents of the generated file to process.
* @returns An array of segment mappings for each mapped segment in the given generated file. An
* empty array is returned if there is no source-map file found.
*/
function getMappedSegments(
fs: ReadonlyFileSystem, generatedPath: AbsoluteFsPath,
generatedContents: string): SegmentMapping[] {
const logger = new ConsoleLogger(LogLevel.debug);
const loader = new SourceFileLoader(fs, logger, {});
const generatedFile = loader.loadSourceFile(generatedPath, generatedContents);
if (generatedFile === null) {
return [];
}
const segments: SegmentMapping[] = [];
for (let i = 0; i < generatedFile.flattenedMappings.length - 1; i++) {
const mapping = generatedFile.flattenedMappings[i];
const generatedStart = mapping.generatedSegment;
const generatedEnd = generatedFile.flattenedMappings[i + 1].generatedSegment;
const originalFile = mapping.originalSource;
const originalStart = mapping.originalSegment;
let originalEnd = originalStart.next;
// Skip until we find an end segment that is after the start segment
while (originalEnd !== undefined && originalEnd.next !== originalEnd &&
originalEnd.position === originalStart.position) {
originalEnd = originalEnd.next;
}
if (originalEnd === undefined || originalEnd.next === originalEnd) {
continue;
}
const segment = {
generated: generatedFile.contents.substring(generatedStart.position, generatedEnd.position),
source: originalFile.contents.substring(originalStart.position, originalEnd!.position),
sourceUrl: originalFile.sourcePath
};
segments.push(segment);
}
return segments;
}
/**
* Check that the `expected` segment appears in the collection of `mappings`.
*
* @returns An error message if a matching segment cannot be found, or null if it can.
*/
function checkMapping(mappings: SegmentMapping[], expected: SegmentMapping): string|null {
if (mappings.some(
m => m.generated === expected.generated && m.source === expected.source &&
m.sourceUrl === expected.sourceUrl)) {
return null;
}
const matchingGenerated = mappings.filter(m => m.generated === expected.generated);
const matchingSource = mappings.filter(m => m.source === expected.source);
const message = [
'Expected mappings to contain the following mapping',
prettyPrintMapping(expected),
];
if (matchingGenerated.length > 0) {
message.push('');
message.push('There are the following mappings that match the generated text:');
matchingGenerated.forEach(m => message.push(prettyPrintMapping(m)));
}
if (matchingSource.length > 0) {
message.push('');
message.push('There are the following mappings that match the source text:');
matchingSource.forEach(m => message.push(prettyPrintMapping(m)));
}
return message.join('\n');
}
function prettyPrintMapping(mapping: SegmentMapping): string {
return [
'{',
` generated: ${JSON.stringify(mapping.generated)}`,
` source : ${JSON.stringify(mapping.source)}`,
` sourceUrl: ${JSON.stringify(mapping.sourceUrl)}`,
'}',
].join('\n');
}
/**
* Helper function for debugging failed mappings.
* This lays out the segment mappings in the console to make it easier to compare.
*/
function dumpMappings(mappings: SegmentMapping[]): string {
return mappings
.map(
mapping => padValue(mapping.sourceUrl, 20, 0) + ' : ' +
padValue(JSON.stringify(mapping.source), 100, 23) + ' : ' +
JSON.stringify(mapping.generated))
.join('\n');
}
function padValue(value: string, max: number, start: number): string {
const padding = value.length > max ? ('\n' +
' '.repeat(max + start)) :
' '.repeat(max - value.length);
return value + padding;
}