fireemu 0.23.0 → 0.25.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 -15
- package/control.mjs +60 -0
- package/package.json +9 -7
- package/testing-snapshot.mjs +86 -33
- package/testing.mjs +15 -9
package/README.md
CHANGED
|
@@ -16,7 +16,7 @@ Fireemu is being developed to close these gaps. For example, it provides:
|
|
|
16
16
|
|
|
17
17
|
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.
|
|
18
18
|
|
|
19
|
-
See the [capability manifest](https://github.com/t-k/fireemu/blob/v0.
|
|
19
|
+
See the [capability manifest](https://github.com/t-k/fireemu/blob/v0.25.0/crates/fireemu/src/capabilities.json) and [compatibility documentation](https://github.com/t-k/fireemu/blob/v0.25.0/docs/compatibility-contract.md) for supported features, known differences, and the status of compatibility verification.
|
|
20
20
|
|
|
21
21
|
## Quick start
|
|
22
22
|
|
|
@@ -72,13 +72,13 @@ Fireemu supports Cloud Firestore, Firebase Authentication (Identity Platform), C
|
|
|
72
72
|
| Cloud Pub/Sub | Schemas, BigQuery and Cloud Storage subscription delivery, and some APIs such as `UpdateTopic` are not supported. |
|
|
73
73
|
| 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. |
|
|
74
74
|
|
|
75
|
-
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.
|
|
75
|
+
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.25.0/crates/fireemu/src/capabilities.json).
|
|
76
76
|
|
|
77
77
|
```sh
|
|
78
78
|
npx fireemu capabilities
|
|
79
79
|
```
|
|
80
80
|
|
|
81
|
-
See the [Functions support inventory](https://github.com/t-k/fireemu/blob/v0.
|
|
81
|
+
See the [Functions support inventory](https://github.com/t-k/fireemu/blob/v0.25.0/docs/functions-export-inventory.md) for support by trigger type.
|
|
82
82
|
|
|
83
83
|
### Unsupported products
|
|
84
84
|
|
|
@@ -139,7 +139,7 @@ Use `--ui-port 0` to disable the UI. When automatic UI startup cannot bind the d
|
|
|
139
139
|
|
|
140
140
|
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.
|
|
141
141
|
|
|
142
|
-
For clock-sensitive and parallel integration tests, [test clocks and independent worlds](https://github.com/t-k/fireemu/blob/v0.
|
|
142
|
+
For clock-sensitive and parallel integration tests, [test clocks and independent worlds](https://github.com/t-k/fireemu/blob/v0.25.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. The `fireemu/control` client also works in `fireemu exec` commands and shares URL resolution and authentication with `world.control()`, so callers do not need to concatenate control URLs.
|
|
143
143
|
|
|
144
144
|
### Export data
|
|
145
145
|
|
|
@@ -272,7 +272,7 @@ JSON also works: existing `fireemu.json` files and `--config <path>.json` remain
|
|
|
272
272
|
| Unsigned emulator custom tokens are accepted | `auth.allowUnsignedCustomTokens` | refused |
|
|
273
273
|
| Firestore and Storage Security Rules accept `@firebase/rules-unit-testing`'s unsigned mock ID token (`authenticatedContext()`, the client SDK's `mockUserToken`) | `auth.allowUnsignedIdTokens` | mock-token fallback disabled; normally verified fireemu-issued tokens still accepted |
|
|
274
274
|
| Identity-provider sign-in accepts the fixture assertions and the sign-in widget (`/emulator/auth/handler`), for built-in, `oidc.*` and `saml.*` providers; under `strict` an `oidc.*` or `saml.*` provider must still be configured and enabled (`auth.providers` or the Admin API), as production requires | `auth.idpAssertions` (`"fixture"`) | `"signed"`: OIDC ID tokens must verify with an `auth.idpSigners` key, SAML responses with the provider's certificates, and the fixture and built-in providers are refused |
|
|
275
|
-
| The strict Eventarc listener admits any credential or none, so the Admin SDK publishes with a fake credential (see [Eventarc authentication](https://github.com/t-k/fireemu/blob/v0.
|
|
275
|
+
| The strict Eventarc listener admits any credential or none, so the Admin SDK publishes with a fake credential (see [Eventarc authentication](https://github.com/t-k/fireemu/blob/v0.25.0/docs/eventarc-authentication.md)) | `eventarc.authentication` (`"permissive"`) | `"production"`: a missing, malformed or JWT-shaped bearer is refused with production's 401 |
|
|
276
276
|
| A tenant of `auth.tenants` can have any readable id, such as `tenant-a` | `auth.tenantIdFormat` (`"readable"`) | the id must have production's generated form, the display name, a hyphen and five characters of `a-z0-9` (`"generated"`) |
|
|
277
277
|
| Multi-tenancy is on: the Admin API creates tenants and `auth.tenants` loads without an `updateConfig` call | `auth.multiTenant.allowTenants` (`true`) | unset until it is enabled, as in a new production project: tenant routes are refused and `auth.tenants` needs `allowTenants: true` |
|
|
278
278
|
| Multi-factor authentication is on, SMS and TOTP, without a console toggle, an `updateConfig` call or an `auth.mfa` block | `auth.mfa` (`state: "ENABLED"`, `PHONE_SMS` and TOTP) | off until enabled, as in a new production project: enrollment is refused with `OPERATION_NOT_ALLOWED : SMS based MFA not enabled.` or `TOTP based MFA not enabled.` |
|
|
@@ -320,7 +320,7 @@ You can also name the configuration file with `--config`.
|
|
|
320
320
|
npx fireemu up --config ./fireemu.toml
|
|
321
321
|
```
|
|
322
322
|
|
|
323
|
-
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.
|
|
323
|
+
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.25.0/spec/config/fireemu.schema.json) for all available settings.
|
|
324
324
|
|
|
325
325
|
### Firebase Authentication (Identity Platform) configuration
|
|
326
326
|
|
|
@@ -351,9 +351,9 @@ Unsigned custom tokens minted by the Firebase Admin SDK in emulator mode are acc
|
|
|
351
351
|
|
|
352
352
|
The unsigned mock ID token of `@firebase/rules-unit-testing` is accepted by default when Firestore and Storage Security Rules are evaluated, including with the `strict` profile. This is the token `authenticatedContext()` and the client SDKs' `mockUserToken` option send (`createMockUserToken` in `@firebase/util`). Normal verification runs first. Only after it fails can the mock-token fallback accept a token. Under `strict` that fallback accepts only the exact SDK shape: a JWT whose header is exactly `{"alg":"none","type":"JWT"}` and whose signature is empty. `request.auth` is built from the token's claims, as the official emulator builds it, when the token names a subject (`sub`) and its audience (`aud`) is the project the request goes to. Its `exp` is not checked, because the SDK sets `iat: 0` and `exp: 3600` by default. Every other token is verified as before, expiry included. That covers the unsigned ID tokens fireemu itself issues (their header carries `typ`, not `type`), and a token that claims a signature that does not verify is still refused. The `emulator` profile accepts any unsigned token, as the official emulator does, and its Storage also accepts another project's token. Set `auth.allowUnsignedIdTokens` to `false` (in either profile) or set `productionExact` to disable this fallback. Neither setting refuses fireemu-issued unsigned ID tokens that pass normal verification. Changing only an issued token's header to the SDK shape can still pass normal verification of its payload; it is not a refusal test. For a refusal test, use `createMockUserToken` with its default `iat: 0` / `exp: 3600`, or construct that exact header and empty signature with those times, a subject and the routed project as audience, and run with a clock after expiry (including Firestore's 30-second allowance). A session with `auth.idTokenSigning = "session-rsa"` accepts no unsigned token. The setting does not affect callable Functions, Identity Toolkit or App Check.
|
|
353
353
|
|
|
354
|
-
See the [configuration schema](https://github.com/t-k/fireemu/blob/v0.
|
|
354
|
+
See the [configuration schema](https://github.com/t-k/fireemu/blob/v0.25.0/spec/config/fireemu.schema.json) for the format and accepted values of each setting.
|
|
355
355
|
|
|
356
|
-
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.
|
|
356
|
+
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.25.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.
|
|
357
357
|
|
|
358
358
|
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.
|
|
359
359
|
|
|
@@ -442,7 +442,7 @@ adjacentIntervals = 1
|
|
|
442
442
|
|
|
443
443
|
#### Enable multi-tenancy
|
|
444
444
|
|
|
445
|
-
Multi-tenancy works by default under both profiles: a new project allows tenants, so the Admin API creates them and `auth.tenants` loads without `multiTenant.allowTenants`. `productionExact` (`strict` only) starts a project as production does, with the switch unset until it is enabled through the Admin API, or with `auth.multiTenant` in the file (see "Start with tenants" below). A declared `allowTenants: false` always turns it off. See the [compatibility documentation](https://github.com/t-k/fireemu/blob/v0.
|
|
445
|
+
Multi-tenancy works by default under both profiles: a new project allows tenants, so the Admin API creates them and `auth.tenants` loads without `multiTenant.allowTenants`. `productionExact` (`strict` only) starts a project as production does, with the switch unset until it is enabled through the Admin API, or with `auth.multiTenant` in the file (see "Start with tenants" below). A declared `allowTenants: false` always turns it off. See the [compatibility documentation](https://github.com/t-k/fireemu/blob/v0.25.0/docs/compatibility-contract.md) for the supported Admin API scope.
|
|
446
446
|
|
|
447
447
|
#### Start with tenants
|
|
448
448
|
|
|
@@ -493,7 +493,7 @@ A manual run (`POST /v1/sessions/{session}/functions/{name}:run`) enqueues one r
|
|
|
493
493
|
|
|
494
494
|
On a task-queue function (`onTaskDispatched`), the same `POST /v1/sessions/{session}/functions/{name}:run` releases the Cloud Tasks that wait for their `scheduleTime`: all of them, or the one named by a `{"task": "<task id>"}` body (404 when no waiting task has that id). Under the strict profile a task enqueued with a `scheduleTime` waits for it, so this is how a test runs a task due in an hour without moving the clock; a released task still obeys the queue's rate and concurrency limits. The Functions page of the Emulator UI lists the waiting tasks with their due time and a **Run now** button.
|
|
495
495
|
|
|
496
|
-
See the [configuration schema](https://github.com/t-k/fireemu/blob/v0.
|
|
496
|
+
See the [configuration schema](https://github.com/t-k/fireemu/blob/v0.25.0/spec/config/fireemu.schema.json) for available settings and values.
|
|
497
497
|
|
|
498
498
|
### Firestore history limits
|
|
499
499
|
|
|
@@ -569,7 +569,7 @@ Fireemu is compatible with the listed Local Emulator Suite products as shipped b
|
|
|
569
569
|
|
|
570
570
|
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.
|
|
571
571
|
|
|
572
|
-
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.
|
|
572
|
+
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.25.0/spec/compatibility/contract.json) records scope and known differences. The [compatibility documentation](https://github.com/t-k/fireemu/blob/v0.25.0/docs/compatibility-contract.md) explains the verification process and how to interpret its records.
|
|
573
573
|
|
|
574
574
|
### Network exposure of the Storage port
|
|
575
575
|
|
|
@@ -600,7 +600,7 @@ allowNonLoopbackHosts = true
|
|
|
600
600
|
|
|
601
601
|
### Changes between versions
|
|
602
602
|
|
|
603
|
-
Configuration and behavior may change between releases. See the [CHANGELOG](https://github.com/t-k/fireemu/blob/v0.
|
|
603
|
+
Configuration and behavior may change between releases. See the [CHANGELOG](https://github.com/t-k/fireemu/blob/v0.25.0/CHANGELOG.md) for release changes.
|
|
604
604
|
|
|
605
605
|
## Performance
|
|
606
606
|
|
|
@@ -617,7 +617,7 @@ The following results come from one `standard` run at commit `f3b942c`. Ratios a
|
|
|
617
617
|
|
|
618
618
|
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]`.
|
|
619
619
|
|
|
620
|
-
See [benchmark.yml](https://github.com/t-k/fireemu/blob/v0.
|
|
620
|
+
See [benchmark.yml](https://github.com/t-k/fireemu/blob/v0.25.0/.github/workflows/benchmark.yml) for the workflow and [`tools/bench/`](https://github.com/t-k/fireemu/tree/v0.25.0/tools/bench/) for the benchmark implementation.
|
|
621
621
|
|
|
622
622
|
## Reporting issues and contributing
|
|
623
623
|
|
|
@@ -625,8 +625,8 @@ When reporting an issue, include the Fireemu and Firebase SDK versions, the prof
|
|
|
625
625
|
|
|
626
626
|
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.
|
|
627
627
|
|
|
628
|
-
See the [development guide](https://github.com/t-k/fireemu/blob/v0.
|
|
628
|
+
See the [development guide](https://github.com/t-k/fireemu/blob/v0.25.0/docs/development.md) for build and test instructions.
|
|
629
629
|
|
|
630
630
|
## License
|
|
631
631
|
|
|
632
|
-
Fireemu is licensed under the [Apache License 2.0](https://github.com/t-k/fireemu/blob/v0.
|
|
632
|
+
Fireemu is licensed under the [Apache License 2.0](https://github.com/t-k/fireemu/blob/v0.25.0/LICENSE). Third-party notices are listed in [THIRD_PARTY_LICENSES.txt](https://github.com/t-k/fireemu/blob/v0.25.0/THIRD_PARTY_LICENSES.txt).
|
package/control.mjs
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/** Create an authenticated control client from explicit endpoints or fireemu exec's environment. */
|
|
2
|
+
export function createControlClient(options = {}) {
|
|
3
|
+
const {origin: suppliedOrigin, apiBaseUrl: suppliedBase, env = process.env, requestTimeoutMs = 10_000} = options;
|
|
4
|
+
if (suppliedOrigin !== undefined && suppliedBase !== undefined) {
|
|
5
|
+
throw new TypeError('Specify origin or apiBaseUrl, not both');
|
|
6
|
+
}
|
|
7
|
+
const explicit = suppliedOrigin !== undefined || suppliedBase !== undefined;
|
|
8
|
+
const endpoint = suppliedOrigin ?? suppliedBase ?? env.FIREEMU_CONTROL_URL;
|
|
9
|
+
const token = options.token ?? (explicit ? undefined : env.FIREEMU_CONTROL_TOKEN);
|
|
10
|
+
if (typeof endpoint !== 'string' || endpoint.length === 0) {
|
|
11
|
+
throw new TypeError('Specify origin or apiBaseUrl, or set FIREEMU_CONTROL_URL');
|
|
12
|
+
}
|
|
13
|
+
let parsed;
|
|
14
|
+
try { parsed = new URL(endpoint); }
|
|
15
|
+
catch { throw new TypeError('Control endpoint must be an absolute HTTP(S) URL'); }
|
|
16
|
+
if (!['http:', 'https:'].includes(parsed.protocol) || parsed.username || parsed.password || parsed.href.includes('?') || parsed.href.includes('#')) {
|
|
17
|
+
throw new TypeError('Control endpoint must be HTTP(S), without credentials, query or fragment');
|
|
18
|
+
}
|
|
19
|
+
const paths = suppliedOrigin !== undefined ? ['/'] : suppliedBase !== undefined ? ['/v1', '/v1/'] : ['/', '/v1', '/v1/'];
|
|
20
|
+
if (!paths.includes(parsed.pathname)) throw new TypeError('Control origin must have no path; apiBaseUrl must end in /v1/');
|
|
21
|
+
if (typeof token !== 'string' || !/^[\x21-\x7e]+$/.test(token)) {
|
|
22
|
+
throw new TypeError('Specify a nonempty ASCII control token without whitespace, or set FIREEMU_CONTROL_TOKEN');
|
|
23
|
+
}
|
|
24
|
+
if (!Number.isSafeInteger(requestTimeoutMs) || requestTimeoutMs <= 0 || requestTimeoutMs > 2_147_483_647) {
|
|
25
|
+
throw new TypeError('requestTimeoutMs must be a positive integer at most 2147483647');
|
|
26
|
+
}
|
|
27
|
+
const origin = parsed.origin;
|
|
28
|
+
const apiBaseUrl = `${origin}/v1/`;
|
|
29
|
+
return Object.freeze({
|
|
30
|
+
origin,
|
|
31
|
+
apiBaseUrl,
|
|
32
|
+
async request(method, path, body, {signal} = {}) {
|
|
33
|
+
// Paths are relative to this capability's endpoint; neither URLs nor redirects may change it.
|
|
34
|
+
if (typeof path !== 'string' || !path || /[\x00-\x20\x7f\\#]/.test(path) || path.startsWith('//') || /^[a-z][a-z0-9+.-]*:/i.test(path)) {
|
|
35
|
+
throw new TypeError('Control path must be API-relative or root-relative, not a URL');
|
|
36
|
+
}
|
|
37
|
+
const url = new URL(path, apiBaseUrl);
|
|
38
|
+
if (url.origin !== origin) throw new TypeError('Control path must stay on the configured origin');
|
|
39
|
+
const response = await fetch(url, {
|
|
40
|
+
method,
|
|
41
|
+
headers: {authorization:`Bearer ${token}`, ...(body === undefined ? {} : {'content-type':'application/json'})},
|
|
42
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
43
|
+
redirect: 'error',
|
|
44
|
+
signal: signal ?? AbortSignal.timeout(requestTimeoutMs),
|
|
45
|
+
});
|
|
46
|
+
const text = await response.text();
|
|
47
|
+
let value;
|
|
48
|
+
try { value = text === '' ? undefined : JSON.parse(text); }
|
|
49
|
+
catch {
|
|
50
|
+
if (response.ok) throw controlError(response.status, 'invalid JSON response');
|
|
51
|
+
}
|
|
52
|
+
if (!response.ok) throw controlError(response.status, value?.error?.message ?? (value === undefined ? response.statusText : JSON.stringify(value)));
|
|
53
|
+
return value;
|
|
54
|
+
},
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function controlError(status, message) {
|
|
59
|
+
return Object.assign(new Error(`control ${status}: ${message}`), {code:'FIREEMU_CONTROL_HTTP', status});
|
|
60
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fireemu",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.25.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",
|
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
"files": [
|
|
23
23
|
"bin/fireemu.mjs",
|
|
24
24
|
"binary.mjs",
|
|
25
|
+
"control.mjs",
|
|
25
26
|
"testing.mjs",
|
|
26
27
|
"testing-snapshot.mjs",
|
|
27
28
|
"testing-process.mjs",
|
|
@@ -31,17 +32,18 @@
|
|
|
31
32
|
],
|
|
32
33
|
"type": "module",
|
|
33
34
|
"optionalDependencies": {
|
|
34
|
-
"@fireemu/darwin-arm64": "0.
|
|
35
|
-
"@fireemu/darwin-x64": "0.
|
|
36
|
-
"@fireemu/linux-x64": "0.
|
|
37
|
-
"@fireemu/linux-arm64": "0.
|
|
38
|
-
"@fireemu/win32-x64": "0.
|
|
35
|
+
"@fireemu/darwin-arm64": "0.25.0",
|
|
36
|
+
"@fireemu/darwin-x64": "0.25.0",
|
|
37
|
+
"@fireemu/linux-x64": "0.25.0",
|
|
38
|
+
"@fireemu/linux-arm64": "0.25.0",
|
|
39
|
+
"@fireemu/win32-x64": "0.25.0"
|
|
39
40
|
},
|
|
40
41
|
"engines": {
|
|
41
42
|
"node": ">=20"
|
|
42
43
|
},
|
|
43
44
|
"exports": {
|
|
44
45
|
"./testing": "./testing.mjs",
|
|
45
|
-
"./package.json": "./package.json"
|
|
46
|
+
"./package.json": "./package.json",
|
|
47
|
+
"./control": "./control.mjs"
|
|
46
48
|
}
|
|
47
49
|
}
|
package/testing-snapshot.mjs
CHANGED
|
@@ -2,9 +2,40 @@
|
|
|
2
2
|
import { constants } from 'node:fs';
|
|
3
3
|
import { cp, mkdir, readFile, writeFile, realpath, symlink, lstat, stat, unlink, readdir, readlink } from 'node:fs/promises';
|
|
4
4
|
import { basename, dirname, join, resolve, relative, sep, delimiter, isAbsolute } from 'node:path';
|
|
5
|
+
import { execFile } from 'node:child_process';
|
|
6
|
+
import { promisify } from 'node:util';
|
|
7
|
+
import { resolveBinary, ensureExecutable } from './binary.mjs';
|
|
8
|
+
|
|
9
|
+
const runFile = promisify(execFile);
|
|
10
|
+
|
|
11
|
+
async function readConfiguration(path, options) {
|
|
12
|
+
const format = path.toLowerCase().endsWith('.toml') ? 'TOML' : 'JSON';
|
|
13
|
+
try {
|
|
14
|
+
if (format === 'JSON') return JSON.parse(await readFile(path, 'utf8'));
|
|
15
|
+
const binary = resolveBinary(options.binaryPath ? resolve(options.binaryPath) : undefined);
|
|
16
|
+
if (!binary) throw Error('install the fireemu platform package or supply binaryPath');
|
|
17
|
+
ensureExecutable(binary.path);
|
|
18
|
+
// Use the CLI parser so TOML types and rejection rules cannot drift between callers.
|
|
19
|
+
const { stdout } = await runFile(binary.path, ['__test-world-config', path], {
|
|
20
|
+
encoding: 'utf8', windowsHide: true, timeout: options.startupTimeoutMs ?? 60_000,
|
|
21
|
+
killSignal: 'SIGKILL', maxBuffer: 16 * 1024 * 1024, signal: options.signal,
|
|
22
|
+
});
|
|
23
|
+
return JSON.parse(stdout);
|
|
24
|
+
} catch (cause) {
|
|
25
|
+
throw Error(`Cannot read ${format} configuration ${JSON.stringify(path)}: ${cause.stderr?.trim() || cause.message}`, { cause });
|
|
26
|
+
}
|
|
27
|
+
}
|
|
5
28
|
|
|
6
29
|
export async function snapshotInputs(options, root) {
|
|
7
30
|
const cwd=resolve(options.cwd??process.cwd());
|
|
31
|
+
const nodePathExcludes=options.nodePathExcludes===undefined?[]:options.nodePathExcludes;
|
|
32
|
+
const packageName=value=>{
|
|
33
|
+
if(typeof value!=='string'||value.includes('\\')||value.includes('\0'))return false;
|
|
34
|
+
const parts=value.split('/');
|
|
35
|
+
return (value.startsWith('@')?parts.length===2&&parts[0].length>1:parts.length===1)
|
|
36
|
+
&&parts.every((part,index)=>part!=='.'&&part!=='..'&&/^[a-zA-Z0-9._~-]+$/.test(index===0&&value.startsWith('@')?part.slice(1):part));
|
|
37
|
+
};
|
|
38
|
+
if(!Array.isArray(nodePathExcludes)||!nodePathExcludes.every(packageName))throw TypeError('nodePathExcludes must be an array of unscoped or scoped package names');
|
|
8
39
|
const privateRoot=await realpath(root);
|
|
9
40
|
const baseline=join(root,'baseline');
|
|
10
41
|
await mkdir(baseline,{mode:0o700});
|
|
@@ -17,34 +48,49 @@ export async function snapshotInputs(options, root) {
|
|
|
17
48
|
};
|
|
18
49
|
const graph=new Map();
|
|
19
50
|
const inside=(parent,path)=>{const suffix=relative(parent,path);return suffix===''||(!suffix.startsWith(`..${sep}`)&&suffix!=='..'&&!isAbsolute(suffix));};
|
|
20
|
-
const
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
const
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
51
|
+
const omitted=new Set();
|
|
52
|
+
const captureTree=async(source,destination,excludes=[])=>{
|
|
53
|
+
try{
|
|
54
|
+
const canonical=await realpath(source);
|
|
55
|
+
graph.set(canonical,destination);
|
|
56
|
+
const links=[];
|
|
57
|
+
await cp(canonical,destination,{recursive:true,mode:constants.COPYFILE_FICLONE,dereference:false,verbatimSymlinks:true,filter:async(path,target)=>{
|
|
58
|
+
if(excludes.includes(path)){omitted.add(path);return false;}
|
|
59
|
+
if(['.git','.worktree'].includes(basename(path)))return false;
|
|
60
|
+
if((await lstat(path)).isSymbolicLink())links.push([path,target]);
|
|
61
|
+
return true;
|
|
62
|
+
}});
|
|
63
|
+
for(const [sourceLink,targetLink] of links){
|
|
64
|
+
let linkValue;
|
|
65
|
+
try{
|
|
66
|
+
linkValue=await readlink(sourceLink);
|
|
67
|
+
const target=await realpath(sourceLink);
|
|
68
|
+
const linkedExcludes=excludes.filter(path=>inside(sourceLink,path)).map(path=>join(target,relative(sourceLink,path)));
|
|
69
|
+
if([...omitted].some(path=>inside(path,target)))throw Error(`Retained link ${JSON.stringify(sourceLink)} requires excluded NODE_PATH package content ${JSON.stringify(target)}; remove that package from nodePathExcludes`);
|
|
70
|
+
let copied;
|
|
71
|
+
const owners=[...graph].filter(([original])=>inside(original,target)).sort((a,b)=>b[0].length-a[0].length);
|
|
72
|
+
if(owners.length&&linkedExcludes.length)throw Error(`Cannot apply nodePathExcludes through linked scope ${JSON.stringify(sourceLink)}: target ${JSON.stringify(target)} was already captured; narrow env.NODE_PATH instead`);
|
|
73
|
+
if(owners.length){const [original,snapshot]=owners[0];copied=join(snapshot,relative(original,target));}
|
|
74
|
+
else{
|
|
75
|
+
if(graph.size>=4096)throw Error('Functions dependency graph exceeds the snapshot budget');
|
|
76
|
+
// pnpm packages resolve dependencies from their enclosing node_modules,
|
|
77
|
+
// including siblings that are not explicit links inside the package.
|
|
78
|
+
let context=dirname(target);
|
|
79
|
+
while(basename(context)!=='node_modules'&&dirname(context)!==context)context=dirname(context);
|
|
80
|
+
if(basename(context)!=='node_modules')context=target;
|
|
81
|
+
const destination=join(baseline,`linked-${sequence++}`,...(basename(context)==='node_modules'?['node_modules']:[]));
|
|
82
|
+
await captureTree(context,destination,linkedExcludes);
|
|
83
|
+
copied=join(destination,relative(context,target));
|
|
84
|
+
}
|
|
85
|
+
await unlink(targetLink);
|
|
86
|
+
const kind=(await lstat(target)).isDirectory()?'dir':'file';
|
|
87
|
+
await symlink(process.platform==='win32'?copied:relative(dirname(targetLink),copied),targetLink,process.platform==='win32'&&kind==='dir'?'junction':kind);
|
|
88
|
+
}catch(cause){
|
|
89
|
+
throw Error(`Cannot snapshot dependency link ${JSON.stringify(sourceLink)} -> ${JSON.stringify(linkValue)}: ${cause.message}`,{cause});
|
|
90
|
+
}
|
|
44
91
|
}
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
await symlink(process.platform==='win32'?copied:relative(dirname(targetLink),copied),targetLink,process.platform==='win32'&&kind==='dir'?'junction':kind);
|
|
92
|
+
}catch(cause){
|
|
93
|
+
throw Error(`Cannot snapshot dependency tree ${JSON.stringify(source)}: ${cause.message}`,{cause});
|
|
48
94
|
}
|
|
49
95
|
};
|
|
50
96
|
const copySource=async(value,base)=>{
|
|
@@ -86,7 +132,7 @@ export async function snapshotInputs(options, root) {
|
|
|
86
132
|
};
|
|
87
133
|
let input=options.config??{schemaVersion:1};
|
|
88
134
|
let configBase=cwd;
|
|
89
|
-
if(typeof input==='string'){const path=resolve(cwd,input);input=
|
|
135
|
+
if(typeof input==='string'){const path=resolve(cwd,input);input=await readConfiguration(path,options);configBase=dirname(path);}
|
|
90
136
|
let config;
|
|
91
137
|
if(input.schemaVersion===undefined){config={schemaVersion:1,firebaseJson:await snapshotFirebase(input,configBase)};}
|
|
92
138
|
else{
|
|
@@ -102,11 +148,18 @@ export async function snapshotInputs(options, root) {
|
|
|
102
148
|
const functionsConfigured=Boolean(graph.size||config.functions?.manifest||config.functions?.runner);
|
|
103
149
|
const nodePaths=[];
|
|
104
150
|
for(const entry of (functionsConfigured?options.env?.NODE_PATH??'':'').split(delimiter).filter(Boolean)){
|
|
105
|
-
let original;
|
|
106
|
-
try{
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
151
|
+
let original=resolve(cwd,entry);
|
|
152
|
+
try{
|
|
153
|
+
try{original=await realpath(original);}catch(error){if(error.code==='ENOENT')continue;throw error;}
|
|
154
|
+
let destination=graph.get(original);
|
|
155
|
+
if(!destination){
|
|
156
|
+
destination=join(baseline,`node-path-${sequence++}`,'node_modules');
|
|
157
|
+
await captureTree(original,destination,nodePathExcludes.map(name=>join(original,name)));
|
|
158
|
+
}
|
|
159
|
+
nodePaths.push(destination);
|
|
160
|
+
}catch(cause){
|
|
161
|
+
throw Error(`Cannot snapshot NODE_PATH entry ${JSON.stringify(entry)} (copy root ${JSON.stringify(original)}): ${cause.message}. If this global package is unused, exclude its name with nodePathExcludes; if all global roots are unused, set env.NODE_PATH to an empty string`,{cause});
|
|
162
|
+
}
|
|
110
163
|
}
|
|
111
164
|
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
165
|
for(const service of ['firestore','http','storage','functions','eventarc','tasks','pubsub','hub','ui','logging'])config.daemon[`${service}Port`]=0;
|
package/testing.mjs
CHANGED
|
@@ -8,6 +8,7 @@ import { fileURLToPath } from 'node:url';
|
|
|
8
8
|
import { setTimeout as delay } from 'node:timers/promises';
|
|
9
9
|
import { resolveBinary, ensureExecutable } from './binary.mjs';
|
|
10
10
|
import { snapshotInputs, prepareGeneration } from './testing-snapshot.mjs';
|
|
11
|
+
import { createControlClient } from './control.mjs';
|
|
11
12
|
|
|
12
13
|
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
14
|
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']);
|
|
@@ -43,17 +44,18 @@ async function stop(process,timeout=5000){
|
|
|
43
44
|
if(!closed)throw Error('test world process tree did not close after forced retirement');
|
|
44
45
|
}
|
|
45
46
|
}
|
|
46
|
-
function endpoints(descriptor){
|
|
47
|
+
function endpoints(descriptor,control){
|
|
47
48
|
const result={};
|
|
48
49
|
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
|
+
result.control=Object.freeze({...decode(descriptor.controlUrl),origin:control.origin,apiBaseUrl:control.apiBaseUrl});
|
|
50
51
|
for(const [service,name] of Object.entries(endpointVariables))if(descriptor.environment[name])result[service]=decode(descriptor.environment[name]);
|
|
51
52
|
return Object.freeze(result);
|
|
52
53
|
}
|
|
53
54
|
|
|
54
55
|
/** Create an independent emulator world; no method changes the parent's environment. */
|
|
55
56
|
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
|
+
options={...options,nodePathExcludes:Array.isArray(options.nodePathExcludes)?[...options.nodePathExcludes]:options.nodePathExcludes,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};
|
|
58
|
+
if(options.ui!==undefined&&typeof options.ui!=='boolean')throw TypeError('ui must be a boolean');
|
|
57
59
|
const binary=resolveBinary(options.binaryPath?resolve(options.binaryPath):undefined);
|
|
58
60
|
if(!binary)throw Error('fireemu could not start a test world: install the platform package or supply binaryPath');
|
|
59
61
|
const timeout=options.startupTimeoutMs??60_000;
|
|
@@ -79,10 +81,11 @@ export async function createTestWorld(options={}){
|
|
|
79
81
|
if(failures.length)throw new AggregateError(failures,'test world retirement failed; cleanup can be retried');
|
|
80
82
|
};
|
|
81
83
|
const requestFor=async(active,method,path,body,signal)=>{
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
84
|
+
try{return await active.control.request(method,path,body,{signal});}
|
|
85
|
+
catch(error){
|
|
86
|
+
if(error.code==='FIREEMU_CONTROL_HTTP')error.message=`test world ${error.message}`;
|
|
87
|
+
throw error;
|
|
88
|
+
}
|
|
86
89
|
};
|
|
87
90
|
const start=async()=>{
|
|
88
91
|
options.signal?.throwIfAborted();
|
|
@@ -91,7 +94,8 @@ export async function createTestWorld(options={}){
|
|
|
91
94
|
const prepared=await prepareGeneration(snapshot,generationDirectory);
|
|
92
95
|
const readyFile=join(generationDirectory,'ready.json');
|
|
93
96
|
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','
|
|
97
|
+
for(const name of ['firestore','http','storage','functions','eventarc','tasks','pubsub','hub','logging'])args.push(`--${name}-port`,'0');
|
|
98
|
+
args.push('--ui-port',options.ui?'auto':'0');
|
|
95
99
|
if(prepared.seed)args.push('--import',prepared.seed);
|
|
96
100
|
const temporary=join(generationDirectory,'tmp');
|
|
97
101
|
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)};
|
|
@@ -107,9 +111,10 @@ export async function createTestWorld(options={}){
|
|
|
107
111
|
const descriptor=JSON.parse(await readFile(readyFile,'utf8'));
|
|
108
112
|
if(descriptor.schemaVersion!==1||descriptor.pid!==daemon.child.pid||descriptor.projectId!==snapshot.config.daemon.authProject)throw Error('invalid test-world readiness descriptor');
|
|
109
113
|
active.descriptor=descriptor;
|
|
114
|
+
active.control=createControlClient({origin:descriptor.controlUrl,token:descriptor.controlToken,requestTimeoutMs:options.requestTimeoutMs});
|
|
110
115
|
await requestFor(active,'GET','/health/ready',undefined,AbortSignal.timeout(Math.min(1000,timeout)));
|
|
111
116
|
if(daemon.result())throw Error('daemon exited during test-world readiness');
|
|
112
|
-
active.endpoints=endpoints(descriptor);
|
|
117
|
+
active.endpoints=endpoints(descriptor,active.control);
|
|
113
118
|
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
119
|
generation=next;
|
|
115
120
|
return;
|
|
@@ -122,6 +127,7 @@ export async function createTestWorld(options={}){
|
|
|
122
127
|
const control=(method,path,body)=>enqueue(async()=>{check();return requestFor(current,method,path,body);});
|
|
123
128
|
const world={
|
|
124
129
|
get generation(){return generation;},
|
|
130
|
+
get uiUrl(){check();return current.descriptor.uiUrl??undefined;},
|
|
125
131
|
get endpoints(){check();return current.endpoints;},
|
|
126
132
|
get environment(){check();return current.environment;},
|
|
127
133
|
get directory(){return directory;},
|