Skip to content

Commit 8fd1941

Browse files
authored
chore(cli-repl): add snapshot package list tests MONGOSH-1736 (#1885)
Add the ability to introspect the compiled mongosh executables for what is part of the snapshot and what is not, and then use that information in e2e tests to verify that it is accurate.
1 parent d83586f commit 8fd1941

File tree

7 files changed

+262
-14
lines changed

7 files changed

+262
-14
lines changed

packages/cli-repl/package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,8 @@
3131
"check": "npm run lint && npm run depcheck",
3232
"depcheck": "depcheck",
3333
"prepublish": "npm run compile",
34-
"webpack-build": "npm run compile && webpack --mode production",
35-
"webpack-build-dev": "npm run compile && webpack --mode development",
34+
"webpack-build": "npm run compile && webpack --mode production && cat dist/add-module-mapping.js >> dist/mongosh.js",
35+
"webpack-build-dev": "npm run compile && webpack --mode development && cat dist/add-module-mapping.js >> dist/mongosh.js",
3636
"start-snapshot": "rm -f snapshot.blob && node --snapshot-blob snapshot.blob --build-snapshot dist/mongosh.js && node --snapshot-blob snapshot.blob dist/mongosh.js",
3737
"prettier": "prettier",
3838
"reformat": "npm run prettier -- --write . && npm run eslint --fix"

packages/cli-repl/src/run.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import crypto from 'crypto';
3030
import net from 'net';
3131
import v8 from 'v8';
3232
import { TimingCategories } from '@mongosh/types';
33+
import './webpack-self-inspection';
3334

3435
// TS does not yet have type definitions for v8.startupSnapshot
3536
if ((v8 as any)?.startupSnapshot?.isBuildingSnapshot?.()) {
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import v8 from 'v8';
2+
3+
// Allow us to inspect the set of loaded modules at a few interesting points in time, e.g.
4+
// startup, snapshot, and after entering VM/REPL execution mode
5+
declare const __webpack_module_cache__: Record<string, unknown> | undefined;
6+
declare const __webpack_modules__: Record<string, unknown> | undefined;
7+
declare const __webpack_reverse_module_lookup__:
8+
| (() => Record<string | number, string>)
9+
| undefined;
10+
11+
// Return all ids of modules loaded at the current time
12+
function enumerateLoadedModules(): (string | number)[] | null {
13+
if (typeof __webpack_module_cache__ !== 'undefined') {
14+
return Object.keys(__webpack_module_cache__);
15+
}
16+
return null;
17+
}
18+
// Return all ids of modules that can be loaded/are known to webpack
19+
function enumerateAllModules(): (string | number)[] | null {
20+
if (typeof __webpack_modules__ !== 'undefined') {
21+
return Object.keys(__webpack_modules__);
22+
}
23+
return null;
24+
}
25+
// Perform a reverse lookup to determine the "natural" name for a given
26+
// module id (i.e. original filename, if available).
27+
// Calling this the first time is potentially expensive.
28+
function lookupNaturalModuleName(id: string | number): string | null {
29+
let lookupTable = null;
30+
if (typeof __webpack_reverse_module_lookup__ !== 'undefined')
31+
lookupTable = __webpack_reverse_module_lookup__();
32+
return lookupTable?.[id] ?? null;
33+
}
34+
Object.defineProperty(process, '__mongosh_webpack_stats', {
35+
value: {
36+
enumerateLoadedModules,
37+
enumerateAllModules,
38+
lookupNaturalModuleName,
39+
},
40+
});
41+
if ((v8 as any)?.startupSnapshot?.isBuildingSnapshot?.()) {
42+
(v8 as any).startupSnapshot.addSerializeCallback(() => {
43+
const atSnapshotTime = enumerateLoadedModules();
44+
(process as any).__mongosh_webpack_stats.enumerateSnapshotModules = () =>
45+
atSnapshotTime;
46+
});
47+
}

packages/cli-repl/webpack.config.js

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,9 @@ const crypto = require('crypto');
55
const { merge } = require('webpack-merge');
66
const path = require('path');
77
const { WebpackDependenciesPlugin } = require('@mongodb-js/sbom-tools');
8+
const {
9+
WebpackEnableReverseModuleLookupPlugin,
10+
} = require('../../scripts/webpack-enable-reverse-module-lookup-plugin.js');
811

912
const baseWebpackConfig = require('../../config/webpack.base.config');
1013

@@ -29,6 +32,11 @@ const webpackDependenciesPlugin = new WebpackDependenciesPlugin({
2932
includeExternalProductionDependencies: true,
3033
});
3134

35+
const enableReverseModuleLookupPlugin =
36+
new WebpackEnableReverseModuleLookupPlugin({
37+
outputFilename: path.resolve(__dirname, 'dist', 'add-module-mapping.js'),
38+
});
39+
3240
/** @type import('webpack').Configuration */
3341
const config = {
3442
output: {
@@ -41,7 +49,7 @@ const config = {
4149
type: 'var',
4250
},
4351
},
44-
plugins: [webpackDependenciesPlugin],
52+
plugins: [webpackDependenciesPlugin, enableReverseModuleLookupPlugin],
4553
entry: './lib/run.js',
4654
resolve: {
4755
alias: {
@@ -83,7 +91,13 @@ module.exports = merge(baseWebpackConfig, config);
8391
// startup that should depend on runtime state.
8492
function makeLazyForwardModule(pkg) {
8593
const S = JSON.stringify;
86-
const tmpdir = path.resolve(__dirname, '..', 'tmp', 'lazy-webpack-modules');
94+
const tmpdir = path.resolve(
95+
__dirname,
96+
'..',
97+
'..',
98+
'tmp',
99+
'lazy-webpack-modules'
100+
);
87101
fs.mkdirSync(tmpdir, { recursive: true });
88102
const filename = path.join(
89103
tmpdir,
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
import {
2+
skipIfApiStrict,
3+
startSharedTestServer,
4+
} from '../../../testing/integration-testing-hooks';
5+
import { TestShell } from './test-shell';
6+
import { expect } from 'chai';
7+
8+
const setDifference = <T>(a: T[], b: T[]) => a.filter((e) => !b.includes(e));
9+
const expectIsSubset = <T>(a: T[], b: T[]) =>
10+
expect(setDifference(a, b)).to.have.lengthOf(0);
11+
const commonPrefix = (a: string, b: string): string =>
12+
a.startsWith(b)
13+
? b
14+
: b.startsWith(a)
15+
? a
16+
: b && commonPrefix(a, b.slice(0, -1));
17+
18+
describe('e2e startup banners', function () {
19+
skipIfApiStrict();
20+
afterEach(TestShell.cleanup);
21+
22+
const testServer = startSharedTestServer();
23+
24+
context('modules included in snapshots', function () {
25+
it('includes the right modules at the right point in time', async function () {
26+
if (!process.env.MONGOSH_TEST_EXECUTABLE_PATH) return this.skip();
27+
28+
const connectionString = await testServer.connectionString();
29+
const helperScript = `
30+
const S = process.__mongosh_webpack_stats;
31+
const L = (list) => list.map(S.lookupNaturalModuleName).filter(name => name && !name.endsWith('.json'));
32+
`;
33+
const commonArgs = ['--quiet', '--json=relaxed', '--eval', helperScript];
34+
const argLists = [
35+
[...commonArgs, '--nodb', '--eval', 'L(S.enumerateAllModules())'],
36+
[...commonArgs, '--nodb', '--eval', 'L(S.enumerateSnapshotModules())'],
37+
[...commonArgs, '--nodb', '--eval', 'L(S.enumerateLoadedModules())'],
38+
[
39+
...commonArgs,
40+
connectionString,
41+
'--eval',
42+
'L(S.enumerateLoadedModules())',
43+
],
44+
[
45+
...commonArgs,
46+
connectionString,
47+
'--jsContext=repl',
48+
'--eval',
49+
'L(S.enumerateLoadedModules())',
50+
],
51+
];
52+
const [
53+
all,
54+
atSnapshotTime,
55+
atNodbEvalTime,
56+
atDbEvalTime,
57+
atReplEvalTime,
58+
] = (
59+
await Promise.all(
60+
argLists.map((args) =>
61+
TestShell.runAndGetOutputWithoutErrors({ args })
62+
)
63+
)
64+
).map((output) =>
65+
(JSON.parse(output) as string[])
66+
.sort()
67+
.map((pkg) => pkg.replace(/\\/g, '/'))
68+
);
69+
70+
// Ensure that: atSnapshotTime ⊆ atNodbEvalTime ⊆ atDbEvalTime ⊆ atReplEvalTime ⊆ all
71+
expectIsSubset(atSnapshotTime, atNodbEvalTime);
72+
expectIsSubset(atNodbEvalTime, atDbEvalTime);
73+
expectIsSubset(atDbEvalTime, atReplEvalTime);
74+
expectIsSubset(atReplEvalTime, all);
75+
76+
const prefix = all.reduce(commonPrefix);
77+
const stripPrefix = (s: string) =>
78+
s.startsWith(prefix) ? s.replace(prefix, '') : s;
79+
80+
const categorized = [
81+
...atSnapshotTime.map(stripPrefix).map((m) => [m, 'snapshot'] as const),
82+
...setDifference(atNodbEvalTime, atSnapshotTime)
83+
.map(stripPrefix)
84+
.map((m) => [m, 'nodb-eval'] as const),
85+
...setDifference(atDbEvalTime, atNodbEvalTime)
86+
.map(stripPrefix)
87+
.map((m) => [m, 'db-eval'] as const),
88+
...setDifference(atReplEvalTime, atDbEvalTime)
89+
.map(stripPrefix)
90+
.map((m) => [m, 'repl-eval'] as const),
91+
...setDifference(all, atReplEvalTime)
92+
.map(stripPrefix)
93+
.map((m) => [m, 'not-loaded'] as const),
94+
];
95+
96+
// This is very helpful for inspecting snapshotted contents manually:
97+
// console.table(categorized.map(([m, c]) => [m.replace(prefix, ''), c]));
98+
const verifyAllInCategoryMatch = (
99+
category: (typeof categorized)[number][1],
100+
re: RegExp
101+
) => {
102+
for (const [module, cat] of categorized) {
103+
if (cat === category) {
104+
expect(module).to.match(
105+
re,
106+
`Found unexpected '${module}' in category '${cat}'`
107+
);
108+
}
109+
}
110+
};
111+
const verifyAllThatMatchAreInCategory = (
112+
category: (typeof categorized)[number][1],
113+
re: RegExp
114+
) => {
115+
for (const [module, cat] of categorized) {
116+
if (re.test(module)) {
117+
expect(cat).to.equal(
118+
category,
119+
`Expected '${module}' to be in category '${category}', actual category is '${cat}'`
120+
);
121+
}
122+
}
123+
};
124+
125+
// The core test: Verify that in the categories beyond 'not loaded at all'
126+
// and 'part of the snapshot', only a very specific set of modules is present,
127+
// and that some modules are only in specific categories.
128+
verifyAllInCategoryMatch('repl-eval', /^node_modules\/pretty-repl\//);
129+
verifyAllInCategoryMatch(
130+
'db-eval',
131+
/^node_modules\/(kerberos|os-dns-native|resolve-mongodb-srv)\//
132+
);
133+
verifyAllInCategoryMatch(
134+
'nodb-eval',
135+
/^node_modules\/(kerberos|mongodb-client-encryption)\//
136+
);
137+
verifyAllThatMatchAreInCategory(
138+
'not-loaded',
139+
/^node_modules\/(express|openid-client|qs|send|jose|execa|body-parser|@babel\/highlight|@babel\/code-frame)\//
140+
);
141+
verifyAllThatMatchAreInCategory(
142+
'snapshot',
143+
/^node_modules\/(@babel\/types|@babel\/traverse|@mongodb-js\/devtools-connect|mongodb)\/|^packages\//
144+
);
145+
});
146+
});
147+
});

packages/e2e-tests/test/test-shell.ts

Lines changed: 19 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -30,22 +30,22 @@ function matches(str: string, pattern: string | RegExp): boolean {
3030
: pattern.test(str);
3131
}
3232

33+
export interface TestShellOptions {
34+
args: string[];
35+
env?: Record<string, string>;
36+
removeSigintListeners?: boolean;
37+
cwd?: string;
38+
forceTerminal?: boolean;
39+
consumeStdio?: boolean;
40+
}
41+
3342
/**
3443
* Test shell helper class.
3544
*/
3645
export class TestShell {
3746
private static _openShells: TestShell[] = [];
3847

39-
static start(
40-
options: {
41-
args: string[];
42-
env?: Record<string, string>;
43-
removeSigintListeners?: boolean;
44-
cwd?: string;
45-
forceTerminal?: boolean;
46-
consumeStdio?: boolean;
47-
} = { args: [] }
48-
): TestShell {
48+
static start(options: TestShellOptions = { args: [] }): TestShell {
4949
let shellProcess: ChildProcessWithoutNullStreams;
5050

5151
let env = options.env || process.env;
@@ -95,6 +95,15 @@ export class TestShell {
9595
return shell;
9696
}
9797

98+
static async runAndGetOutputWithoutErrors(
99+
options: TestShellOptions
100+
): Promise<string> {
101+
const shell = this.start(options);
102+
await shell.waitForExit();
103+
shell.assertNoErrors();
104+
return shell.output;
105+
}
106+
98107
static async killall(): Promise<void> {
99108
const exitPromises: Promise<unknown>[] = [];
100109
while (TestShell._openShells.length) {
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
'use strict';
2+
const path = require('path');
3+
const fs = require('fs');
4+
const zlib = require('zlib');
5+
const { promisify } = require('util');
6+
7+
class WebpackEnableReverseModuleLookupPlugin {
8+
outputFilename;
9+
constructor({ outputFilename }) { this.outputFilename = outputFilename; }
10+
11+
apply(compiler) {
12+
compiler.hooks.emit.tapPromise('EnableReverseModuleLookupPlugin', async(compilation) => {
13+
const map = Object.create(null);
14+
for (const module of compilation.modules) {
15+
const id = compilation.chunkGraph.getModuleId(module);
16+
if (id && module.resource) {
17+
map[id] = module.resource;
18+
}
19+
}
20+
const data = (await promisify(zlib.brotliCompress)(JSON.stringify(map))).toString('base64');
21+
await fs.promises.mkdir(path.dirname(this.outputFilename), { recursive: true });
22+
await fs.promises.writeFile(this.outputFilename, `function __webpack_reverse_module_lookup__() {
23+
return __webpack_reverse_module_lookup__.data ??= JSON.parse(
24+
require("zlib").brotliDecompressSync(Buffer.from(${JSON.stringify(data)}, 'base64')));
25+
}`);
26+
})
27+
}
28+
}
29+
30+
module.exports = { WebpackEnableReverseModuleLookupPlugin };

0 commit comments

Comments
 (0)