Skip to content

Commit bf80971

Browse files
committed
feat(import-bundle): Test bundle format #2719
1 parent 8f97bf9 commit bf80971

File tree

4 files changed

+136
-1
lines changed

4 files changed

+136
-1
lines changed

packages/import-bundle/NEWS.md

+7
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
11
User-visible changes to `@endo/import-bundle`:
22

3+
# Next release
4+
5+
- Adds support for `test` format bundles, which simply return a promise for an
6+
object that resembles a module exports namespace with the objects specified
7+
on the symbol-named property @exports, which is deliberately not JSON
8+
serializable or passable.
9+
310
# v1.3.0 (2024-10-10)
411

512
- Adds support for `endoScript` format bundles.

packages/import-bundle/README.md

+26
Original file line numberDiff line numberDiff line change
@@ -29,20 +29,28 @@ evaluated, to enforce ocap rules.
2929

3030
The source can be bundled in a variety of "formats".
3131

32+
### endoZipBase64
33+
3234
By default, `bundleSource` uses a format named `endoZipBase64`, in which the
3335
source modules and a "compartment map" are captured in a Zip file and base-64
3436
encoded.
3537
The compartment map describes how to construct a set of [Hardened
3638
JavaScript](https://hardenedjs.org) compartments and how to load and link the
3739
source modules between them.
3840

41+
### endoScript
42+
3943
The `endoScript` format captures the sources as a single JavaScript program
4044
that completes with the entry module's namespace object.
4145

46+
### getExport
47+
4248
The `getExport` format captures the sources as a single CommonJS-style string,
4349
and wrapped in a callable function that provides the `exports` and
4450
`module.exports` context to which the exports can be attached.
4551

52+
### nestedEvaluate
53+
4654
More sophisticated than `getExport` is named `nestedEvaluate`.
4755
In this mode, the source tree is converted into a table of evaluable strings,
4856
one for each original module.
@@ -59,6 +67,24 @@ Note that the `nestedEvaluate` format receives a global endowment named
5967
`require`, although it will only be called if the source tree imported one of
6068
the few modules on the `bundle-source` "external" list.
6169

70+
### test
71+
72+
The `test` format is useful for mocking a bundle locally for a test and is
73+
deliberately not serializable or passable.
74+
Use this format in tests to avoid the need to generate a bundle from source,
75+
providing instead just the exports you need returned by `importBundle`.
76+
77+
```js
78+
import { importBundle, bundleTestExports } from '@endo/import-bundle';
79+
80+
test('who tests the tests', async t => {
81+
const bundle = bundleTestExports({ a: 10 });
82+
const namespace = await importBundle(bundle);
83+
t.is(namespace.a, 10);
84+
t.is(Object.prototype.toString.call(ns), '[object Module]');
85+
});
86+
```
87+
6288
## Options
6389

6490
`importBundle()` takes an options bag and optional additional powers.

packages/import-bundle/src/index.js

+64
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,55 @@ export async function importBundle(bundle, options = {}, powers = {}) {
7878
return namespace;
7979
}
8080

81+
// The 'test' format is not generated by bundleSource and is not
82+
// serializable as JSON and is not passable because copy-records cannot have symbol keys.
83+
if (moduleFormat === 'test') {
84+
const exports = bundle[Symbol.for('exports')];
85+
if (exports === undefined) {
86+
throw new Error(
87+
'Cannot import bundle with moduleFormat "test" that lacks an symbol-named property @exports and has likely been partially transported via JSON or eventual-send',
88+
);
89+
}
90+
91+
// We emulate a module exports namespace object, which has certain invariants:
92+
// Property names are only strings, so we will ignore symbol-named properties.
93+
// All properties are enumerable, so we will ignore non-enumerable properties.
94+
// All properties are non-writable values.
95+
// The namespace object is sealed, so frozen by implication.
96+
// We capture the value for each property now and never again consult the given exports object.
97+
return Object.freeze(
98+
Object.create(
99+
null,
100+
Object.fromEntries([
101+
...Object.entries(Object.getOwnPropertyDescriptors(exports))
102+
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
103+
.filter(
104+
([name, descriptor]) =>
105+
typeof name === 'string' && descriptor.enumerable,
106+
)
107+
.map(([name]) => [
108+
name,
109+
{
110+
value: exports[name],
111+
writable: false,
112+
enumerable: true,
113+
configurable: false,
114+
},
115+
]),
116+
[
117+
Symbol.toStringTag,
118+
{
119+
value: 'Module',
120+
writable: false,
121+
enumerable: false,
122+
configurable: false,
123+
},
124+
],
125+
]),
126+
),
127+
);
128+
}
129+
81130
let { source } = bundle;
82131
const { sourceMap } = bundle;
83132
if (moduleFormat === 'getExport') {
@@ -124,6 +173,21 @@ export async function importBundle(bundle, options = {}, powers = {}) {
124173
}
125174
}
126175

176+
/**
177+
* A utility function for producing test bundles, which are not seerializable
178+
* as JSON or passable.
179+
* @param {Record<PropertyKey, unknown>} exports
180+
*/
181+
export const bundleTestExports = exports => {
182+
const symbols = Object.getOwnPropertySymbols(exports);
183+
symbols.length > 0 &&
184+
Fail`exports must not have symbol-named properties, got: ${symbols.map(String).join(', ')}`;
185+
return {
186+
moduleFormat: 'test',
187+
[Symbol.for('exports')]: exports,
188+
};
189+
};
190+
127191
/*
128192
importBundle(bundle, { metering: { getMeter, meteringOptions } });
129193
importBundle(bundle, { transforms: [ meterTransform ], lexicals: { getMeter } });

packages/import-bundle/test/import-bundle.test.js

+39-1
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import crypto from 'crypto';
77
import { makeArchive } from '@endo/compartment-mapper/archive.js';
88
import bundleSource from '@endo/bundle-source';
99
import { makeReadPowers } from '@endo/compartment-mapper/node-powers.js';
10-
import { importBundle } from '../src/index.js';
10+
import { importBundle, bundleTestExports } from '../src/index.js';
1111

1212
const { read } = makeReadPowers({ fs, url, crypto });
1313

@@ -188,3 +188,41 @@ test('inescapable global properties, zip base64 format', async t => {
188188
});
189189
t.is(ns.default, 42);
190190
});
191+
192+
test('test the test format', async t => {
193+
const bundle = {
194+
moduleFormat: 'test',
195+
[Symbol.for('exports')]: {
196+
z: 43,
197+
default: 42,
198+
a: 41,
199+
},
200+
};
201+
const ns = await importBundle(bundle);
202+
t.is(ns.default, 42);
203+
t.deepEqual(Object.keys(ns), ['a', 'default', 'z']);
204+
t.is(Object.prototype.toString.call(ns), '[object Module]');
205+
});
206+
207+
test('test format must not round-trip via JSON', async t => {
208+
const bundle = JSON.parse(
209+
JSON.stringify({
210+
moduleFormat: 'test',
211+
[Symbol.for('exports')]: {
212+
default: 42,
213+
},
214+
}),
215+
);
216+
await t.throwsAsync(importBundle(bundle), {
217+
message: /Cannot import bundle with moduleFormat "test" that lacks/,
218+
});
219+
});
220+
221+
test('test bundle utility should fail early for symbol keys', t => {
222+
t.throws(() =>
223+
bundleTestExports({
224+
[Symbol.for('iterator')]: 1,
225+
[Symbol.iterator]: 'a',
226+
}),
227+
);
228+
});

0 commit comments

Comments
 (0)