Webpack and jest now have line source-map support, but coluns are still lacking

This commit is contained in:
Eloy Durán 2023-07-20 16:05:39 -07:00
Родитель 3c9c005e80
Коммит 2443014f49
14 изменённых файлов: 406 добавлений и 168 удалений

Просмотреть файл

@ -14,6 +14,9 @@
"types": "monorepo-scripts types",
"just": "monorepo-scripts"
},
"dependencies": {
"source-map-js": "^1.0.2"
},
"peerDependencies": {
"ts-jest": "*"
},

Просмотреть файл

@ -0,0 +1,5 @@
describe("a jest test", () => {
it("does nothing really", () => {
expect(true).toBe(true);
});
});

Просмотреть файл

@ -3,7 +3,7 @@ function graphql(..._args: any[]) {
}
describe("a jest test", () => {
it("works", () => {
it("succeeds", () => {
expect(
graphql`
fragment SomeComponent_query on Query {
@ -12,4 +12,14 @@ describe("a jest test", () => {
`,
).toEqual("hello world");
});
it("fails", () => {
expect(
graphql`
fragment SomeComponent_query on Query {
helloWorld
}
`,
).toEqual("bye world");
});
});

Просмотреть файл

@ -1,4 +1,9 @@
import { graphql } from "@nova/react";
const doc = graphql\`query SomeComponentQuery($id: ID!) { helloWorld }\`;
const doc = graphql`
query SomeComponentQuery($id: ID!) {
helloWorld
}
`;
console.log()

Просмотреть файл

@ -1 +1 @@
{"version":3,"sources":["fixture.ts"],"names":[],"mappings":"AACQ,OAAO,EACL,OAAO,EACR,MAAM,aAAa,CAAC;AACrB,MAAM,GAAG,GAHL,AAGQ,OAAO,CAAA,mDAAmD,CAAC,CAHR;AAI/D,OAAO,CAAC,GAAG,EAAE,CAAA","file":"fixture.js","sourceRoot":"","sourcesContent":["import { graphql } from \"@nova/react\";\nconst doc = graphql `query SomeComponentQuery($id: ID!) { helloWorld }`;\nconsole.log();\n//# sourceMappingURL=fixture.js.map"]}
{"version":3,"sources":["fixture.ts"],"names":[],"mappings":"AAAA;AACA;AACA,oBAAoB,AACpB,AACA,AACA,AACA,6DAAS;AACT;AACA","file":"fixture.ts.map","sourcesContent":["\n import { graphql } from \"@nova/react\";\n const doc = graphql`\n query SomeComponentQuery($id: ID!) {\n helloWorld\n }\n `;\n console.log()\n "]}

Просмотреть файл

@ -1,4 +1,5 @@
import { graphql } from "@nova/react";
const doc = require("./__generated__/SomeComponentQuery.graphql").default;
console.log();
//# sourceMappingURL=fixture.js.map
import { graphql } from "@nova/react";
const doc = require("./__generated__/SomeComponentQuery.graphql").default;
console.log()

Просмотреть файл

@ -1,31 +1,65 @@
import { runCLI } from "jest";
import type { Config } from "@jest/types";
import * as path from "path";
import { SourceMapGenerator } from "source-map-js";
describe("jest loader", () => {
it("works", async () => {
let output = "";
jest.spyOn(process.stderr, "write").mockImplementation((chunk) => {
if (typeof chunk === "string") {
output += chunk;
}
return true;
});
expect(await runJestTest("succeeds")).not.toMatch("failed");
});
const roots = [path.join(__dirname, "./fixtures")];
await runCLI(
{
roots,
testRegex: "a-jest-test\\.ts$",
runInBand: true,
useStderr: true,
transform: JSON.stringify({
"\\.ts$": path.join(__dirname, "../ts-jest.ts"),
}),
} as Config.Argv,
roots,
);
xit("uses the source-map to point at the correct failing line and column", async () => {
expect(await runJestTest("fails")).toMatch("a-jest-test.ts:23:7");
});
expect(output).not.toMatch(/failed/i);
it("uses the source-map to point at the correct failing line", async () => {
expect(await runJestTest("fails")).toMatch("a-jest-test.ts:23:1");
});
it("does not perform any extra source-map processing when there were no changes", async () => {
const spy = jest.spyOn(SourceMapGenerator, "fromSourceMap");
await runJestTest("does nothing");
expect(spy).not.toHaveBeenCalled();
});
});
async function runJestTest(testNamePattern: string) {
let output = "";
jest.spyOn(process.stderr, "write").mockImplementation((chunk) => {
if (typeof chunk === "string") {
output += chunk;
}
return true;
});
const roots = [path.join(__dirname, "./fixtures")];
await runCLI(
{
roots,
testRegex:
testNamePattern === "does nothing"
? "a-jest-test-without-doc\\.ts$"
: "a-jest-test\\.ts$",
testNamePattern,
runInBand: true,
useStderr: true,
cache: false,
transform: JSON.stringify({
"\\.ts$": [path.join(__dirname, "../ts-jest.ts"), {}],
}),
} as Config.Argv,
roots,
);
return cleanAnsi(output);
}
// Taken from vscode-jest, which is MIT licensed:
// https://github.com/jest-community/vscode-jest/pull/501/files#diff-ca5696b367d474e785317cb7a0d9853fef5729387ab8d0fab4c46268c03aae99R135-R137
function cleanAnsi(str: string): string {
return str.replace(
// eslint-disable-next-line no-control-regex
/[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g,
"",
);
}

Просмотреть файл

@ -1,5 +1,8 @@
import { RunLoaderResult, runLoaders } from "loader-runner";
import { SourceMapConsumer } from "source-map";
import { SourceMapConsumer, MappingItem } from "source-map-js";
import * as fs from "fs";
import * as path from "path";
import { Position } from "source-map-js";
function runLoader(
source: string,
@ -37,16 +40,36 @@ function runLoader(
});
}
function getPositionOffset(str: string, line: number, column: number): number {
function getPositionOffset(str: string, position: Position): number {
const lines = str.split("\n");
let offset = 0;
for (let i = 0; i < line - 1; i++) {
for (let i = 0; i < position.line - 1; i++) {
offset += lines[i].length + 1;
}
offset += column;
offset += position.column;
return offset;
}
function getGeneratedCodeForOriginalRange(
originalStart: Position,
originalEnd: Position,
sourceMap: SourceMapConsumer,
transpiled: string,
): string {
const generatedStart = sourceMap.generatedPositionFor({
...originalStart,
source: "fixture.ts",
});
const generatedEnd = sourceMap.generatedPositionFor({
...originalEnd,
source: "fixture.ts",
});
return transpiled.slice(
getPositionOffset(transpiled, generatedStart),
getPositionOffset(transpiled, generatedEnd),
);
}
describe("webpackLoader", () => {
it.todo("works with watch query documents");
it.todo("has a dependency on the artefact file");
@ -161,25 +184,16 @@ describe("webpackLoader", () => {
const result = await runLoader(source);
const transpiled = result.result![0]?.toString();
const sourceMap = result.result![1];
const consumer = new SourceMapConsumer(sourceMap!.toString() as any);
const [startPosition, endPosition] = consumer.allGeneratedPositionsFor({
line: 2,
column: undefined as any,
source: "fixture.ts",
});
const startOffset = getPositionOffset(
transpiled!,
startPosition.line,
startPosition.column,
);
const endOffset = getPositionOffset(
transpiled!,
endPosition.line,
endPosition.column,
);
expect(transpiled?.slice(startOffset, endOffset)).toMatchInlineSnapshot(
expect(
getGeneratedCodeForOriginalRange(
{ line: 3, column: 20 },
{ line: 3, column: 79 },
consumer,
transpiled!,
),
).toMatchInlineSnapshot(
`"require("./__generated__/SomeComponentQuery.graphql").default"`,
);
});
@ -198,35 +212,65 @@ describe("webpackLoader", () => {
const result = await runLoader(source);
const transpiled = result.result![0]?.toString();
const sourceMap = result.result![1];
const consumer = new SourceMapConsumer(sourceMap!.toString() as any);
const startPosition = consumer.generatedPositionFor({
line: 3,
column: 20,
source: "fixture.ts",
});
const endPosition = consumer.generatedPositionFor({
line: 7,
column: 10,
source: "fixture.ts",
});
const startOffset = getPositionOffset(
transpiled!,
startPosition.line,
startPosition.column,
);
const endOffset = getPositionOffset(
transpiled!,
endPosition.line,
endPosition.column,
);
expect(transpiled?.slice(startOffset, endOffset)).toMatchInlineSnapshot(
// fs.writeFileSync(__dirname + "/tmp/test.out", transpiled!);
// fs.writeFileSync(__dirname + "/tmp/test.map", sourceMap!);
expect(
getGeneratedCodeForOriginalRange(
{ line: 3, column: 20 },
{ line: 7, column: 9 },
consumer,
transpiled!,
),
).toMatchInlineSnapshot(
`"require("./__generated__/SomeComponentQuery.graphql").default"`,
);
});
xit("emits source-map that expands on existing input map", async () => {
xit("emits source-mapping for the character immediately after a tagged template", async () => {
const source = `
import { graphql } from "@nova/react";
const doc = graphql\`
query SomeComponentQuery($id: ID!) {
helloWorld
}
\`;
console.log()
`;
const result = await runLoader(source);
const transpiled = result.result![0]?.toString();
const sourceMap = result.result![1];
const consumer = new SourceMapConsumer(sourceMap!.toString() as any);
expect(
consumer.generatedPositionFor({
line: 7,
column: 9,
source: "fixture.ts",
}),
).toMatchObject({ line: 3, column: 81 });
// TODO: This is why the assertion below it fails. The column comes back the same as in the above assertion.
expect(
consumer.generatedPositionFor({
line: 7,
column: 10,
source: "fixture.ts",
}),
).toMatchObject({ line: 3, column: 82 });
expect(
getGeneratedCodeForOriginalRange(
{ line: 7, column: 9 },
{ line: 7, column: 10 },
consumer,
transpiled!,
),
).toMatchInlineSnapshot(`";"`);
});
it("emits source-map that expands on existing input map", async () => {
// The import over multiple lines is important here, as it will transpile to a single line
// and the source-map should be able to map back to the original source with multiple lines.
const source = `
@ -240,44 +284,16 @@ describe("webpackLoader", () => {
const result = await runLoader(source, { compileTS: true });
const transpiled = result.result![0]?.toString();
const sourceMap = result.result![1];
// console.log(transpiled);
console.log(sourceMap);
const consumer = new SourceMapConsumer(sourceMap!.toString() as any);
// consumer.eachMapping((mapping) => {
// console.log(mapping);
// });
consumer.computeColumnSpans();
console.log(
consumer.allGeneratedPositionsFor({
line: 5,
column: undefined as any,
source: "fixture.ts",
}),
);
const [startPosition, endPosition] = consumer.allGeneratedPositionsFor({
line: 5,
column: undefined as any,
source: "fixture.ts",
});
const x = consumer.generatedPositionFor({
line: 5,
column: 20,
source: "fixture.ts",
});
console.log(x);
const startOffset = getPositionOffset(
transpiled!,
2, // startPosition.line,
12, // startPosition.column,
);
const endOffset = getPositionOffset(
transpiled!,
2, // endPosition.line,
73, // endPosition.column,
);
expect(transpiled?.slice(startOffset, endOffset)).toMatchInlineSnapshot(
expect(
getGeneratedCodeForOriginalRange(
{ line: 5, column: 20 },
{ line: 5, column: 80 },
consumer,
transpiled!,
),
).toMatchInlineSnapshot(
`"require("./__generated__/SomeComponentQuery.graphql").default"`,
);
});

Просмотреть файл

@ -0,0 +1,20 @@
const COMMENT =
"//# sourceMappingURL=data:application/json;charset=utf-8;base64,";
export function addInlineSourceMap(source: string, sourceMap: string) {
return `${source}\n${COMMENT}${Buffer.from(sourceMap).toString("base64")}\n`;
}
export function extractInlineSourceMap(
source: string,
): [code: string, map: string | undefined] {
const [sourceWithoutSourceMap, encodedSourceMap] = source.split(COMMENT);
if (encodedSourceMap && sourceWithoutSourceMap) {
return [
sourceWithoutSourceMap,
Buffer.from(encodedSourceMap, "base64").toString("utf8"),
];
} else {
return [sourceWithoutSourceMap, undefined];
}
}

Просмотреть файл

@ -0,0 +1,13 @@
import { SourceMapConsumer, SourceMapGenerator } from "source-map-js";
export function applySourceMap(
sourcePath: string,
sourceMap1: string,
sourceMap2: string,
): string {
const smc1 = new SourceMapConsumer(JSON.parse(sourceMap1));
const smc2 = new SourceMapConsumer(JSON.parse(sourceMap2!));
const pipeline = SourceMapGenerator.fromSourceMap(smc2);
pipeline.applySourceMap(smc1, sourcePath);
return pipeline.toString();
}

Просмотреть файл

@ -1,4 +1,107 @@
import { SourceMapGenerator } from "source-map";
import { SourceMapGenerator } from "source-map-js";
export function transform(
source: string,
sourcePath: string,
sourceMap: SourceMapGenerator | undefined,
): string | undefined {
let anyChanges = false;
if (sourceMap) {
sourceMap.addMapping({
original: { line: 1, column: 0 },
generated: { line: 1, column: 0 },
source: sourcePath,
});
}
// This represents a chunk of the source either before a graphql tag, inside
// a graphql tag, or after a graphql tag.
let lastChunkOffset = 0;
// This represents the number of lines that have been removed from the
// generated source due to the removal of graphql tags.
let lineDelta = 0;
const result = source.replace(
/graphql\s*`(?:[^`])*`/g,
(taggedTemplateExpression, offset: number) => {
anyChanges = true;
if (sourceMap) {
addNewLineMappings(
lastChunkOffset,
offset,
source,
sourceMap,
lineDelta,
sourcePath,
);
}
lastChunkOffset = offset + taggedTemplateExpression.length;
const match = taggedTemplateExpression.match(
/(query|mutation|subscription|fragment)\s+\b(.+?)\b/,
);
if (match && match[2]) {
const generated = `require("./__generated__/${match[2]}.graphql").default`;
if (sourceMap) {
const originalStart = offsetToLineColumn(source, offset);
sourceMap.addMapping({
original: originalStart,
generated: {
line: originalStart.line - lineDelta,
column: originalStart.column,
},
source: sourcePath,
});
const originalEndOffset = offset + taggedTemplateExpression.length;
forEachNewLine(offset, originalEndOffset, source, (offset) => {
const lineStart = offsetToLineColumn(source, offset);
sourceMap.addMapping({
original: lineStart,
generated: {
line: originalStart.line - lineDelta,
column: originalStart.column,
},
source: sourcePath,
});
});
sourceMap.addMapping({
original: offsetToLineColumn(source, originalEndOffset),
generated: {
line: originalStart.line - lineDelta,
column: originalStart.column + generated.length,
},
source: sourcePath,
});
lineDelta += taggedTemplateExpression.split("\n").length - 1;
}
return generated;
}
return taggedTemplateExpression;
},
);
if (sourceMap) {
addNewLineMappings(
lastChunkOffset,
source.length,
source,
sourceMap,
lineDelta,
sourcePath,
);
}
return anyChanges ? result : undefined;
}
function offsetToLineColumn(
str: string,
@ -17,43 +120,36 @@ function offsetToLineColumn(
return { line, column };
}
export function transform(
function forEachNewLine(
start: number,
end: number,
source: string,
sourcePath: string,
sourceMap: SourceMapGenerator | undefined,
callback: (offset: number) => void,
) {
return source.replace(
/graphql\s*`(?:[^`])*`/g,
(taggedTemplateExpression, offset: number) => {
const match = taggedTemplateExpression.match(
/(query|mutation|subscription|fragment)\s+\b(.+?)\b/,
);
if (match && match[2]) {
const generated = `require("./__generated__/${match[2]}.graphql").default`;
if (sourceMap) {
const originalStart = offsetToLineColumn(source, offset);
sourceMap.addMapping({
original: originalStart,
generated: originalStart,
source: sourcePath,
});
sourceMap.addMapping({
original: offsetToLineColumn(
source,
offset + taggedTemplateExpression.length,
),
generated: {
line: originalStart.line,
column: originalStart.column + generated.length,
},
source: sourcePath,
});
}
return generated;
}
return taggedTemplateExpression;
},
);
for (let i = start; i < end; i++) {
if (source[i] === "\n") {
callback(i + 1);
}
}
}
function addNewLineMappings(
start: number,
end: number,
source: string,
sourceMap: SourceMapGenerator,
lineDelta: number,
sourcePath: string,
) {
forEachNewLine(start, end, source, (offset) => {
const lineStart = offsetToLineColumn(source, offset);
sourceMap.addMapping({
original: lineStart,
generated: {
line: lineStart.line - lineDelta,
column: lineStart.column,
},
source: sourcePath,
});
});
}

Просмотреть файл

@ -1,26 +1,57 @@
import { transform } from "./transform";
import tsLoader from "ts-jest";
import { SourceMapGenerator } from "source-map-js";
import type { TransformerFactory, SyncTransformer } from "@jest/transform";
import type { TsJestGlobalOptions } from "ts-jest";
import { extractInlineSourceMap } from "./addInlineSourceMap";
import { applySourceMap } from "./source-map-utils";
const transformerFactory: TransformerFactory<SyncTransformer<unknown>> = {
createTransformer(config) {
const tsLoaderInstance = tsLoader.createTransformer(
config as TsJestGlobalOptions,
);
const generateSourceMap = true;
return {
...tsLoaderInstance,
process(sourceText, sourcePath, options) {
return tsLoaderInstance.process(
transform(sourceText, sourcePath, undefined),
const sourceMap = generateSourceMap
? new SourceMapGenerator()
: undefined;
sourceMap?.setSourceContent(sourcePath, sourceText);
const transformed = transform(sourceText, sourcePath, sourceMap);
let tsResult = tsLoaderInstance.process(
transformed || sourceText,
sourcePath,
options,
);
if (sourceMap && transformed) {
const [tsResultCode, tsResultMap] = extractInlineSourceMap(
tsResult.code,
);
if (tsResultMap === undefined) {
throw new Error("Expected inline source-map");
}
// TODO: Determine why ts-jest insists on using inline source-maps
// and/or if it's cheaper to inline the source-map again
// ourselves rather than letting jest do the work.
tsResult = {
code: tsResultCode,
map: applySourceMap(sourcePath, sourceMap.toString(), tsResultMap),
};
}
return tsResult;
},
processAsync(sourceText, sourcePath, options) {
const sourceMapGenerator = new SourceMapGenerator();
sourceMapGenerator.setSourceContent(sourcePath, sourceText);
return tsLoaderInstance.processAsync(
transform(sourceText, sourcePath, undefined),
transform(sourceText, sourcePath, sourceMapGenerator)!,
sourcePath,
options,
);

Просмотреть файл

@ -1,6 +1,7 @@
import type { LoaderDefinitionFunction } from "webpack";
import { SourceMapConsumer, SourceMapGenerator } from "source-map";
import { SourceMapConsumer, SourceMapGenerator } from "source-map-js";
import { transform } from "./transform";
import { applySourceMap } from "./source-map-utils";
const webpackLoader: LoaderDefinitionFunction = function (
source,
@ -11,33 +12,31 @@ const webpackLoader: LoaderDefinitionFunction = function (
let sourceMap: SourceMapGenerator | undefined;
if (this.sourceMap) {
// sourceMap = inputSourceMap
// ? SourceMapGenerator.fromSourceMap(
// new SourceMapConsumer(JSON.parse(inputSourceMap as string)),
// )
// : new SourceMapGenerator({
// file: this.resourcePath + ".map",
// });
sourceMap = new SourceMapGenerator({
file: this.resourcePath + ".map",
});
sourceMap.setSourceContent(this.resourcePath, source);
}
const result = transform(source, this.resourcePath, sourceMap);
const transformed = transform(source, this.resourcePath, sourceMap);
if (sourceMap && inputSourceMap) {
const compoundSourceMap = SourceMapGenerator.fromSourceMap(
new SourceMapConsumer(JSON.parse(inputSourceMap as string)),
if (transformed && sourceMap && inputSourceMap) {
callback(
null,
transformed,
applySourceMap(
this.resourcePath,
inputSourceMap as string,
sourceMap.toString(),
),
);
compoundSourceMap.applySourceMap(
new SourceMapConsumer(JSON.parse(sourceMap.toString())),
);
sourceMap = compoundSourceMap;
} else if (transformed && sourceMap) {
callback(null, transformed, sourceMap.toString());
} else if (transformed) {
callback(null, transformed);
} else {
callback(null, source);
}
callback(null, result, sourceMap?.toString());
};
export default webpackLoader;

Просмотреть файл

@ -11227,6 +11227,11 @@ sockjs@^0.3.21, sockjs@^0.3.24:
uuid "^8.3.2"
websocket-driver "^0.7.4"
source-map-js@^1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.0.2.tgz#adbc361d9c62df380125e7f161f71c826f1e490c"
integrity sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==
source-map-resolve@^0.5.0:
version "0.5.3"
resolved "https://registry.yarnpkg.com/source-map-resolve/-/source-map-resolve-0.5.3.tgz#190866bece7553e1f8f267a2ee82c606b5509a1a"