Skip to content

Commit

Permalink
feat!: implement default filepath inference using Error stack trace
Browse files Browse the repository at this point in the history
BREAKING CHANGE: `babelOptions.filename` is now set to `filepath`
by default rather than `undefined`.
  • Loading branch information
Xunnamius committed Jan 7, 2023
1 parent 2efbe55 commit 9d1b321
Show file tree
Hide file tree
Showing 2 changed files with 105 additions and 10 deletions.
81 changes: 78 additions & 3 deletions src/plugin-tester.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,12 @@ import {

import type { Class } from 'type-fest';

const parseErrorStackRegExp =
/at (?<fn>\S+) (?:.*? )?\(?(?<path>(?:\/|file:).*?)(?:\)|$)/i;

const parseScriptFilepathRegExp =
/\/babel-plugin-tester\/(dist|src)\/(index|plugin-tester)\.(j|t)s$/;

export default pluginTester;

/**
Expand Down Expand Up @@ -110,8 +116,7 @@ export function pluginTester(options: PluginTesterOptions = {}) {
const baseConfig: PartialPluginTesterBaseConfig = {
babel: rawBaseConfig.babel || require('@babel/core'),
baseBabelOptions: rawBaseConfig.babelOptions,
// TODO: implement default filepath inference using Error stack trace
filepath: rawBaseConfig.filepath ?? rawBaseConfig.filename,
filepath: rawBaseConfig.filepath ?? rawBaseConfig.filename ?? tryInferFilepath(),
endOfLine: rawBaseConfig.endOfLine,
baseSetup: rawBaseConfig.setup,
baseTeardown: rawBaseConfig.teardown,
Expand Down Expand Up @@ -159,6 +164,76 @@ export function pluginTester(options: PluginTesterOptions = {}) {
return undefined;
}
}

function tryInferFilepath() {
const oldStackTraceLimit = Error.stackTraceLimit;
Error.stackTraceLimit = Number.POSITIVE_INFINITY;

try {
let inferredFilepath: string | undefined = undefined;
// ? Turn the V8 call stack into function names and file paths
const reversedCallStack = (
new Error('faux error').stack
?.split('\n')
.map((line) => {
const { fn: functionName, path: filePath } =
line.match(parseErrorStackRegExp)?.groups || {};

return functionName && filePath
? {
functionName,
// ? Paranoid just in case the script name/path has colons
filePath: filePath.split(':').slice(0, -2).join(':')
}
: undefined;
})
.filter(<T>(o: T): o is NonNullable<T> => Boolean(o)) || []
).reverse();

// TODO: debug statement here displaying reversed call stack contents

if (reversedCallStack?.length) {
// TODO: debug statements below
const referenceIndex = findReferenceStackIndex(reversedCallStack);

if (referenceIndex) {
inferredFilepath = reversedCallStack.at(referenceIndex - 1)?.filePath;
}
}

// TODO: debug statement here outputting inferredFilepath

return inferredFilepath;
} finally {
Error.stackTraceLimit = oldStackTraceLimit;
}

function findReferenceStackIndex(
reversedCallStack: { functionName: string; filePath: string }[]
) {
// ? Different realms might have slightly different stacks depending on
// ? which file was imported. Return the first one found.
return [
reversedCallStack.findIndex(({ functionName, filePath }) => {
return (
functionName == 'defaultPluginTester' &&
parseScriptFilepathRegExp.test(filePath)
);
}),
reversedCallStack.findIndex(({ functionName, filePath }) => {
return (
functionName == 'pluginTester' && parseScriptFilepathRegExp.test(filePath)
);
}),
reversedCallStack.findIndex(({ functionName, filePath }) => {
return (
functionName == 'resolveBaseConfig' &&
parseScriptFilepathRegExp.test(filePath)
);
})
].find((ndx) => ndx != -1);
}
}
}

function normalizeTests() {
Expand Down Expand Up @@ -480,7 +555,7 @@ export function pluginTester(options: PluginTesterOptions = {}) {
{ [$type]: 'test-object' } as const,
{ babelOptions: baseBabelOptions },
{
babelOptions: { filename: getAbsolutePath(filepath, codeFixture) }
babelOptions: { filename: getAbsolutePath(filepath, codeFixture) ?? filepath }
},
{ babelOptions },
{
Expand Down
34 changes: 27 additions & 7 deletions test/plugin-tester.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@ import { unstringSnapshotSerializer } from '../src/serializers/unstring-snapshot
import {
type PluginTesterOptions,
runPluginUnderTestHere,
runPresetUnderTestHere
runPresetUnderTestHere,
pluginTester
} from '../src/index';

import {
Expand Down Expand Up @@ -45,7 +46,8 @@ import {
runPluginTesterExpectThrownExceptionWhenCapturingError,
getFixturePath,
getFixtureContents,
requireFixtureOptions
requireFixtureOptions,
getPendingJestTests
} from './helpers';

import type { AnyFunction } from '@xunnamius/jest-types';
Expand Down Expand Up @@ -891,21 +893,39 @@ describe('tests targeting the PluginTesterOptions interface', () => {
await runPluginTester(
getDummyPluginOptions({
tests: [simpleTest],
fixtures: 'fixtures/simple'
fixtures: '../fixtures/simple'
})
);

await runPluginTester(
getDummyPresetOptions({
tests: [simpleTest],
fixtures: 'fixtures/simple'
fixtures: '../fixtures/simple'
})
);

const fixtureFilename = getFixturePath('simple/fixture/code.js');
const testObjectFilename = path.resolve(__dirname, './helpers/index.ts');

expect(transformAsyncSpy.mock.calls).toMatchObject([
[expect.any(String), expect.objectContaining({ filename: fixtureFilename })],
[expect.any(String), expect.objectContaining({ filename: testObjectFilename })],
[expect.any(String), expect.objectContaining({ filename: fixtureFilename })],
[expect.any(String), expect.objectContaining({ filename: testObjectFilename })]
]);

jest.clearAllMocks();

pluginTester({
plugin: () => ({ visitor: {} }),
tests: [simpleTest],
fixtures: 'fixtures/simple'
});

await Promise.all(getPendingJestTests());

expect(transformAsyncSpy.mock.calls).toMatchObject([
[expect.any(String), expect.objectContaining({ filename: __filename })],
[expect.any(String), expect.objectContaining({ filename: __filename })],
[expect.any(String), expect.objectContaining({ filename: __filename })],
[expect.any(String), expect.objectContaining({ filename: fixtureFilename })],
[expect.any(String), expect.objectContaining({ filename: __filename })]
]);
});
Expand Down

0 comments on commit 9d1b321

Please sign in to comment.