fireemu 0.13.0 → 0.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +15 -13
- package/bin/fireemu.mjs +2 -53
- package/binary.mjs +15 -0
- package/package.json +14 -6
- package/testing-process.mjs +27 -0
- package/testing-snapshot.mjs +146 -0
- package/testing.mjs +169 -0
package/README.md
CHANGED
|
@@ -14,7 +14,7 @@ Fireemu is being developed to close these gaps. For example, it provides:
|
|
|
14
14
|
|
|
15
15
|
Fireemu is still in development and does not yet reproduce every feature and behavior of production Firebase. Before relying on Fireemu’s test results in CI or other automated workflows, we recommend differential testing against production Firebase services for the features and critical workflows your application uses. Run the same tests against Fireemu and a dedicated Firebase test project, and check for differences in results and behavior that could affect your application. Use a dedicated test project, not your live production project.
|
|
16
16
|
|
|
17
|
-
See the [capability manifest](https://github.com/t-k/fireemu/blob/v0.
|
|
17
|
+
See the [capability manifest](https://github.com/t-k/fireemu/blob/v0.15.0/crates/fireemu/src/capabilities.json) and [compatibility documentation](https://github.com/t-k/fireemu/blob/v0.15.0/docs/compatibility-contract.md) for supported features, known differences, and the status of compatibility verification.
|
|
18
18
|
|
|
19
19
|
## Quick start
|
|
20
20
|
|
|
@@ -66,13 +66,13 @@ Fireemu supports Cloud Firestore, Firebase Authentication (Identity Platform), C
|
|
|
66
66
|
| Cloud Pub/Sub | Schemas, BigQuery and Cloud Storage subscription delivery, and some APIs such as `UpdateTopic` are not supported. |
|
|
67
67
|
| Firebase App Check | Local testing with debug tokens is supported. Real attestation through Play Integrity, App Attest, DeviceCheck, or reCAPTCHA, and replay protection using limited-use tokens, are not supported. |
|
|
68
68
|
|
|
69
|
-
This table highlights key limitations; it is not exhaustive. Check individual API support and conditions with the following command or the [capability manifest](https://github.com/t-k/fireemu/blob/v0.
|
|
69
|
+
This table highlights key limitations; it is not exhaustive. Check individual API support and conditions with the following command or the [capability manifest](https://github.com/t-k/fireemu/blob/v0.15.0/crates/fireemu/src/capabilities.json).
|
|
70
70
|
|
|
71
71
|
```sh
|
|
72
72
|
npx fireemu capabilities
|
|
73
73
|
```
|
|
74
74
|
|
|
75
|
-
See the [Functions support inventory](https://github.com/t-k/fireemu/blob/v0.
|
|
75
|
+
See the [Functions support inventory](https://github.com/t-k/fireemu/blob/v0.15.0/docs/functions-export-inventory.md) for support by trigger type.
|
|
76
76
|
|
|
77
77
|
### Unsupported products
|
|
78
78
|
|
|
@@ -131,6 +131,8 @@ Use `--ui-port 0` to disable the UI. When automatic UI startup cannot bind the d
|
|
|
131
131
|
|
|
132
132
|
When a Functions codebase is loaded, the Functions page shows registered functions, triggers, invocation results, and logs. You can run scheduled functions immediately or advance the virtual clock to their next scheduled execution.
|
|
133
133
|
|
|
134
|
+
For clock-sensitive and parallel integration tests, [test clocks and independent worlds](https://github.com/t-k/fireemu/blob/v0.15.0/docs/test-worlds.md) describes explicit Node Date/timer/Cloud Tasks policies and the `fireemu/testing` helper. Separate worlds isolate their clocks, Rules, Functions and service state, including when they use the same project ID.
|
|
135
|
+
|
|
134
136
|
### Export data
|
|
135
137
|
|
|
136
138
|
To save data from a running emulator, run the following command in another terminal.
|
|
@@ -204,7 +206,7 @@ You can specify the configuration file with `--config`.
|
|
|
204
206
|
npx fireemu up --config ./fireemu.json
|
|
205
207
|
```
|
|
206
208
|
|
|
207
|
-
Keep Fireemu-specific settings in a Fireemu configuration file containing `"schemaVersion": 1`, rather than in `firebase.json`. See the [configuration schema](https://github.com/t-k/fireemu/blob/v0.
|
|
209
|
+
Keep Fireemu-specific settings in a Fireemu configuration file containing `"schemaVersion": 1`, rather than in `firebase.json`. See the [configuration schema](https://github.com/t-k/fireemu/blob/v0.15.0/spec/config/fireemu.schema.json) for all available settings.
|
|
208
210
|
|
|
209
211
|
### Firebase Authentication (Identity Platform) configuration
|
|
210
212
|
|
|
@@ -228,9 +230,9 @@ The `auth` section of `fireemu.json` lets you configure sign-in methods, passwor
|
|
|
228
230
|
| Public keys for verifying custom tokens and OIDC ID tokens | `auth.customTokenSigners`, `auth.idpSigners` |
|
|
229
231
|
| Sign-up quota configuration and local quota-exceeded simulation | `auth.quota`, `auth.quotaSimulation` |
|
|
230
232
|
|
|
231
|
-
See the [configuration schema](https://github.com/t-k/fireemu/blob/v0.
|
|
233
|
+
See the [configuration schema](https://github.com/t-k/fireemu/blob/v0.15.0/spec/config/fireemu.schema.json) for the format and accepted values of each setting.
|
|
232
234
|
|
|
233
|
-
Declare `auth.authorizedDomains`, for example `["localhost", "app.test"]`, to set the initial project-wide list in either profile. The list replaces the defaults, so include `localhost` if your tests use it. An empty list authorizes no host under `strict`; an absent or `null` setting keeps the existing defaults (`localhost` and the project's `firebaseapp.com` and `web.app` domains). Validation uses the Admin API's rules for the selected profile, including its conversion of numeric and boolean entries to strings and omission of `null` entries; use strings when writing the list. Fireemu intentionally keeps `localhost` in its defaults for development. The [recorded production sandbox baseline](https://github.com/t-k/fireemu/blob/v0.
|
|
235
|
+
Declare `auth.authorizedDomains`, for example `["localhost", "app.test"]`, to set the initial project-wide list in either profile. The list replaces the defaults, so include `localhost` if your tests use it. An empty list authorizes no host under `strict`; an absent or `null` setting keeps the existing defaults (`localhost` and the project's `firebaseapp.com` and `web.app` domains). Validation uses the Admin API's rules for the selected profile, including its conversion of numeric and boolean entries to strings and omission of `null` entries; use strings when writing the list. Fireemu intentionally keeps `localhost` in its defaults for development. The [recorded production sandbox baseline](https://github.com/t-k/fireemu/blob/v0.15.0/conformance/auth-action-production.json) does not include it; new production projects may also omit it. This default is a Fireemu-only convenience, and declaring the list replaces it. Tenant-scoped strict action links currently check the tenant's default domains rather than this project declaration; support for the parent project's live list is pending.
|
|
234
236
|
|
|
235
237
|
An Admin update changes the live list. A session reset restores the declared list, when present, and preserves an Admin-written list when no initial list was declared. Projects created later receive the declaration word for word, independently of the default project's live list; listing a particular project's `firebaseapp.com` or `web.app` domain therefore gives later projects that same domain. Clearing accounts leaves the live list alone. Auth export/import carries accounts without the domain list or its declaration. The `emulator` profile accepts the setting and exposes the list, while continuing to allow action-link continue URLs outside it.
|
|
236
238
|
|
|
@@ -324,7 +326,7 @@ Under the `strict` profile, TOTP is enabled only through the project's MFA confi
|
|
|
324
326
|
|
|
325
327
|
#### Enable multi-tenancy
|
|
326
328
|
|
|
327
|
-
Under the `strict` profile, enable `multiTenant.allowTenants` before using multi-tenancy: through the Admin API, or with `auth.multiTenant` in the file (see "Start with tenants" below). See the [compatibility documentation](https://github.com/t-k/fireemu/blob/v0.
|
|
329
|
+
Under the `strict` profile, enable `multiTenant.allowTenants` before using multi-tenancy: through the Admin API, or with `auth.multiTenant` in the file (see "Start with tenants" below). See the [compatibility documentation](https://github.com/t-k/fireemu/blob/v0.15.0/docs/compatibility-contract.md) for the supported Admin API scope.
|
|
328
330
|
|
|
329
331
|
#### Start with tenants
|
|
330
332
|
|
|
@@ -375,7 +377,7 @@ A schedule is driven by the virtual clock alone: real time passing runs nothing.
|
|
|
375
377
|
|
|
376
378
|
A manual run (`POST /v1/sessions/{session}/functions/{name}:run`) enqueues one run of a scheduled function stamped with the current virtual time. It does not consume the next occurrence, which still comes due and is subject to `scheduler.overlap` like any run. It answers `400 INVALID_ARGUMENT` for an unknown function, a function that is not scheduled, an overlap policy refusal, or a runtime that is shutting down, and `429 RESOURCE_EXHAUSTED` when the event queue is full. This is fireemu's own control, not a claim about production's run-now.
|
|
377
379
|
|
|
378
|
-
See the [configuration schema](https://github.com/t-k/fireemu/blob/v0.
|
|
380
|
+
See the [configuration schema](https://github.com/t-k/fireemu/blob/v0.15.0/spec/config/fireemu.schema.json) for available settings and values.
|
|
379
381
|
|
|
380
382
|
### Firestore history limits
|
|
381
383
|
|
|
@@ -453,7 +455,7 @@ Fireemu is compatible with the listed Local Emulator Suite products as shipped b
|
|
|
453
455
|
|
|
454
456
|
The `strict` and `emulator` profiles differ in some behaviors to serve their respective purposes. Where the official emulator and production Firebase disagree, Fireemu also adopts production behavior in some cases and records the difference.
|
|
455
457
|
|
|
456
|
-
Check support for the specific APIs and conditions your application uses, rather than relying on product names alone. The [compatibility contract](https://github.com/t-k/fireemu/blob/v0.
|
|
458
|
+
Check support for the specific APIs and conditions your application uses, rather than relying on product names alone. The [compatibility contract](https://github.com/t-k/fireemu/blob/v0.15.0/spec/compatibility/contract.json) records scope and known differences. The [compatibility documentation](https://github.com/t-k/fireemu/blob/v0.15.0/docs/compatibility-contract.md) explains the verification process and how to interpret its records.
|
|
457
459
|
|
|
458
460
|
### Network exposure of the Storage port
|
|
459
461
|
|
|
@@ -461,7 +463,7 @@ Like the official emulator, the Storage port reflects the requested object name
|
|
|
461
463
|
|
|
462
464
|
### Changes between versions
|
|
463
465
|
|
|
464
|
-
Configuration and behavior may change between releases. See the [CHANGELOG](https://github.com/t-k/fireemu/blob/v0.
|
|
466
|
+
Configuration and behavior may change between releases. See the [CHANGELOG](https://github.com/t-k/fireemu/blob/v0.15.0/CHANGELOG.md) for release changes.
|
|
465
467
|
|
|
466
468
|
## Performance
|
|
467
469
|
|
|
@@ -478,7 +480,7 @@ The following results come from one `standard` run at commit `f3b942c`. Ratios a
|
|
|
478
480
|
|
|
479
481
|
The 95% confidence interval for the startup-time ratio is `[21.4, 21.8]`. For the peak-memory ratio, calculated as official emulator divided by Fireemu, it is `[10.2, 11.1]`.
|
|
480
482
|
|
|
481
|
-
See [benchmark.yml](https://github.com/t-k/fireemu/blob/v0.
|
|
483
|
+
See [benchmark.yml](https://github.com/t-k/fireemu/blob/v0.15.0/.github/workflows/benchmark.yml) for the workflow and [`tools/bench/`](https://github.com/t-k/fireemu/tree/v0.15.0/tools/bench/) for the benchmark implementation.
|
|
482
484
|
|
|
483
485
|
## Reporting issues and contributing
|
|
484
486
|
|
|
@@ -486,8 +488,8 @@ When reporting an issue, include the Fireemu and Firebase SDK versions, the prof
|
|
|
486
488
|
|
|
487
489
|
For compatibility issues, also state whether you are comparing against the actual Firebase service or the official emulator, along with the expected and actual results. Remove credentials and personal information before sharing configuration files or logs.
|
|
488
490
|
|
|
489
|
-
See the [development guide](https://github.com/t-k/fireemu/blob/v0.
|
|
491
|
+
See the [development guide](https://github.com/t-k/fireemu/blob/v0.15.0/docs/development.md) for build and test instructions.
|
|
490
492
|
|
|
491
493
|
## License
|
|
492
494
|
|
|
493
|
-
Fireemu is licensed under the [Apache License 2.0](https://github.com/t-k/fireemu/blob/v0.
|
|
495
|
+
Fireemu is licensed under the [Apache License 2.0](https://github.com/t-k/fireemu/blob/v0.15.0/LICENSE). Third-party notices are listed in [THIRD_PARTY_LICENSES.txt](https://github.com/t-k/fireemu/blob/v0.15.0/THIRD_PARTY_LICENSES.txt).
|
package/bin/fireemu.mjs
CHANGED
|
@@ -12,13 +12,12 @@
|
|
|
12
12
|
// published beside this launcher.
|
|
13
13
|
|
|
14
14
|
import { spawn } from "node:child_process";
|
|
15
|
-
import {
|
|
16
|
-
import {
|
|
15
|
+
import { readFileSync } from "node:fs";
|
|
16
|
+
import { resolveBinary, ensureExecutable } from "../binary.mjs";
|
|
17
17
|
import { constants as osConstants } from "node:os";
|
|
18
18
|
import { dirname, join } from "node:path";
|
|
19
19
|
import { fileURLToPath } from "node:url";
|
|
20
20
|
|
|
21
|
-
const require = createRequire(import.meta.url);
|
|
22
21
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
23
22
|
|
|
24
23
|
/** This launcher's own manifest: the version, and the platforms published with it. */
|
|
@@ -31,33 +30,6 @@ function platformPackage() {
|
|
|
31
30
|
return `@fireemu/${process.platform}-${process.arch}`;
|
|
32
31
|
}
|
|
33
32
|
|
|
34
|
-
/** The daemon's file name inside a platform package. */
|
|
35
|
-
function binaryName() {
|
|
36
|
-
return process.platform === "win32" ? "fireemu.exe" : "fireemu";
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
/**
|
|
40
|
-
* The daemon to run.
|
|
41
|
-
*
|
|
42
|
-
* `FIREEMU_BINARY_PATH` wins so a vendored, air-gapped or locally built binary can be used
|
|
43
|
-
* without touching `node_modules`. Otherwise the platform package is resolved through Node's
|
|
44
|
-
* own algorithm, which finds it wherever the package manager put it -- a flat `node_modules`,
|
|
45
|
-
* a pnpm store, or a workspace root.
|
|
46
|
-
*/
|
|
47
|
-
function resolveBinary() {
|
|
48
|
-
const override = process.env.FIREEMU_BINARY_PATH;
|
|
49
|
-
if (override) {
|
|
50
|
-
return { path: override, from: "FIREEMU_BINARY_PATH" };
|
|
51
|
-
}
|
|
52
|
-
const pkg = platformPackage();
|
|
53
|
-
try {
|
|
54
|
-
const root = dirname(require.resolve(`${pkg}/package.json`));
|
|
55
|
-
return { path: join(root, "bin", binaryName()), from: pkg };
|
|
56
|
-
} catch {
|
|
57
|
-
return undefined;
|
|
58
|
-
}
|
|
59
|
-
}
|
|
60
|
-
|
|
61
33
|
/** The error a user can act on: what this host is, and what was published. */
|
|
62
34
|
function noBinaryMessage() {
|
|
63
35
|
const supported = Object.keys(manifest().optionalDependencies ?? {})
|
|
@@ -84,29 +56,6 @@ function noBinaryMessage() {
|
|
|
84
56
|
].join("\n");
|
|
85
57
|
}
|
|
86
58
|
|
|
87
|
-
/**
|
|
88
|
-
* Makes sure the binary is executable.
|
|
89
|
-
*
|
|
90
|
-
* npm preserves the executable bit through a tarball, but a registry proxy, a `pnpm` store on
|
|
91
|
-
* a filesystem that drops modes, or an archive round-tripped through a zip can lose it. Fixing
|
|
92
|
-
* it here keeps the package free of an install script; a read-only installation that is
|
|
93
|
-
* already executable is left alone.
|
|
94
|
-
*/
|
|
95
|
-
function ensureExecutable(path) {
|
|
96
|
-
if (process.platform === "win32") return;
|
|
97
|
-
try {
|
|
98
|
-
accessSync(path, fsConstants.X_OK);
|
|
99
|
-
return;
|
|
100
|
-
} catch {
|
|
101
|
-
// Fall through and try to grant it.
|
|
102
|
-
}
|
|
103
|
-
try {
|
|
104
|
-
chmodSync(path, 0o755);
|
|
105
|
-
} catch {
|
|
106
|
-
// A read-only install: let the spawn below report the real failure.
|
|
107
|
-
}
|
|
108
|
-
}
|
|
109
|
-
|
|
110
59
|
function main() {
|
|
111
60
|
const binary = resolveBinary();
|
|
112
61
|
if (!binary) {
|
package/binary.mjs
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// Resolve package-local optional platform dependencies without starting the CLI.
|
|
2
|
+
import { createRequire } from 'node:module';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
import { accessSync, chmodSync, constants } from 'node:fs';
|
|
5
|
+
const require = createRequire(import.meta.url);
|
|
6
|
+
export function resolveBinary(override = process.env.FIREEMU_BINARY_PATH) {
|
|
7
|
+
if (override) return {path:override,from:'FIREEMU_BINARY_PATH'};
|
|
8
|
+
const name = `@fireemu/${process.platform}-${process.arch}`;
|
|
9
|
+
try { return {path:join(dirname(require.resolve(`${name}/package.json`)),'bin',process.platform==='win32'?'fireemu.exe':'fireemu'),from:name}; }
|
|
10
|
+
catch { return undefined; }
|
|
11
|
+
}
|
|
12
|
+
export function ensureExecutable(path) {
|
|
13
|
+
if(process.platform==='win32')return;
|
|
14
|
+
try {accessSync(path,constants.X_OK);} catch {try{chmodSync(path,0o755);}catch{/* Spawn reports the actionable failure. */}}
|
|
15
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fireemu",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.15.0",
|
|
4
4
|
"description": "A local runtime for testing Firebase SDK and Functions code, checked against production Firebase behavior",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"emulator",
|
|
@@ -21,19 +21,27 @@
|
|
|
21
21
|
},
|
|
22
22
|
"files": [
|
|
23
23
|
"bin/fireemu.mjs",
|
|
24
|
+
"binary.mjs",
|
|
25
|
+
"testing.mjs",
|
|
26
|
+
"testing-snapshot.mjs",
|
|
27
|
+
"testing-process.mjs",
|
|
24
28
|
"README.md",
|
|
25
29
|
"LICENSE",
|
|
26
30
|
"THIRD_PARTY_LICENSES.txt"
|
|
27
31
|
],
|
|
28
32
|
"type": "module",
|
|
29
33
|
"optionalDependencies": {
|
|
30
|
-
"@fireemu/darwin-arm64": "0.
|
|
31
|
-
"@fireemu/darwin-x64": "0.
|
|
32
|
-
"@fireemu/linux-x64": "0.
|
|
33
|
-
"@fireemu/linux-arm64": "0.
|
|
34
|
-
"@fireemu/win32-x64": "0.
|
|
34
|
+
"@fireemu/darwin-arm64": "0.15.0",
|
|
35
|
+
"@fireemu/darwin-x64": "0.15.0",
|
|
36
|
+
"@fireemu/linux-x64": "0.15.0",
|
|
37
|
+
"@fireemu/linux-arm64": "0.15.0",
|
|
38
|
+
"@fireemu/win32-x64": "0.15.0"
|
|
35
39
|
},
|
|
36
40
|
"engines": {
|
|
37
41
|
"node": ">=20"
|
|
42
|
+
},
|
|
43
|
+
"exports": {
|
|
44
|
+
"./testing": "./testing.mjs",
|
|
45
|
+
"./package.json": "./package.json"
|
|
38
46
|
}
|
|
39
47
|
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// Unix command group owner. The application runs in a separate process, so its code
|
|
2
|
+
// cannot block this native lifecycle loop or consume the parent's lifetime pipe.
|
|
3
|
+
import { spawn } from 'node:child_process';
|
|
4
|
+
|
|
5
|
+
const [command, ...args] = process.argv.slice(2);
|
|
6
|
+
let stopping = false;
|
|
7
|
+
function retire() {
|
|
8
|
+
if (stopping) return;
|
|
9
|
+
stopping = true;
|
|
10
|
+
// This process remains the group's leader until escalation, preventing ID reuse.
|
|
11
|
+
try { process.kill(-process.pid, 'SIGTERM'); } catch {}
|
|
12
|
+
setTimeout(() => {
|
|
13
|
+
try { process.kill(-process.pid, 'SIGKILL'); } catch { process.exit(1); }
|
|
14
|
+
}, 100);
|
|
15
|
+
}
|
|
16
|
+
process.on('SIGTERM', retire);
|
|
17
|
+
process.on('SIGINT', retire);
|
|
18
|
+
process.stdin.on('end', retire);
|
|
19
|
+
process.stdin.on('error', retire);
|
|
20
|
+
process.stdin.resume();
|
|
21
|
+
const child = spawn(command, args, {stdio:['ignore','inherit','inherit']});
|
|
22
|
+
function finish(result) {
|
|
23
|
+
if (process.connected) process.send(result, retire);
|
|
24
|
+
else retire();
|
|
25
|
+
}
|
|
26
|
+
child.once('error', error => finish({type:'command-exit',error:error.message}));
|
|
27
|
+
child.once('exit', (code, signal) => finish({type:'command-exit',code,signal}));
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
// Snapshot caller-owned input once; each generation gets its own writable copy.
|
|
2
|
+
import { constants } from 'node:fs';
|
|
3
|
+
import { cp, mkdir, readFile, writeFile, realpath, symlink, lstat, stat, unlink, readdir, readlink } from 'node:fs/promises';
|
|
4
|
+
import { basename, dirname, join, resolve, relative, sep, delimiter, isAbsolute } from 'node:path';
|
|
5
|
+
|
|
6
|
+
export async function snapshotInputs(options, root) {
|
|
7
|
+
const cwd=resolve(options.cwd??process.cwd());
|
|
8
|
+
const privateRoot=await realpath(root);
|
|
9
|
+
const baseline=join(root,'baseline');
|
|
10
|
+
await mkdir(baseline,{mode:0o700});
|
|
11
|
+
let sequence=0;
|
|
12
|
+
const copyFile=async(value,base)=>{
|
|
13
|
+
if(typeof value!=='string')return value;
|
|
14
|
+
const destination=join(baseline,`input-${sequence++}-${basename(value)}`);
|
|
15
|
+
await cp(resolve(base,value),destination,{recursive:true,mode:constants.COPYFILE_FICLONE,dereference:true});
|
|
16
|
+
return destination;
|
|
17
|
+
};
|
|
18
|
+
const graph=new Map();
|
|
19
|
+
const inside=(parent,path)=>{const suffix=relative(parent,path);return suffix===''||(!suffix.startsWith(`..${sep}`)&&suffix!=='..'&&!isAbsolute(suffix));};
|
|
20
|
+
const captureTree=async(source,destination)=>{
|
|
21
|
+
const canonical=await realpath(source);
|
|
22
|
+
graph.set(canonical,destination);
|
|
23
|
+
const links=[];
|
|
24
|
+
await cp(canonical,destination,{recursive:true,mode:constants.COPYFILE_FICLONE,dereference:false,verbatimSymlinks:true,filter:async(path,target)=>{
|
|
25
|
+
if(['.git','.worktree'].includes(basename(path)))return false;
|
|
26
|
+
if((await lstat(path)).isSymbolicLink())links.push([path,target]);
|
|
27
|
+
return true;
|
|
28
|
+
}});
|
|
29
|
+
for(const [sourceLink,targetLink] of links){
|
|
30
|
+
const target=await realpath(sourceLink);
|
|
31
|
+
let copied;
|
|
32
|
+
const owners=[...graph].filter(([original])=>inside(original,target)).sort((a,b)=>b[0].length-a[0].length);
|
|
33
|
+
if(owners.length){const [original,snapshot]=owners[0];copied=join(snapshot,relative(original,target));}
|
|
34
|
+
else{
|
|
35
|
+
if(graph.size>=4096)throw Error('Functions dependency graph exceeds the snapshot budget');
|
|
36
|
+
// pnpm packages resolve dependencies from their enclosing node_modules,
|
|
37
|
+
// including siblings that are not explicit links inside the package.
|
|
38
|
+
let context=dirname(target);
|
|
39
|
+
while(basename(context)!=='node_modules'&&dirname(context)!==context)context=dirname(context);
|
|
40
|
+
if(basename(context)!=='node_modules')context=target;
|
|
41
|
+
const destination=join(baseline,`linked-${sequence++}`,...(basename(context)==='node_modules'?['node_modules']:[]));
|
|
42
|
+
await captureTree(context,destination);
|
|
43
|
+
copied=join(destination,relative(context,target));
|
|
44
|
+
}
|
|
45
|
+
await unlink(targetLink);
|
|
46
|
+
const kind=(await lstat(target)).isDirectory()?'dir':'file';
|
|
47
|
+
await symlink(process.platform==='win32'?copied:relative(dirname(targetLink),copied),targetLink,process.platform==='win32'&&kind==='dir'?'junction':kind);
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
const copySource=async(value,base)=>{
|
|
51
|
+
if(typeof value!=='string')return value;
|
|
52
|
+
const source=await realpath(resolve(base,value));
|
|
53
|
+
if(inside(source,privateRoot))throw Error('world temporary directory must be outside the Functions source');
|
|
54
|
+
const dependencies=[];
|
|
55
|
+
for(let ancestor=dirname(source),level=1;;ancestor=dirname(ancestor),level++){
|
|
56
|
+
const modules=join(ancestor,'node_modules');
|
|
57
|
+
try{if((await stat(modules)).isDirectory())dependencies.push({modules,level});}catch(error){if(error.code!=='ENOENT')throw error;}
|
|
58
|
+
if(dirname(ancestor)===ancestor)break;
|
|
59
|
+
}
|
|
60
|
+
const container=join(baseline,`functions-${sequence++}`);
|
|
61
|
+
const depth=dependencies.at(-1)?.level??0;
|
|
62
|
+
const destination=join(container,...Array(depth).fill('source'));
|
|
63
|
+
await captureTree(source,destination);
|
|
64
|
+
for(const {modules,level} of dependencies){
|
|
65
|
+
let ancestor=destination;for(let count=0;count<level;count++)ancestor=dirname(ancestor);
|
|
66
|
+
const target=join(ancestor,'node_modules'),canonical=await realpath(modules);
|
|
67
|
+
if(graph.has(canonical))await symlink(process.platform==='win32'?graph.get(canonical):relative(ancestor,graph.get(canonical)),target,process.platform==='win32'?'junction':'dir');
|
|
68
|
+
else await captureTree(modules,target);
|
|
69
|
+
}
|
|
70
|
+
return destination;
|
|
71
|
+
};
|
|
72
|
+
const snapshotFirebase=async(input,base)=>{
|
|
73
|
+
const value=structuredClone(input);
|
|
74
|
+
for(const service of ['firestore','storage']){
|
|
75
|
+
const sections=Array.isArray(value[service])?value[service]:[value[service]];
|
|
76
|
+
for(const section of sections){if(!section)continue;for(const key of ['rules','indexes'])if(section[key])section[key]=await copyFile(section[key],base);}
|
|
77
|
+
}
|
|
78
|
+
const codebases=Array.isArray(value.functions)?value.functions:[value.functions];
|
|
79
|
+
for(const codebase of codebases)if(codebase?.source)codebase.source=await copySource(codebase.source,base);
|
|
80
|
+
for(const emulator of Object.values(value.emulators??{}))if(emulator&&typeof emulator==='object'){emulator.host='127.0.0.1';emulator.port=0;}
|
|
81
|
+
const path=join(baseline,'firebase.json');
|
|
82
|
+
await writeFile(path,JSON.stringify(value),{mode:0o600});
|
|
83
|
+
// Storage target associations require the original project's alias file.
|
|
84
|
+
try{await cp(join(base,'.firebaserc'),join(baseline,'.firebaserc'));}catch(error){if(error.code!=='ENOENT')throw error;}
|
|
85
|
+
return path;
|
|
86
|
+
};
|
|
87
|
+
let input=options.config??{schemaVersion:1};
|
|
88
|
+
let configBase=cwd;
|
|
89
|
+
if(typeof input==='string'){const path=resolve(cwd,input);input=JSON.parse(await readFile(path,'utf8'));configBase=dirname(path);}
|
|
90
|
+
let config;
|
|
91
|
+
if(input.schemaVersion===undefined){config={schemaVersion:1,firebaseJson:await snapshotFirebase(input,configBase)};}
|
|
92
|
+
else{
|
|
93
|
+
config=structuredClone(input);
|
|
94
|
+
if(config.firebaseJson){const path=resolve(configBase,config.firebaseJson);config.firebaseJson=await snapshotFirebase(JSON.parse(await readFile(path,'utf8')),dirname(path));}
|
|
95
|
+
for(const [section,key] of [['rules','source'],['firestore','indexFile'],['firestore','textIndexDefinitionFile'],['storage','rules'],['functions','manifest']]){
|
|
96
|
+
if(config[section]?.[key])config[section][key]=await copyFile(config[section][key],cwd);
|
|
97
|
+
}
|
|
98
|
+
if(config.functions?.source)config.functions.source=await copySource(config.functions.source,cwd);
|
|
99
|
+
}
|
|
100
|
+
if(options.firebaseJson){const path=resolve(cwd,options.firebaseJson);config.firebaseJson=await snapshotFirebase(JSON.parse(await readFile(path,'utf8')),dirname(path));}
|
|
101
|
+
if(options.functionsSource){config.functions??={};config.functions.source=await copySource(options.functionsSource,cwd);}
|
|
102
|
+
const functionsConfigured=Boolean(graph.size||config.functions?.manifest||config.functions?.runner);
|
|
103
|
+
const nodePaths=[];
|
|
104
|
+
for(const entry of (functionsConfigured?options.env?.NODE_PATH??'':'').split(delimiter).filter(Boolean)){
|
|
105
|
+
let original;
|
|
106
|
+
try{original=await realpath(resolve(cwd,entry));}catch(error){if(error.code==='ENOENT')continue;throw error;}
|
|
107
|
+
let destination=graph.get(original);
|
|
108
|
+
if(!destination){destination=join(baseline,`node-path-${sequence++}`,'node_modules');await captureTree(original,destination);}
|
|
109
|
+
nodePaths.push(destination);
|
|
110
|
+
}
|
|
111
|
+
config.daemon={...config.daemon,clockStart:options.clockStart??config.daemon?.clockStart??'2026-01-01T00:00:00Z',seed:options.seed??config.daemon?.seed??1,authProject:options.projectId??config.daemon?.authProject??'demo-test-world'};
|
|
112
|
+
for(const service of ['firestore','http','storage','functions','eventarc','tasks','pubsub','hub','ui','logging'])config.daemon[`${service}Port`]=0;
|
|
113
|
+
config.functions={...config.functions,clock:{date:'virtual',timers:'real',tasks:'virtual',...config.functions?.clock,...options.clock}};
|
|
114
|
+
await writeFile(join(baseline,'fireemu.json'),JSON.stringify(config),{mode:0o600});
|
|
115
|
+
let seed;
|
|
116
|
+
if(options.import){seed=join(baseline,'seed');await cp(resolve(cwd,options.import),seed,{recursive:true,mode:constants.COPYFILE_FICLONE,dereference:true});}
|
|
117
|
+
return {baseline,config,seed,nodePaths,functionsConfigured};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export async function prepareGeneration(snapshot,directory){
|
|
121
|
+
await mkdir(directory,{mode:0o700});
|
|
122
|
+
const inputs=join(directory,'inputs');
|
|
123
|
+
await cp(snapshot.baseline,inputs,{recursive:true,mode:constants.COPYFILE_FICLONE,dereference:false,verbatimSymlinks:true});
|
|
124
|
+
const rewrite=value=>typeof value==='string'&&value.startsWith(`${snapshot.baseline}${sep}`)?`${inputs}${value.slice(snapshot.baseline.length)}`:Array.isArray(value)?value.map(rewrite):value&&typeof value==='object'?Object.fromEntries(Object.entries(value).map(([key,item])=>[key,rewrite(item)])):value;
|
|
125
|
+
// Windows junctions are absolute; copying them verbatim would expose the baseline.
|
|
126
|
+
const relocateLinks=async path=>{
|
|
127
|
+
for(const entry of await readdir(path,{withFileTypes:true})){
|
|
128
|
+
const child=join(path,entry.name);
|
|
129
|
+
if(entry.isSymbolicLink()){
|
|
130
|
+
const target=await readlink(child);
|
|
131
|
+
const relocated=rewrite(target);
|
|
132
|
+
if(relocated!==target){
|
|
133
|
+
const kind=(await lstat(target)).isDirectory()?'dir':'file';
|
|
134
|
+
await unlink(child);
|
|
135
|
+
await symlink(relocated,child,process.platform==='win32'&&kind==='dir'?'junction':kind);
|
|
136
|
+
}
|
|
137
|
+
}else if(entry.isDirectory())await relocateLinks(child);
|
|
138
|
+
}
|
|
139
|
+
};
|
|
140
|
+
await relocateLinks(inputs);
|
|
141
|
+
const config=rewrite(snapshot.config);
|
|
142
|
+
if(config.firebaseJson){const firebase=rewrite(JSON.parse(await readFile(config.firebaseJson,'utf8')));await writeFile(config.firebaseJson,JSON.stringify(firebase),{mode:0o600});}
|
|
143
|
+
await writeFile(join(inputs,'fireemu.json'),JSON.stringify(config),{mode:0o600});
|
|
144
|
+
await mkdir(join(directory,'tmp'),{mode:0o700});
|
|
145
|
+
return {configPath:join(inputs,'fireemu.json'),cwd:inputs,seed:snapshot.seed?rewrite(snapshot.seed):undefined,nodePath:(snapshot.nodePaths??[]).map(rewrite).join(delimiter)};
|
|
146
|
+
}
|
package/testing.mjs
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
// A test world owns a daemon, private input snapshots and generation-scoped clients.
|
|
2
|
+
import { spawn } from 'node:child_process';
|
|
3
|
+
import { mkdtemp, readFile, rm } from 'node:fs/promises';
|
|
4
|
+
import { randomUUID } from 'node:crypto';
|
|
5
|
+
import { tmpdir } from 'node:os';
|
|
6
|
+
import { join, resolve } from 'node:path';
|
|
7
|
+
import { fileURLToPath } from 'node:url';
|
|
8
|
+
import { setTimeout as delay } from 'node:timers/promises';
|
|
9
|
+
import { resolveBinary, ensureExecutable } from './binary.mjs';
|
|
10
|
+
import { snapshotInputs, prepareGeneration } from './testing-snapshot.mjs';
|
|
11
|
+
|
|
12
|
+
const endpointVariables = {firestore:'FIRESTORE_EMULATOR_HOST',auth:'FIREBASE_AUTH_EMULATOR_HOST',storage:'FIREBASE_STORAGE_EMULATOR_HOST',functions:'FIREEMU_FUNCTIONS_HOST',eventarc:'CLOUD_EVENTARC_EMULATOR_HOST',tasks:'CLOUD_TASKS_EMULATOR_HOST',pubsub:'PUBSUB_EMULATOR_HOST',hub:'FIREBASE_EMULATOR_HUB',logging:'FIREBASE_LOGGING_EMULATOR_HOST'};
|
|
13
|
+
const emulatorVariables = new Set([...Object.values(endpointVariables),'FIREBASE_CONFIG','NODE_PATH','FIREBASE_FIRESTORE_EMULATOR_ADDRESS','STORAGE_EMULATOR_HOST','FIREEMU_CONTROL_TOKEN','FIREEMU_CONTROL_URL','FIREEMU_APP_CHECK_EMULATOR_HOST','FIREEMU_APP_CHECK_JWKS_URL','FIREEMU_APP_CHECK_DEBUG_TOKEN_URL','FIREBASE_DATABASE_EMULATOR_HOST']);
|
|
14
|
+
const emptyEnvironment=base=>Object.fromEntries(Object.entries(base).filter(([name])=>!emulatorVariables.has(name)));
|
|
15
|
+
|
|
16
|
+
function supervise(command,args,options){
|
|
17
|
+
const child=spawn(command,args,{...options,stdio:options.stdio??['ignore','pipe','pipe'],windowsHide:true,detached:process.platform!=='win32'});
|
|
18
|
+
let output='',errorOutput='',result,commandResult;
|
|
19
|
+
if(options.commandOwner)child.on('message',message=>{if(message?.type==='command-exit')commandResult=message;});
|
|
20
|
+
child.stdin?.on('error',()=>{});
|
|
21
|
+
child.stdout?.on('data',data=>{output=(output+data).slice(-65536);});
|
|
22
|
+
child.stderr?.on('data',data=>{errorOutput=(errorOutput+data).slice(-65536);});
|
|
23
|
+
const exited=new Promise(resolve=>{
|
|
24
|
+
child.once('error',error=>{result={error};resolve(result);});
|
|
25
|
+
child.once('close',(code,signal)=>{result??=commandResult?.error?{error:Error(commandResult.error)}:commandResult??{code,signal};resolve(result);});
|
|
26
|
+
});
|
|
27
|
+
return {child,exited,result:()=>result,output:()=>output,errorOutput:()=>errorOutput};
|
|
28
|
+
}
|
|
29
|
+
async function stop(process,timeout=5000){
|
|
30
|
+
if(!process||process.result())return;
|
|
31
|
+
const signal=kind=>{if(process.result())return;try{if(globalThis.process.platform==='win32')process.child.kill(kind);else globalThis.process.kill(-process.child.pid,kind);}catch(error){if(error.code!=='ESRCH')throw error;}};
|
|
32
|
+
process.child.stdin?.end();
|
|
33
|
+
signal('SIGTERM');
|
|
34
|
+
let timer;
|
|
35
|
+
const grace=new Promise(resolve=>{timer=setTimeout(()=>resolve(false),timeout);});
|
|
36
|
+
const retired=await Promise.race([process.exited.then(()=>true),grace]);
|
|
37
|
+
clearTimeout(timer);
|
|
38
|
+
if(!retired){
|
|
39
|
+
signal('SIGKILL');
|
|
40
|
+
const forced=new Promise(resolve=>{timer=setTimeout(()=>resolve(false),5000);});
|
|
41
|
+
const closed=await Promise.race([process.exited.then(()=>true),forced]);
|
|
42
|
+
clearTimeout(timer);
|
|
43
|
+
if(!closed)throw Error('test world process tree did not close after forced retirement');
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
function endpoints(descriptor){
|
|
47
|
+
const result={};
|
|
48
|
+
const decode=value=>{const url=new URL(value.includes('://')?value:`http://${value}`);return Object.freeze({host:url.hostname,port:Number(url.port),url:`${url.protocol}//${url.host}`});};
|
|
49
|
+
result.control=decode(descriptor.controlUrl);
|
|
50
|
+
for(const [service,name] of Object.entries(endpointVariables))if(descriptor.environment[name])result[service]=decode(descriptor.environment[name]);
|
|
51
|
+
return Object.freeze(result);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Create an independent emulator world; no method changes the parent's environment. */
|
|
55
|
+
export async function createTestWorld(options={}){
|
|
56
|
+
options={...options,config:typeof options.config==='object'?structuredClone(options.config):options.config,services:options.services?[...options.services]:undefined,env:{...(options.env??process.env)},clock:options.clock?{...options.clock}:undefined};
|
|
57
|
+
const binary=resolveBinary(options.binaryPath?resolve(options.binaryPath):undefined);
|
|
58
|
+
if(!binary)throw Error('fireemu could not start a test world: install the platform package or supply binaryPath');
|
|
59
|
+
const timeout=options.startupTimeoutMs??60_000;
|
|
60
|
+
if(!Number.isSafeInteger(timeout)||timeout<=0)throw TypeError('startupTimeoutMs must be a positive integer');
|
|
61
|
+
let services=options.services;
|
|
62
|
+
if(services&&(!Array.isArray(services)||!services.length||services.some(name=>typeof name!=='string'||! /^(auth|firestore|storage|functions(?::[A-Za-z0-9_-]+)?|pubsub|appcheck)$/.test(name))))throw TypeError('services must name supported test-world services');
|
|
63
|
+
const id=randomUUID();
|
|
64
|
+
const directory=await mkdtemp(join(resolve(options.tempRoot??tmpdir()),'fireemu-world-'));
|
|
65
|
+
let snapshot,current,tail=Promise.resolve(),closing=false,disposed=false,generation=0;
|
|
66
|
+
const clients=new Set();
|
|
67
|
+
const retiring=new Set();
|
|
68
|
+
const enqueue=operation=>{const job=tail.then(operation);tail=job.catch(()=>{});return job;};
|
|
69
|
+
const check=()=>{if(closing||disposed)throw Error('test world is disposed');if(!current?.descriptor||current.process.result())throw Error('test world generation is unavailable');};
|
|
70
|
+
const retire=async()=>{
|
|
71
|
+
if(current)retiring.add(current);
|
|
72
|
+
current=undefined;
|
|
73
|
+
const clientResults=await Promise.allSettled([...clients].map(async client=>{await stop(client);clients.delete(client);}));
|
|
74
|
+
const daemonResults=await Promise.allSettled([...retiring].map(async active=>{
|
|
75
|
+
await stop(active.process,options.shutdownTimeoutMs??5000);
|
|
76
|
+
if(!clients.size){await rm(active.directory,{recursive:true,force:true});retiring.delete(active);}
|
|
77
|
+
}));
|
|
78
|
+
const failures=[...clientResults,...daemonResults].filter(result=>result.status==='rejected').map(result=>result.reason);
|
|
79
|
+
if(failures.length)throw new AggregateError(failures,'test world retirement failed; cleanup can be retried');
|
|
80
|
+
};
|
|
81
|
+
const requestFor=async(active,method,path,body,signal)=>{
|
|
82
|
+
const response=await fetch(`${active.descriptor.controlUrl}${path}`,{method,headers:{authorization:`Bearer ${active.descriptor.controlToken}`,...(body===undefined?{}:{'content-type':'application/json'})},body:body===undefined?undefined:JSON.stringify(body),signal:signal??AbortSignal.timeout(options.requestTimeoutMs??10_000)});
|
|
83
|
+
const value=await response.json();
|
|
84
|
+
if(!response.ok)throw Error(`test world control ${response.status}: ${value.error?.message??JSON.stringify(value)}`);
|
|
85
|
+
return value;
|
|
86
|
+
};
|
|
87
|
+
const start=async()=>{
|
|
88
|
+
options.signal?.throwIfAborted();
|
|
89
|
+
const next=generation+1;
|
|
90
|
+
const generationDirectory=join(directory,`generation-${next}`);
|
|
91
|
+
const prepared=await prepareGeneration(snapshot,generationDirectory);
|
|
92
|
+
const readyFile=join(generationDirectory,'ready.json');
|
|
93
|
+
const args=['up','--config',prepared.configPath,'--project',snapshot.config.daemon.authProject,'--only',services.join(','),'--ready-file',readyFile,'--owner-stdin','--log-verbosity','quiet'];
|
|
94
|
+
for(const name of ['firestore','http','storage','functions','eventarc','tasks','pubsub','hub','ui','logging'])args.push(`--${name}-port`,'0');
|
|
95
|
+
if(prepared.seed)args.push('--import',prepared.seed);
|
|
96
|
+
const temporary=join(generationDirectory,'tmp');
|
|
97
|
+
const environment={...emptyEnvironment(options.env??process.env),NODE_PATH:prepared.nodePath,TMPDIR:temporary,TEMP:temporary,TMP:temporary,FIREEMU_TEST_WORLD_ID:id,FIREEMU_TEST_WORLD_GENERATION:String(next)};
|
|
98
|
+
ensureExecutable(binary.path);
|
|
99
|
+
const daemon=supervise(binary.path,args,{cwd:prepared.cwd,env:environment,stdio:['pipe','pipe','pipe']});
|
|
100
|
+
const active={process:daemon,directory:generationDirectory};current=active;
|
|
101
|
+
const deadline=Date.now()+timeout;
|
|
102
|
+
try{
|
|
103
|
+
while(Date.now()<deadline){
|
|
104
|
+
options.signal?.throwIfAborted();
|
|
105
|
+
if(daemon.result())throw Error(`fireemu could not start test world: ${daemon.result().error?.message??daemon.errorOutput()??'daemon exited'}`);
|
|
106
|
+
try{
|
|
107
|
+
const descriptor=JSON.parse(await readFile(readyFile,'utf8'));
|
|
108
|
+
if(descriptor.schemaVersion!==1||descriptor.pid!==daemon.child.pid||descriptor.projectId!==snapshot.config.daemon.authProject)throw Error('invalid test-world readiness descriptor');
|
|
109
|
+
active.descriptor=descriptor;
|
|
110
|
+
await requestFor(active,'GET','/health/ready',undefined,AbortSignal.timeout(Math.min(1000,timeout)));
|
|
111
|
+
if(daemon.result())throw Error('daemon exited during test-world readiness');
|
|
112
|
+
active.endpoints=endpoints(descriptor);
|
|
113
|
+
active.environment=Object.freeze({...descriptor.environment,NODE_PATH:prepared.nodePath,TMPDIR:temporary,TEMP:temporary,TMP:temporary,FIREEMU_TEST_WORLD_ID:id,FIREEMU_TEST_WORLD_GENERATION:String(next)});
|
|
114
|
+
generation=next;
|
|
115
|
+
return;
|
|
116
|
+
}catch(error){if(error.code!=='ENOENT'&&!(error instanceof SyntaxError)&&error.name!=='TimeoutError'&&error.name!=='TypeError')throw error;}
|
|
117
|
+
await delay(10);
|
|
118
|
+
}
|
|
119
|
+
throw Error('fireemu test-world startup timed out');
|
|
120
|
+
}catch(error){await retire();throw error;}
|
|
121
|
+
};
|
|
122
|
+
const control=(method,path,body)=>enqueue(async()=>{check();return requestFor(current,method,path,body);});
|
|
123
|
+
const world={
|
|
124
|
+
get generation(){return generation;},
|
|
125
|
+
get endpoints(){check();return current.endpoints;},
|
|
126
|
+
get environment(){check();return current.environment;},
|
|
127
|
+
get directory(){return directory;},
|
|
128
|
+
control,
|
|
129
|
+
clock:Object.freeze({
|
|
130
|
+
get:()=>control('GET','/v1/sessions/default'),
|
|
131
|
+
set:(instant,{allowBackwards=false}={})=>control('POST','/v1/sessions/default/clock:set',{instant,allowBackwards}),
|
|
132
|
+
advance:duration=>control('POST','/v1/sessions/default/clock:advance',typeof duration==='number'?{millis:duration}:duration),
|
|
133
|
+
advanceTo:instant=>control('POST','/v1/sessions/default/clock:advanceTo',{instant}),
|
|
134
|
+
runDue:(budget=1000)=>control('POST','/v1/sessions/default/clock:runDue',{budget}),
|
|
135
|
+
}),
|
|
136
|
+
reset:()=>enqueue(async()=>{check();await retire();await start();return world;}),
|
|
137
|
+
dispose:()=>{closing=true;return enqueue(async()=>{if(disposed)return;await retire();await rm(directory,{recursive:true,force:true});disposed=true;});},
|
|
138
|
+
async run(command,args=[],runOptions={}){
|
|
139
|
+
await tail;check();
|
|
140
|
+
const active=current;
|
|
141
|
+
const windows=process.platform==='win32';
|
|
142
|
+
const stdio=runOptions.stdio==='inherit'?['pipe','inherit','inherit']:['pipe','pipe','pipe'];
|
|
143
|
+
if(!windows)stdio.push('ipc');
|
|
144
|
+
const client=supervise(windows?binary.path:process.execPath,windows?['__test-world-command',command,...args]:[fileURLToPath(new URL('./testing-process.mjs',import.meta.url)),command,...args],{cwd:runOptions.cwd??options.cwd??process.cwd(),env:{...emptyEnvironment(process.env),...emptyEnvironment(runOptions.env??{}),...active.environment},stdio,commandOwner:!windows});
|
|
145
|
+
clients.add(client);
|
|
146
|
+
const aborted=()=>{void stop(client).catch(()=>{});};
|
|
147
|
+
runOptions.signal?.addEventListener('abort',aborted,{once:true});
|
|
148
|
+
if(runOptions.signal?.aborted)aborted();
|
|
149
|
+
try{
|
|
150
|
+
const result=await client.exited;
|
|
151
|
+
if(current!==active||closing)throw Error('test world command was retired with its generation');
|
|
152
|
+
runOptions.signal?.throwIfAborted();
|
|
153
|
+
if(result.error)throw result.error;
|
|
154
|
+
if(result.code!==0)throw Error(`test world command exited ${result.code??result.signal}: ${client.errorOutput()}`);
|
|
155
|
+
return {...result,stdout:client.output(),stderr:client.errorOutput()};
|
|
156
|
+
}finally{clients.delete(client);runOptions.signal?.removeEventListener('abort',aborted);}
|
|
157
|
+
},
|
|
158
|
+
};
|
|
159
|
+
Object.defineProperty(world,'id',{value:id,enumerable:true});
|
|
160
|
+
Object.defineProperty(world,'projectId',{get:()=>snapshot?.config.daemon.authProject,enumerable:true});
|
|
161
|
+
try{snapshot=await snapshotInputs(options,directory);services??=['auth','firestore','storage',...(snapshot.functionsConfigured?['functions']:[])];await start();return Object.freeze(world);}
|
|
162
|
+
catch(error){await retire();await rm(directory,{recursive:true,force:true});throw error;}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Always join disposal, including when the test callback rejects. */
|
|
166
|
+
export async function withTestWorld(options,callback){
|
|
167
|
+
const world=await createTestWorld(options);
|
|
168
|
+
try{return await callback(world);}finally{await world.dispose();}
|
|
169
|
+
}
|