[7.x] decorateSnapshotUi: get file from stacktrace (#88950) (#89000)

This commit is contained in:
Dario Gieselaar 2021-01-22 08:55:43 +01:00 committed by GitHub
parent 09ceaf3f0a
commit 71a4715cc3
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 240 additions and 168 deletions

View file

@ -589,6 +589,7 @@
"base64-js": "^1.3.1",
"base64url": "^3.0.1",
"broadcast-channel": "^3.0.3",
"callsites": "^3.1.0",
"chai": "3.5.0",
"chance": "1.0.18",
"chromedriver": "^87.0.3",

View file

@ -12,7 +12,32 @@ import { decorateSnapshotUi, expectSnapshot } from './decorate_snapshot_ui';
import path from 'path';
import fs from 'fs';
const createMockTest = ({
title = 'Test',
passed = true,
}: { title?: string; passed?: boolean } = {}) => {
return {
fullTitle: () => title,
isPassed: () => passed,
parent: {},
} as Test;
};
describe('decorateSnapshotUi', () => {
const snapshotFolder = path.resolve(__dirname, '__snapshots__');
const snapshotFile = path.resolve(snapshotFolder, 'decorate_snapshot_ui.test.snap');
const cleanup = () => {
if (fs.existsSync(snapshotFile)) {
fs.unlinkSync(snapshotFile);
fs.rmdirSync(snapshotFolder);
}
};
beforeEach(cleanup);
afterAll(cleanup);
describe('when running a test', () => {
let lifecycle: Lifecycle;
beforeEach(() => {
@ -21,15 +46,7 @@ describe('decorateSnapshotUi', () => {
});
it('passes when the snapshot matches the actual value', async () => {
const test: Test = {
title: 'Test',
file: 'foo.ts',
parent: {
file: 'foo.ts',
tests: [],
suites: [],
},
} as any;
const test = createMockTest();
await lifecycle.beforeEachTest.trigger(test);
@ -39,15 +56,7 @@ describe('decorateSnapshotUi', () => {
});
it('throws when the snapshot does not match the actual value', async () => {
const test: Test = {
title: 'Test',
file: 'foo.ts',
parent: {
file: 'foo.ts',
tests: [],
suites: [],
},
} as any;
const test = createMockTest();
await lifecycle.beforeEachTest.trigger(test);
@ -57,27 +66,10 @@ describe('decorateSnapshotUi', () => {
});
it('writes a snapshot to an external file if it does not exist', async () => {
const test: Test = {
title: 'Test',
file: __filename,
isPassed: () => true,
} as any;
// @ts-expect-error
test.parent = {
file: __filename,
tests: [test],
suites: [],
};
const test: Test = createMockTest();
await lifecycle.beforeEachTest.trigger(test);
const snapshotFile = path.resolve(
__dirname,
'__snapshots__',
'decorate_snapshot_ui.test.snap'
);
expect(fs.existsSync(snapshotFile)).toBe(false);
expect(() => {
@ -87,10 +79,48 @@ describe('decorateSnapshotUi', () => {
await lifecycle.afterTestSuite.trigger(test.parent);
expect(fs.existsSync(snapshotFile)).toBe(true);
});
});
fs.unlinkSync(snapshotFile);
describe('when writing multiple snapshots to a single file', () => {
let lifecycle: Lifecycle;
beforeEach(() => {
lifecycle = new Lifecycle();
decorateSnapshotUi({ lifecycle, updateSnapshots: false, isCi: false });
});
fs.rmdirSync(path.resolve(__dirname, '__snapshots__'));
beforeEach(() => {
fs.mkdirSync(path.resolve(__dirname, '__snapshots__'));
fs.writeFileSync(
snapshotFile,
`// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[\`Test1 1\`] = \`"foo"\`;
exports[\`Test2 1\`] = \`"bar"\`;
`,
{ encoding: 'utf-8' }
);
});
it('compares to an existing snapshot', async () => {
const test1 = createMockTest({ title: 'Test1' });
await lifecycle.beforeEachTest.trigger(test1);
expect(() => {
expectSnapshot('foo').toMatch();
}).not.toThrow();
const test2 = createMockTest({ title: 'Test2' });
await lifecycle.beforeEachTest.trigger(test2);
expect(() => {
expectSnapshot('foo').toMatch();
}).toThrow();
await lifecycle.afterTestSuite.trigger(test1.parent);
});
});
@ -102,15 +132,7 @@ describe('decorateSnapshotUi', () => {
});
it("doesn't throw if the value does not match", async () => {
const test: Test = {
title: 'Test',
file: 'foo.ts',
parent: {
file: 'foo.ts',
tests: [],
suites: [],
},
} as any;
const test = createMockTest();
await lifecycle.beforeEachTest.trigger(test);
@ -128,15 +150,7 @@ describe('decorateSnapshotUi', () => {
});
it('throws on new snapshots', async () => {
const test: Test = {
title: 'Test',
file: 'foo.ts',
parent: {
file: 'foo.ts',
tests: [],
suites: [],
},
} as any;
const test = createMockTest();
await lifecycle.beforeEachTest.trigger(test);
@ -144,5 +158,82 @@ describe('decorateSnapshotUi', () => {
expectSnapshot('bar').toMatchInline();
}).toThrow();
});
describe('when adding to an existing file', () => {
beforeEach(() => {
fs.mkdirSync(path.resolve(__dirname, '__snapshots__'));
fs.writeFileSync(
snapshotFile,
`// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[\`Test 1\`] = \`"foo"\`;
exports[\`Test2 1\`] = \`"bar"\`;
`,
{ encoding: 'utf-8' }
);
});
it('does not throw on an existing test', async () => {
const test = createMockTest({ title: 'Test' });
await lifecycle.beforeEachTest.trigger(test);
expect(() => {
expectSnapshot('foo').toMatch();
}).not.toThrow();
});
it('throws on a new test', async () => {
const test = createMockTest({ title: 'New test' });
await lifecycle.beforeEachTest.trigger(test);
expect(() => {
expectSnapshot('foo').toMatch();
}).toThrow();
});
it('does not throw when all snapshots are used ', async () => {
const test = createMockTest({ title: 'Test' });
await lifecycle.beforeEachTest.trigger(test);
expect(() => {
expectSnapshot('foo').toMatch();
}).not.toThrow();
const test2 = createMockTest({ title: 'Test2' });
await lifecycle.beforeEachTest.trigger(test2);
expect(() => {
expectSnapshot('bar').toMatch();
}).not.toThrow();
const afterTestSuite = lifecycle.afterTestSuite.trigger({});
await expect(afterTestSuite).resolves.toBe(undefined);
});
it('throws on unused snapshots', async () => {
const test = createMockTest({ title: 'Test' });
await lifecycle.beforeEachTest.trigger(test);
expect(() => {
expectSnapshot('foo').toMatch();
}).not.toThrow();
const afterTestSuite = lifecycle.afterTestSuite.trigger({});
await expect(afterTestSuite).rejects.toMatchInlineSnapshot(`
[Error: 1 obsolete snapshot(s) found:
Test2 1.
Run tests again with \`--updateSnapshots\` to remove them.]
`);
});
});
});
});

View file

@ -15,9 +15,10 @@ import {
import path from 'path';
import prettier from 'prettier';
import babelTraverse from '@babel/traverse';
import { flatten, once } from 'lodash';
import { once } from 'lodash';
import callsites from 'callsites';
import { Lifecycle } from '../lifecycle';
import { Test, Suite } from '../../fake_mocha_types';
import { Test } from '../../fake_mocha_types';
type ISnapshotState = InstanceType<typeof SnapshotState>;
@ -28,40 +29,17 @@ interface SnapshotContext {
currentTestName: string;
}
let testContext: {
file: string;
snapshotTitle: string;
snapshotContext: SnapshotContext;
} | null = null;
let registered: boolean = false;
function getSnapshotMeta(currentTest: Test) {
// Make sure snapshot title is unique per-file, rather than entire
// suite. This allows reuse of tests, for instance to compare
// results for different configurations.
const titles = [currentTest.title];
const file = currentTest.file;
let test: Suite | undefined = currentTest?.parent;
while (test && test.file === file) {
titles.push(test.title);
test = test.parent;
}
const snapshotTitle = titles.reverse().join(' ');
if (!file || !snapshotTitle) {
throw new Error(`file or snapshotTitle not available in Mocha test context`);
}
return {
file,
snapshotTitle,
};
}
const globalState: {
updateSnapshot: SnapshotUpdateState;
registered: boolean;
currentTest: Test | null;
snapshots: Array<{ tests: Test[]; file: string; snapshotState: ISnapshotState }>;
} = {
updateSnapshot: 'none',
registered: false,
currentTest: null,
snapshots: [],
};
const modifyStackTracePrepareOnce = once(() => {
const originalPrepareStackTrace = Error.prepareStackTrace;
@ -72,7 +50,7 @@ const modifyStackTracePrepareOnce = once(() => {
Error.prepareStackTrace = (error, structuredStackTrace) => {
let filteredStrackTrace: NodeJS.CallSite[] = structuredStackTrace;
if (registered) {
if (globalState.registered) {
filteredStrackTrace = filteredStrackTrace.filter((callSite) => {
// check for both compiled and uncompiled files
return !callSite.getFileName()?.match(/decorate_snapshot_ui\.(js|ts)/);
@ -94,21 +72,16 @@ export function decorateSnapshotUi({
updateSnapshots: boolean;
isCi: boolean;
}) {
let snapshotStatesByFilePath: Record<
string,
{ snapshotState: ISnapshotState; testsInFile: Test[] }
> = {};
registered = true;
let updateSnapshot: SnapshotUpdateState;
globalState.registered = true;
globalState.snapshots.length = 0;
globalState.currentTest = null;
if (isCi) {
// make sure snapshots that have not been committed
// are not written to file on CI, passing the test
updateSnapshot = 'none';
globalState.updateSnapshot = 'none';
} else {
updateSnapshot = updateSnapshots ? 'all' : 'new';
globalState.updateSnapshot = updateSnapshots ? 'all' : 'new';
}
modifyStackTracePrepareOnce();
@ -125,21 +98,8 @@ export function decorateSnapshotUi({
// @ts-expect-error
global.expectSnapshot = expectSnapshot;
lifecycle.beforeEachTest.add((currentTest: Test) => {
const { file, snapshotTitle } = getSnapshotMeta(currentTest);
if (!snapshotStatesByFilePath[file]) {
snapshotStatesByFilePath[file] = getSnapshotState(file, currentTest, updateSnapshot);
}
testContext = {
file,
snapshotTitle,
snapshotContext: {
snapshotState: snapshotStatesByFilePath[file].snapshotState,
currentTestName: snapshotTitle,
},
};
lifecycle.beforeEachTest.add((test: Test) => {
globalState.currentTest = test;
});
lifecycle.afterTestSuite.add(function (testSuite) {
@ -150,19 +110,18 @@ export function decorateSnapshotUi({
const unused: string[] = [];
Object.keys(snapshotStatesByFilePath).forEach((file) => {
const { snapshotState, testsInFile } = snapshotStatesByFilePath[file];
testsInFile.forEach((test) => {
const snapshotMeta = getSnapshotMeta(test);
globalState.snapshots.forEach((snapshot) => {
const { tests, snapshotState } = snapshot;
tests.forEach((test) => {
const title = test.fullTitle();
// If test is failed or skipped, mark snapshots as used. Otherwise,
// running a test in isolation will generate false positives.
if (!test.isPassed()) {
snapshotState.markSnapshotsAsCheckedForTest(snapshotMeta.snapshotTitle);
snapshotState.markSnapshotsAsCheckedForTest(title);
}
});
if (!updateSnapshots) {
if (globalState.updateSnapshot !== 'all') {
unused.push(...snapshotState.getUncheckedKeys());
} else {
snapshotState.removeUncheckedKeys();
@ -179,28 +138,14 @@ export function decorateSnapshotUi({
);
}
snapshotStatesByFilePath = {};
globalState.snapshots.length = 0;
});
}
function recursivelyGetTestsFromSuite(suite: Suite): Test[] {
return suite.tests.concat(flatten(suite.suites.map((s) => recursivelyGetTestsFromSuite(s))));
}
function getSnapshotState(file: string, test: Test, updateSnapshot: SnapshotUpdateState) {
function getSnapshotState(file: string, updateSnapshot: SnapshotUpdateState) {
const dirname = path.dirname(file);
const filename = path.basename(file);
let parent: Suite | undefined = test.parent;
while (parent && parent.parent?.file === file) {
parent = parent.parent;
}
if (!parent) {
throw new Error('Top-level suite not found');
}
const snapshotState = new SnapshotState(
path.join(dirname + `/__snapshots__/` + filename.replace(path.extname(filename), '.snap')),
{
@ -211,24 +156,54 @@ function getSnapshotState(file: string, test: Test, updateSnapshot: SnapshotUpda
}
);
return { snapshotState, testsInFile: recursivelyGetTestsFromSuite(parent) };
return snapshotState;
}
export function expectSnapshot(received: any) {
if (!registered) {
if (!globalState.registered) {
throw new Error(
'Mocha hooks were not registered before expectSnapshot was used. Call `registerMochaHooksForSnapshots` in your top-level describe().'
);
}
if (!testContext) {
throw new Error('A current Mocha context is needed to match snapshots');
if (!globalState.currentTest) {
throw new Error('expectSnapshot can only be called inside of an it()');
}
const [, fileOfTest] = callsites().map((site) => site.getFileName());
if (!fileOfTest) {
throw new Error("Couldn't infer a filename for the current test");
}
let snapshot = globalState.snapshots.find(({ file }) => file === fileOfTest);
if (!snapshot) {
snapshot = {
file: fileOfTest,
tests: [],
snapshotState: getSnapshotState(fileOfTest, globalState.updateSnapshot),
};
globalState.snapshots.unshift(snapshot!);
}
if (!snapshot) {
throw new Error('Snapshot is undefined');
}
if (!snapshot.tests.includes(globalState.currentTest)) {
snapshot.tests.push(globalState.currentTest);
}
const context: SnapshotContext = {
snapshotState: snapshot.snapshotState,
currentTestName: globalState.currentTest.fullTitle(),
};
return {
toMatch: expectToMatchSnapshot.bind(null, testContext.snapshotContext, received),
toMatch: expectToMatchSnapshot.bind(null, context, received),
// use bind to support optional 3rd argument (actual)
toMatchInline: expectToMatchInlineSnapshot.bind(null, testContext.snapshotContext, received),
toMatchInline: expectToMatchInlineSnapshot.bind(null, context, received),
};
}

View file

@ -1,6 +1,6 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`monitor states endpoint will fetch monitor state data for the given down filters 1`] = `
exports[`apis uptime uptime REST endpoints with real-world data monitor states endpoint will fetch monitor state data for the given down filters 1`] = `
Object {
"nextPagePagination": "{\\"cursorDirection\\":\\"AFTER\\",\\"sortOrder\\":\\"ASC\\",\\"cursorKey\\":{\\"monitor_id\\":\\"0020-down\\"}}",
"prevPagePagination": null,

View file

@ -1,6 +1,6 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Throughput when data is loaded returns the service throughput has the correct throughput 1`] = `
exports[`APM specs (basic) Services Throughput when data is loaded returns the service throughput has the correct throughput 1`] = `
Array [
Object {
"x": 1607435850000,

View file

@ -1,6 +1,6 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Top traces when data is loaded returns the correct buckets 1`] = `
exports[`APM specs (basic) Traces Top traces when data is loaded returns the correct buckets 1`] = `
Array [
Object {
"averageResponseTime": 1733,

View file

@ -1,6 +1,6 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Breakdown when data is loaded returns the transaction breakdown for a service 1`] = `
exports[`APM specs (basic) Transactions Breakdown when data is loaded returns the transaction breakdown for a service 1`] = `
Object {
"timeseries": Array [
Object {
@ -1019,7 +1019,7 @@ Object {
}
`;
exports[`Breakdown when data is loaded returns the transaction breakdown for a transaction group 9`] = `
exports[`APM specs (basic) Transactions Breakdown when data is loaded returns the transaction breakdown for a transaction group 9`] = `
Array [
Object {
"x": 1607435850000,

View file

@ -1,6 +1,6 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Error rate when data is loaded returns the transaction error rate has the correct error rate 1`] = `
exports[`APM specs (basic) Transactions Error rate when data is loaded returns the transaction error rate has the correct error rate 1`] = `
Array [
Object {
"x": 1607435850000,

View file

@ -1,6 +1,6 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Top transaction groups when data is loaded returns the correct buckets (when ignoring samples) 1`] = `
exports[`APM specs (basic) Transactions Top transaction groups when data is loaded returns the correct buckets (when ignoring samples) 1`] = `
Array [
Object {
"averageResponseTime": 2722.75,

View file

@ -1,6 +1,6 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`UX page load dist when there is data returns page load distribution 1`] = `
exports[`APM specs (trial) CSM UX page load dist when there is data returns page load distribution 1`] = `
Object {
"maxDuration": 54.46,
"minDuration": 0,
@ -456,7 +456,7 @@ Object {
}
`;
exports[`UX page load dist when there is data returns page load distribution with breakdown 1`] = `
exports[`APM specs (trial) CSM UX page load dist when there is data returns page load distribution with breakdown 1`] = `
Array [
Object {
"data": Array [
@ -819,6 +819,6 @@ Array [
]
`;
exports[`UX page load dist when there is no data returns empty list 1`] = `Object {}`;
exports[`APM specs (trial) CSM UX page load dist when there is no data returns empty list 1`] = `Object {}`;
exports[`UX page load dist when there is no data returns empty list with breakdowns 1`] = `Object {}`;
exports[`APM specs (trial) CSM UX page load dist when there is no data returns empty list with breakdowns 1`] = `Object {}`;

View file

@ -1,6 +1,6 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`CSM page views when there is data returns page views 1`] = `
exports[`APM specs (trial) CSM CSM page views when there is data returns page views 1`] = `
Object {
"items": Array [
Object {
@ -128,7 +128,7 @@ Object {
}
`;
exports[`CSM page views when there is data returns page views with breakdown 1`] = `
exports[`APM specs (trial) CSM CSM page views when there is data returns page views with breakdown 1`] = `
Object {
"items": Array [
Object {
@ -265,14 +265,14 @@ Object {
}
`;
exports[`CSM page views when there is no data returns empty list 1`] = `
exports[`APM specs (trial) CSM CSM page views when there is no data returns empty list 1`] = `
Object {
"items": Array [],
"topItems": Array [],
}
`;
exports[`CSM page views when there is no data returns empty list with breakdowns 1`] = `
exports[`APM specs (trial) CSM CSM page views when there is no data returns empty list with breakdowns 1`] = `
Object {
"items": Array [],
"topItems": Array [],

View file

@ -1,6 +1,6 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Service Maps with a trial license /api/apm/service-map when there is data returns service map elements filtering by environment not defined 1`] = `
exports[`APM specs (trial) Service Maps Service Maps with a trial license /api/apm/service-map when there is data returns service map elements filtering by environment not defined 1`] = `
Object {
"elements": Array [
Object {
@ -514,7 +514,7 @@ Object {
}
`;
exports[`Service Maps with a trial license /api/apm/service-map when there is data returns the correct data 3`] = `
exports[`APM specs (trial) Service Maps Service Maps with a trial license /api/apm/service-map when there is data returns the correct data 3`] = `
Array [
Object {
"data": Object {
@ -1741,7 +1741,7 @@ Array [
]
`;
exports[`Service Maps with a trial license when there is data with anomalies with the default apm user returns the correct anomaly stats 3`] = `
exports[`APM specs (trial) Service Maps Service Maps with a trial license when there is data with anomalies with the default apm user returns the correct anomaly stats 3`] = `
Object {
"elements": Array [
Object {

View file

@ -1,6 +1,6 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Latency when data is loaded and fetching transaction charts with uiFilters when not defined environments seleted should return the correct anomaly boundaries 1`] = `
exports[`APM specs (trial) Transactions Latency when data is loaded and fetching transaction charts with uiFilters when not defined environments seleted should return the correct anomaly boundaries 1`] = `
Array [
Object {
"x": 1607436000000,
@ -15,7 +15,7 @@ Array [
]
`;
exports[`Latency when data is loaded and fetching transaction charts with uiFilters with environment selected and empty kuery filter should return a non-empty anomaly series 1`] = `
exports[`APM specs (trial) Transactions Latency when data is loaded and fetching transaction charts with uiFilters with environment selected and empty kuery filter should return a non-empty anomaly series 1`] = `
Array [
Object {
"x": 1607436000000,
@ -30,7 +30,7 @@ Array [
]
`;
exports[`Latency when data is loaded and fetching transaction charts with uiFilters with environment selected in uiFilters should return a non-empty anomaly series 1`] = `
exports[`APM specs (trial) Transactions Latency when data is loaded and fetching transaction charts with uiFilters with environment selected in uiFilters should return a non-empty anomaly series 1`] = `
Array [
Object {
"x": 1607436000000,

View file

@ -9114,6 +9114,11 @@ callsites@^3.0.0:
resolved "https://registry.yarnpkg.com/callsites/-/callsites-3.0.0.tgz#fb7eb569b72ad7a45812f93fd9430a3e410b3dd3"
integrity sha512-tWnkwu9YEq2uzlBDI4RcLn8jrFvF9AOi8PxDNU3hZZjJcjkcRAq3vCI+vZcg1SuxISDYe86k9VZFwAxDiJGoAw==
callsites@^3.1.0:
version "3.1.0"
resolved "https://registry.yarnpkg.com/callsites/-/callsites-3.1.0.tgz#b3630abd8943432f54b3f0519238e33cd7df2f73"
integrity sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==
camel-case@3.0.x, camel-case@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/camel-case/-/camel-case-3.0.0.tgz#ca3c3688a4e9cf3a4cda777dc4dcbc713249cf73"