@crvouga/mockingbird-service-google-ads 1.2.0 → 1.4.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/CHANGELOG.md +13 -1
- package/DISCOVERY.md +23 -0
- package/README.md +109 -40
- package/dist/{chunk-KCDQPGFS.js → chunk-ITE3L2DF.js} +136 -25
- package/dist/chunk-ITE3L2DF.js.map +7 -0
- package/dist/chunk-SNVGFGLM.js +6817 -0
- package/dist/chunk-SNVGFGLM.js.map +7 -0
- package/dist/cli.js +2 -2
- package/dist/index.d.ts +354 -41
- package/dist/index.js +1 -1
- package/dist/server.d.ts +693 -81
- package/dist/server.js +2 -2
- package/openapi.yaml +28 -0
- package/package.json +3 -2
- package/dist/chunk-DDJPMCRI.js +0 -4958
- package/dist/chunk-DDJPMCRI.js.map +0 -7
- package/dist/chunk-KCDQPGFS.js.map +0 -7
package/CHANGELOG.md
CHANGED
|
@@ -1,6 +1,18 @@
|
|
|
1
1
|
# Changelog — @crvouga/mockingbird-service-google-ads
|
|
2
2
|
|
|
3
|
-
## 1.
|
|
3
|
+
## 1.4.0 (2026-10-15)
|
|
4
|
+
|
|
5
|
+
### Features
|
|
6
|
+
|
|
7
|
+
- unify the emulator control plane ([2068547](https://github.com/crvouga/mockingbird/commit/2068547bedde4e2e2331c1657c1a78120e84428b))
|
|
8
|
+
|
|
9
|
+
## 1.3.0 (2026-10-10)
|
|
10
|
+
|
|
11
|
+
### Features
|
|
12
|
+
|
|
13
|
+
- resolve reported emulator coverage gaps ([6edc643](https://github.com/crvouga/mockingbird/commit/6edc6438444853a51aca27849c4c8c0449109ea0))
|
|
14
|
+
|
|
15
|
+
## 1.2.0 (2026-10-08)
|
|
4
16
|
|
|
5
17
|
### Features
|
|
6
18
|
|
package/DISCOVERY.md
CHANGED
|
@@ -35,6 +35,7 @@ repository; live parity is an additional oracle check, not a substitute for the
|
|
|
35
35
|
|
|
36
36
|
- `GET /__admin/health`
|
|
37
37
|
- `GET /__admin`
|
|
38
|
+
- `GET /__admin/controls`
|
|
38
39
|
- `GET /__admin/state`
|
|
39
40
|
- `GET /__admin/requests`
|
|
40
41
|
- `GET /__admin/metrics`
|
|
@@ -45,6 +46,28 @@ For HTTP services, use `x-mockingbird-namespace` (or the documented credential/p
|
|
|
45
46
|
parallel tests do not share state. Admin state, journal, metrics and fault-preset endpoints are
|
|
46
47
|
designed for assertions and diagnosis by consuming test suites.
|
|
47
48
|
|
|
49
|
+
## Admin controls
|
|
50
|
+
|
|
51
|
+
Every HTTP emulator takes the same controls, and each means the same thing at boot
|
|
52
|
+
(`createRuntime({ adminControls })`, `serve --admin-controls`), over `/__admin`, and in a request header:
|
|
53
|
+
|
|
54
|
+
| Control | Admin API | Request header | Interface |
|
|
55
|
+
| --- | --- | --- | --- |
|
|
56
|
+
| Isolate state by key | `GET /__admin/namespaces` | `x-mockingbird-namespace: <key>` | [Namespaces](https://mockingbird.chrisvouga.dev/docs/admin-controls/namespaces) |
|
|
57
|
+
| Reset to boot | `POST /__admin/reset` | `x-mockingbird-reset: true` | [Reset](https://mockingbird.chrisvouga.dev/docs/admin-controls/reset) |
|
|
58
|
+
| Snapshot and restore | `POST /__admin/snapshots`, `POST /__admin/snapshots/<id>/restore` | `x-mockingbird-snapshot: <name>`, `x-mockingbird-restore: <name>` | [Snapshot and restore](https://mockingbird.chrisvouga.dev/docs/admin-controls/snapshot-and-restore) |
|
|
59
|
+
| Move the clock | `POST /__admin/clock` | `x-mockingbird-time: <instant>`, `x-mockingbird-advance: <duration>` | [Clock](https://mockingbird.chrisvouga.dev/docs/admin-controls/clock) |
|
|
60
|
+
| Read or branch from old state | `GET /__admin/timeline` | `x-mockingbird-at: <checkpoint>`, `x-mockingbird-branch: <name>` | [Checkpoints and branches](https://mockingbird.chrisvouga.dev/docs/admin-controls/checkpoints-and-branches) |
|
|
61
|
+
| Inject a fault | `POST /__admin/faults` | `x-mockingbird-fault: <fault>` | [Faults](https://mockingbird.chrisvouga.dev/docs/admin-controls/faults) |
|
|
62
|
+
| Latency and timeouts | `PUT /__admin/latency` | `x-mockingbird-delay: <duration>`, `x-mockingbird-timeout: <duration>` | [Latency and timeouts](https://mockingbird.chrisvouga.dev/docs/admin-controls/latency-and-timeouts) |
|
|
63
|
+
| Replay from a seed | `PUT /__admin/controls` | `x-mockingbird-seed: <seed>` | [Seed and determinism](https://mockingbird.chrisvouga.dev/docs/admin-controls/seed-and-determinism) |
|
|
64
|
+
| See what happened | `GET /__admin/requests`, `GET /__admin/state` | | [Journal, metrics and state](https://mockingbird.chrisvouga.dev/docs/admin-controls/journal-and-state) |
|
|
65
|
+
|
|
66
|
+
`GET /__admin` lists every route and header with a one-line summary, and `GET /__admin/controls`
|
|
67
|
+
is the namespace's current control document. The same seed and the same requests give the same
|
|
68
|
+
bytes. Setting controls as an emulator starts is [Boot](https://mockingbird.chrisvouga.dev/docs/admin-controls/boot); every route and header is in
|
|
69
|
+
the [Reference](https://mockingbird.chrisvouga.dev/docs/admin-controls/reference). The full guide: <https://mockingbird.chrisvouga.dev/docs/admin-controls>.
|
|
70
|
+
|
|
48
71
|
## Report a mismatch or missing capability
|
|
49
72
|
|
|
50
73
|
Follow the [agent reporting contract](https://github.com/crvouga/mockingbird/blob/main/docs/REPORTING_ISSUES.md). Include package version,
|
package/README.md
CHANGED
|
@@ -15,27 +15,24 @@ bun add @crvouga/mockingbird-service-google-ads
|
|
|
15
15
|
## Usage
|
|
16
16
|
|
|
17
17
|
```ts
|
|
18
|
-
import {
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
)
|
|
37
|
-
|
|
38
|
-
console.log(await response.json())
|
|
18
|
+
import { OAuth2Client } from "google-auth-library"
|
|
19
|
+
import { DEFAULT_CUSTOMER, DEFAULT_TOKEN } from "@crvouga/mockingbird-service-google-ads"
|
|
20
|
+
import { createServer } from "@crvouga/mockingbird-service-google-ads/server"
|
|
21
|
+
|
|
22
|
+
const emulator = await createServer()
|
|
23
|
+
|
|
24
|
+
// Google publishes no Node client for the Ads API, so the application calls REST through
|
|
25
|
+
// google-auth-library. Only the base URL and the fixture token change.
|
|
26
|
+
const client = new OAuth2Client()
|
|
27
|
+
client.setCredentials({ access_token: DEFAULT_TOKEN })
|
|
28
|
+
const response = await client.request({
|
|
29
|
+
url: `${emulator.url}/v25/customers/${DEFAULT_CUSTOMER}/googleAds:search`,
|
|
30
|
+
method: "POST",
|
|
31
|
+
data: { query: "SELECT campaign.id, campaign.name, metrics.clicks FROM campaign" },
|
|
32
|
+
})
|
|
33
|
+
console.log(response.data)
|
|
34
|
+
|
|
35
|
+
await emulator.close()
|
|
39
36
|
```
|
|
40
37
|
|
|
41
38
|
The Ads and Analytics Data routes use OAuth Bearer credentials. Measurement Protocol routes use
|
|
@@ -51,7 +48,7 @@ faults, and resets are isolated by namespace.
|
|
|
51
48
|
|
|
52
49
|
| Operation | Route | Behavior |
|
|
53
50
|
| --- | --- | --- |
|
|
54
|
-
| SearchGoogleAds | `POST /v25/customers/{customerId}/googleAds:search` | GAQL selection, filtering, ordering, aggregation, summary rows,
|
|
51
|
+
| SearchGoogleAds | `POST /v25/customers/{customerId}/googleAds:search` | GAQL selection, filtering, ordering, aggregation, summary rows, fixed 10,000-row pages, two-hour page tokens bound to the query, and validate-only requests |
|
|
55
52
|
| SearchStreamGoogleAds | `POST /v25/customers/{customerId}/googleAds:searchStream` | Deterministic JSON batches and optional summary rows |
|
|
56
53
|
| MutateCampaignBudgets | `POST /v25/customers/{customerId}/campaignBudgets:mutate` | Create, update, remove, update masks, validate-only and atomic or partial failure |
|
|
57
54
|
| UploadClickConversions | `POST /v25/customers/{customerId}:uploadClickConversions` | Click identifiers, conversion actions, duplicate detection, job IDs and partial failure |
|
|
@@ -63,6 +60,50 @@ faults, and resets are isolated by namespace.
|
|
|
63
60
|
The exact request and response schemas are in [`openapi.yaml`](openapi.yaml), and the generated
|
|
64
61
|
support matrix is in [`SUPPORT.md`](SUPPORT.md).
|
|
65
62
|
|
|
63
|
+
Campaign budgets are read back with GAQL (`FROM campaign_budget`); uploaded conversions and
|
|
64
|
+
collected events are read through the inspection routes below, and events also through the
|
|
65
|
+
Analytics Data reports. REST rows use protobuf JSON names (`costMicros`, `conversionsValue`,
|
|
66
|
+
`conversionActionCategory`) and int64 values are strings; GAQL field names stay snake_case.
|
|
67
|
+
|
|
68
|
+
Google Ads failures are a `google.rpc.Status` whose `details` hold a `GoogleAdsFailure` with the
|
|
69
|
+
per-operation `errorCode`, `message`, `location` and a `requestId` equal to the `request-id`
|
|
70
|
+
response header. Unparsable JSON and every Analytics Data failure are a bare status with no Ads
|
|
71
|
+
detail and no `request-id` header.
|
|
72
|
+
|
|
73
|
+
## Where Google differs from a common assumption
|
|
74
|
+
|
|
75
|
+
These follow Google's documentation and the pinned `googleapis` v25 protos rather than what a
|
|
76
|
+
client written against an older contract may expect.
|
|
77
|
+
|
|
78
|
+
- **Search page size is fixed.** `Search` returns pages of up to 10,000 rows and a request that
|
|
79
|
+
sets `pageSize` is rejected with `requestError: PAGE_SIZE_NOT_SUPPORTED`. Continue by re-sending
|
|
80
|
+
the identical query with `pageToken`; a changed query is `INVALID_PAGE_TOKEN` and a token unused
|
|
81
|
+
for two hours is `EXPIRED_PAGE_TOKEN`
|
|
82
|
+
([paging guide](https://developers.google.com/google-ads/api/docs/reporting/paging),
|
|
83
|
+
[`SearchGoogleAdsRequest`](https://github.com/googleapis/googleapis/blob/03a91044136a014466d4293eb1fe91f2b02075d2/google/ads/googleads/v25/services/google_ads_service.proto)).
|
|
84
|
+
To page a small fixture, lower the emulator's page with the `searchPageSize` setting; the
|
|
85
|
+
request still cannot choose it.
|
|
86
|
+
- **`/mp/collect` never reports validation errors over HTTP.** It answers an empty `204` for a
|
|
87
|
+
bad secret, an unparsable body or a rejected event alike. Inspect a rejection with
|
|
88
|
+
`POST /debug/mp/collect`, which returns `validationMessages` and stores nothing, or with
|
|
89
|
+
`GET /__admin/request-metadata`, which records `accepted`, `rejected`, `duplicate` and `reasons`
|
|
90
|
+
per request
|
|
91
|
+
([validation guide](https://developers.google.com/analytics/devguides/collection/protocol/ga4/validating-events)).
|
|
92
|
+
- **Timestamps older than 72 hours are overridden, not rejected,** unless the request sets
|
|
93
|
+
`validation_behavior: "ENFORCE_RECOMMENDATIONS"`
|
|
94
|
+
([sending guide](https://developers.google.com/analytics/devguides/collection/protocol/ga4/sending-events?client_type=gtag)).
|
|
95
|
+
Google documents no bound for future timestamps, so the one-minute `futureToleranceMs` default
|
|
96
|
+
is a test policy of this emulator, not a vendor rule; set it to `null` to disable it.
|
|
97
|
+
- **A validate-only budget mutation returns errors only.** A valid preflight answers `{}` with no
|
|
98
|
+
`results` and writes nothing
|
|
99
|
+
([`MutateCampaignBudgetsRequest`](https://github.com/googleapis/googleapis/blob/03a91044136a014466d4293eb1fe91f2b02075d2/google/ads/googleads/v25/services/campaign_budget_service.proto)).
|
|
100
|
+
- **Mutations take no idempotency key.** The duplicate guards are Google's own: a shared budget
|
|
101
|
+
name (`campaignBudgetError: DUPLICATE_NAME`), a conversion `orderId`
|
|
102
|
+
(`ORDER_ID_ALREADY_IN_USE`), a click and conversion time (`CLICK_CONVERSION_ALREADY_EXISTS`),
|
|
103
|
+
and a web `purchase` event's `transaction_id` per user.
|
|
104
|
+
- **`developer-token` is accepted and ignored.** The pinned protos mark the developer-token
|
|
105
|
+
errors as sunset; `login-customer-id` is still checked against the customer's manager.
|
|
106
|
+
|
|
66
107
|
## Controls and failures
|
|
67
108
|
|
|
68
109
|
Service-specific controls are protected by the shared `x-mockingbird-admin-key` guard and live
|
|
@@ -70,21 +111,41 @@ beside the shared health, state, clock, journal, fault, reset, and Timeline endp
|
|
|
70
111
|
|
|
71
112
|
| Control | Purpose |
|
|
72
113
|
| --- | --- |
|
|
73
|
-
| `GET /__admin/events` | Inspect stored Analytics events |
|
|
74
|
-
| `GET /__admin/conversions` | Inspect uploaded conversions |
|
|
114
|
+
| `GET /__admin/events` | Inspect stored Analytics events, oldest first |
|
|
115
|
+
| `GET /__admin/conversions` | Inspect uploaded conversions, oldest first |
|
|
75
116
|
| `GET /__admin/budgets` | Inspect campaign budgets |
|
|
76
|
-
| `GET /__admin/budget-mutations` | Inspect the mutation audit trail |
|
|
117
|
+
| `GET /__admin/budget-mutations` | Inspect the mutation audit trail: request ID, `before` and `after` per write |
|
|
77
118
|
| `GET /__admin/request-metadata` | Inspect redacted request metadata |
|
|
78
|
-
| `POST /__admin/daily-metrics` | Seed synthetic daily Ads metrics |
|
|
119
|
+
| `POST /__admin/daily-metrics` | Seed synthetic daily Ads metrics (`availableAt` delays one row) |
|
|
79
120
|
| `POST /__admin/page-tokens/expire` | Expire all current GAQL page tokens |
|
|
80
|
-
| `GET /__admin/settings` | Read
|
|
121
|
+
| `GET /__admin/settings` | Read `reportingLagMs`, `pageTokenTtlMs`, `futureToleranceMs` and `searchPageSize` |
|
|
81
122
|
| `PUT /__admin/settings` | Update those deterministic test settings |
|
|
82
123
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
124
|
+
| To | Use |
|
|
125
|
+
| --- | --- |
|
|
126
|
+
| Advance the reporting date | `POST /__admin/clock {"advance": "1d"}` or `{"set": "<ISO-8601>"}`; GAQL `DURING` ranges and Analytics `today`/`yesterday`/`NdaysAgo` follow it |
|
|
127
|
+
| Delay reporting | `PUT /__admin/settings {"reportingLagMs": n}` hides new events until the clock passes it |
|
|
128
|
+
| Page a small fixture | `PUT /__admin/settings {"searchPageSize": n}` (1 to 10,000) |
|
|
129
|
+
| Add latency | the `slow_response` preset, or `POST /__admin/faults {"latencyMs": n, "count": 1}` |
|
|
130
|
+
| Fail one request | the `server_error` or `network_reset` preset |
|
|
131
|
+
| Throttle | the `quota_exhausted` or `rate_limited` preset; `"count": n` throttles n requests |
|
|
132
|
+
| See what arrived | `GET /__admin/requests` (the journal) and `GET /__admin/request-metadata` |
|
|
133
|
+
|
|
134
|
+
Fault presets are `quota_exhausted`, `rate_limited`, `server_error`, `network_reset`,
|
|
135
|
+
`slow_response`, `ambiguous_budget_write`, `partial_budget_failure`, and
|
|
136
|
+
`partial_conversion_failure`. Each fires once unless `count` says otherwise, and is scoped to the
|
|
137
|
+
calling namespace. The two throttling presets answer `429 RESOURCE_EXHAUSTED` with
|
|
138
|
+
`quotaError: RESOURCE_EXHAUSTED` or `RESOURCE_TEMPORARILY_EXHAUSTED` and the backoff in
|
|
139
|
+
`details.quotaErrorDetails.retryDelay`, before anything is written.
|
|
140
|
+
|
|
141
|
+
`ambiguous_budget_write` commits the next executed budget mutation and then returns `503`, so
|
|
142
|
+
retry logic can be tested against an uncertain outcome. A validate-only preflight executes
|
|
143
|
+
nothing and does not spend it: the fault waits for the real call that follows. Reconcile before
|
|
144
|
+
replaying: the `503` carries a `request-id` header, and `GET /__admin/budget-mutations` lists the
|
|
145
|
+
write under that request ID with the budget's `before` and `after`.
|
|
146
|
+
|
|
147
|
+
Sensitive event and conversion payloads are sealed in state; journals and inspection metadata
|
|
148
|
+
contain only the fields needed for assertions.
|
|
88
149
|
|
|
89
150
|
## API
|
|
90
151
|
|
|
@@ -100,11 +161,19 @@ and the public fixture/runtime option types. The Node `./server` entry exports `
|
|
|
100
161
|
Acceptance tests cover GAQL precision and paging, budget preflight and partial writes, conversion
|
|
101
162
|
deduplication, GA4 event validation and reporting, OAuth scope/expiry behavior, encrypted state,
|
|
102
163
|
namespaces, resets, fault recovery, and the unmodified `google-auth-library` 11.1.0 HTTP signing
|
|
103
|
-
path.
|
|
104
|
-
is detected deterministically.
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
164
|
+
path. Runtime tests drive every control above over `/__admin`. Property tests exercise every
|
|
165
|
+
parity-enabled operation and prove that a divergent transport is detected deterministically.
|
|
166
|
+
|
|
167
|
+
`test/consumer.ts` is a port of the raw-fetch traffic described in the original service request.
|
|
168
|
+
It is not the requesting application's client, whose source is not in this repository, so no test
|
|
169
|
+
here runs that client unmodified.
|
|
170
|
+
|
|
171
|
+
## Deliberately not modelled
|
|
172
|
+
|
|
173
|
+
The emulator implements a bounded subset. It does not model the complete Google Ads resource
|
|
174
|
+
graph, every GAQL function, GAQL field-compatibility rules between segments and metrics, every
|
|
175
|
+
Analytics dimension or metric, real Google identity, production quota allocation, billing,
|
|
176
|
+
dashboards, attribution processing, or undocumented backend behavior. Uploaded conversions do not
|
|
177
|
+
feed `metrics.conversions`; seed those with daily metrics. Normal operation is local and does not
|
|
178
|
+
contact Google; only the explicit parity command uses credentials supplied through `.env.local`
|
|
179
|
+
or GitHub Actions secrets.
|
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
import {
|
|
2
2
|
createClock,
|
|
3
3
|
createRuntime,
|
|
4
|
+
normalizeBootControls,
|
|
4
5
|
parseDuration,
|
|
5
|
-
|
|
6
|
-
|
|
6
|
+
parseFaultHeader,
|
|
7
|
+
resolveAdminPrefix,
|
|
8
|
+
restoreClock
|
|
9
|
+
} from "./chunk-SNVGFGLM.js";
|
|
7
10
|
|
|
8
11
|
// ../../adapters/node/dist/cli.js
|
|
9
12
|
import { readFile as readFile2 } from "node:fs/promises";
|
|
@@ -50,6 +53,8 @@ var serve = async (api, options = {}) => {
|
|
|
50
53
|
req.socket.destroy();
|
|
51
54
|
return;
|
|
52
55
|
}
|
|
56
|
+
if (aborted.signal.aborted)
|
|
57
|
+
return;
|
|
53
58
|
res.writeHead(500, { "content-type": "application/json" });
|
|
54
59
|
res.end(JSON.stringify({
|
|
55
60
|
error: {
|
|
@@ -81,6 +86,7 @@ var serve = async (api, options = {}) => {
|
|
|
81
86
|
}
|
|
82
87
|
res.end();
|
|
83
88
|
} catch {
|
|
89
|
+
await new Promise((resolve2) => setImmediate(resolve2));
|
|
84
90
|
res.destroy();
|
|
85
91
|
} finally {
|
|
86
92
|
reader.releaseLock();
|
|
@@ -164,11 +170,11 @@ var httpChild = async (target, entry, options) => {
|
|
|
164
170
|
adminKey: entry.adminKey,
|
|
165
171
|
seed: entry.seed,
|
|
166
172
|
onLog: options.onLog,
|
|
173
|
+
...entry.adminControls !== void 0 ? { adminControls: entry.adminControls } : {},
|
|
167
174
|
...entry.fixtures !== void 0 ? { fixtures: entry.fixtures } : {},
|
|
168
175
|
...entry.baseUrl !== void 0 ? { baseUrl: entry.baseUrl } : {}
|
|
169
176
|
});
|
|
170
177
|
const adminPrefix = resolveAdminPrefix(entry.adminPrefix);
|
|
171
|
-
runtime.isolateNamespaces();
|
|
172
178
|
const locked = /* @__PURE__ */ new Set();
|
|
173
179
|
const active = /* @__PURE__ */ new Map();
|
|
174
180
|
const api = {
|
|
@@ -430,19 +436,12 @@ async function startFleet(config, options) {
|
|
|
430
436
|
return json(400, { error: "freeze must be boolean" });
|
|
431
437
|
const response2 = await transaction(namespace, selected, async (_name, child) => {
|
|
432
438
|
const childClock = child.clock(namespace);
|
|
433
|
-
childClock
|
|
434
|
-
childClock.set(target);
|
|
435
|
-
if (!frozen)
|
|
436
|
-
childClock.unfreeze();
|
|
439
|
+
restoreClock(childClock, { now: target, frozen });
|
|
437
440
|
await child.settle?.(namespace);
|
|
438
441
|
return childClock.state();
|
|
439
442
|
});
|
|
440
|
-
if (response2.ok)
|
|
441
|
-
current
|
|
442
|
-
current.set(target);
|
|
443
|
-
if (!frozen)
|
|
444
|
-
current.unfreeze();
|
|
445
|
-
}
|
|
443
|
+
if (response2.ok)
|
|
444
|
+
restoreClock(current, { now: target, frozen });
|
|
446
445
|
return response2;
|
|
447
446
|
}
|
|
448
447
|
if (!route)
|
|
@@ -461,13 +460,8 @@ async function startFleet(config, options) {
|
|
|
461
460
|
return json(404, { error: "no snapshot in this namespace" });
|
|
462
461
|
const restored = new Map([...point.values].map(([name]) => [name, children.get(name)]));
|
|
463
462
|
const response2 = await transaction(namespace, restored, async (name, child) => child.restore(namespace, point.values.get(name)));
|
|
464
|
-
if (response2.ok)
|
|
465
|
-
|
|
466
|
-
current.freeze();
|
|
467
|
-
current.set(point.clock.now);
|
|
468
|
-
if (!point.clock.frozen)
|
|
469
|
-
current.unfreeze();
|
|
470
|
-
}
|
|
463
|
+
if (response2.ok)
|
|
464
|
+
restoreClock(clock(namespace), point.clock);
|
|
471
465
|
return response2;
|
|
472
466
|
}
|
|
473
467
|
const snapshotId = randomUUID();
|
|
@@ -493,6 +487,8 @@ async function startFleet(config, options) {
|
|
|
493
487
|
for (const [name, entry] of Object.entries(config.services)) {
|
|
494
488
|
if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(name))
|
|
495
489
|
throw new Error("invalid service name");
|
|
490
|
+
if (config.adminControls !== void 0 || entry.adminControls !== void 0)
|
|
491
|
+
normalizeBootControls({ ...config.adminControls, ...entry.adminControls }, `services.${name}.adminControls`);
|
|
496
492
|
try {
|
|
497
493
|
const target = await options.load(name);
|
|
498
494
|
if (entry.protocol && entry.protocol !== ("protocol" in target ? target.protocol : "http"))
|
|
@@ -501,7 +497,9 @@ async function startFleet(config, options) {
|
|
|
501
497
|
const child = "start" in target ? await target.start(entry) : await httpChild(target, {
|
|
502
498
|
...entry,
|
|
503
499
|
adminPrefix: resolveAdminPrefix(entry.adminPrefix ?? adminPrefix),
|
|
504
|
-
...adminKey !== void 0 ? { adminKey } : {}
|
|
500
|
+
...adminKey !== void 0 ? { adminKey } : {},
|
|
501
|
+
// Fleet-wide controls, with the service's own keys replacing them.
|
|
502
|
+
...config.adminControls !== void 0 || entry.adminControls !== void 0 ? { adminControls: { ...config.adminControls, ...entry.adminControls } } : {}
|
|
505
503
|
}, options);
|
|
506
504
|
children.set(name, child);
|
|
507
505
|
if (!await child.ready())
|
|
@@ -634,7 +632,51 @@ var COMMON_SERVE_OPTIONS = {
|
|
|
634
632
|
value: "<key>",
|
|
635
633
|
description: "Require x-mockingbird-admin-key on /__admin/* (env MOCKINGBIRD_ADMIN_KEY)"
|
|
636
634
|
},
|
|
637
|
-
seed: {
|
|
635
|
+
seed: {
|
|
636
|
+
type: "string",
|
|
637
|
+
value: "<seed>",
|
|
638
|
+
description: "Seed for every random choice (env MOCKINGBIRD_SEED)"
|
|
639
|
+
},
|
|
640
|
+
"admin-controls": {
|
|
641
|
+
type: "string",
|
|
642
|
+
value: "<json|file>",
|
|
643
|
+
description: "Admin controls to boot with, as JSON or a JSON file: clock, latency, faults, snapshot, namespaces (env MOCKINGBIRD_ADMIN_CONTROLS)"
|
|
644
|
+
},
|
|
645
|
+
clock: {
|
|
646
|
+
type: "string",
|
|
647
|
+
value: "<instant>",
|
|
648
|
+
description: "Start the clock at this instant (epoch ms or ISO-8601)"
|
|
649
|
+
},
|
|
650
|
+
freeze: { type: "boolean", description: "Freeze the clock: it moves only when told to" },
|
|
651
|
+
"auto-advance": {
|
|
652
|
+
type: "string",
|
|
653
|
+
value: "<duration>",
|
|
654
|
+
description: "Step the clock this much before every request (e.g. 1s)"
|
|
655
|
+
},
|
|
656
|
+
latency: {
|
|
657
|
+
type: "string",
|
|
658
|
+
value: "<duration>",
|
|
659
|
+
description: "Baseline response time for every request (e.g. 250ms)"
|
|
660
|
+
},
|
|
661
|
+
jitter: {
|
|
662
|
+
type: "string",
|
|
663
|
+
value: "<duration>",
|
|
664
|
+
description: "Seeded extra latency between 0 and this"
|
|
665
|
+
},
|
|
666
|
+
"virtual-time": {
|
|
667
|
+
type: "boolean",
|
|
668
|
+
description: "Spend latency, delays and timeouts on the mock clock instead of really waiting"
|
|
669
|
+
},
|
|
670
|
+
fault: {
|
|
671
|
+
type: "string",
|
|
672
|
+
value: "<fault>",
|
|
673
|
+
description: 'A fault to boot with, as in x-mockingbird-fault: 503, rate_limited, "status=503; delay=2s"'
|
|
674
|
+
},
|
|
675
|
+
snapshot: {
|
|
676
|
+
type: "string",
|
|
677
|
+
value: "<file>",
|
|
678
|
+
description: "Start from a snapshot document (GET /__admin/snapshots/:id)"
|
|
679
|
+
},
|
|
638
680
|
log: {
|
|
639
681
|
type: "string",
|
|
640
682
|
value: "<pretty|json|off>",
|
|
@@ -681,6 +723,68 @@ var formatLog = (format) => {
|
|
|
681
723
|
};
|
|
682
724
|
};
|
|
683
725
|
var asString = (value) => typeof value === "string" ? value : void 0;
|
|
726
|
+
var readJsonFile = async (path, label) => {
|
|
727
|
+
try {
|
|
728
|
+
return JSON.parse(await readFile2(path, "utf8"));
|
|
729
|
+
} catch (error) {
|
|
730
|
+
throw new Error(`${label}: cannot read ${path}: ${error instanceof Error ? error.message : error}`);
|
|
731
|
+
}
|
|
732
|
+
};
|
|
733
|
+
var isObject2 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
734
|
+
var resolveServeControls = async (values, env = process.env, base) => {
|
|
735
|
+
const source = asString(values["admin-controls"]) ?? env.MOCKINGBIRD_ADMIN_CONTROLS;
|
|
736
|
+
let document = isObject2(base) ? { ...base } : {};
|
|
737
|
+
if (source !== void 0 && source.trim() !== "") {
|
|
738
|
+
let parsed;
|
|
739
|
+
if (source.trim().startsWith("{")) {
|
|
740
|
+
try {
|
|
741
|
+
parsed = JSON.parse(source);
|
|
742
|
+
} catch {
|
|
743
|
+
throw new Error("--admin-controls: not valid JSON");
|
|
744
|
+
}
|
|
745
|
+
} else
|
|
746
|
+
parsed = await readJsonFile(source, "--admin-controls");
|
|
747
|
+
if (!isObject2(parsed))
|
|
748
|
+
throw new Error("--admin-controls: expected a JSON object");
|
|
749
|
+
document = { ...document, ...parsed };
|
|
750
|
+
}
|
|
751
|
+
const clock = isObject2(document.clock) ? { ...document.clock } : {};
|
|
752
|
+
const now = asString(values.clock);
|
|
753
|
+
if (now !== void 0)
|
|
754
|
+
clock.now = now;
|
|
755
|
+
if (values.freeze === true)
|
|
756
|
+
clock.frozen = true;
|
|
757
|
+
const autoAdvance = asString(values["auto-advance"]);
|
|
758
|
+
if (autoAdvance !== void 0)
|
|
759
|
+
clock.autoAdvance = autoAdvance;
|
|
760
|
+
if (Object.keys(clock).length > 0)
|
|
761
|
+
document.clock = clock;
|
|
762
|
+
const delay = asString(values.latency);
|
|
763
|
+
const jitter = asString(values.jitter);
|
|
764
|
+
if (delay !== void 0 || jitter !== void 0) {
|
|
765
|
+
const latency = isObject2(document.latency) ? { ...document.latency } : document.latency !== void 0 ? { delay: document.latency } : {};
|
|
766
|
+
if (delay !== void 0)
|
|
767
|
+
latency.delay = delay;
|
|
768
|
+
if (jitter !== void 0)
|
|
769
|
+
latency.jitter = jitter;
|
|
770
|
+
document.latency = latency;
|
|
771
|
+
}
|
|
772
|
+
if (values["virtual-time"] === true)
|
|
773
|
+
document.virtualTime = true;
|
|
774
|
+
const fault = asString(values.fault);
|
|
775
|
+
if (fault !== void 0)
|
|
776
|
+
document.faults = [
|
|
777
|
+
...Array.isArray(document.faults) ? document.faults : [],
|
|
778
|
+
parseFaultHeader(fault, "--fault")
|
|
779
|
+
];
|
|
780
|
+
const snapshot = asString(values.snapshot);
|
|
781
|
+
if (snapshot !== void 0)
|
|
782
|
+
document.snapshot = await readJsonFile(snapshot, "--snapshot");
|
|
783
|
+
if (Object.keys(document).length === 0)
|
|
784
|
+
return void 0;
|
|
785
|
+
normalizeBootControls(document, "adminControls");
|
|
786
|
+
return document;
|
|
787
|
+
};
|
|
684
788
|
var loadTarget = async (name, own) => {
|
|
685
789
|
if (name === own.name)
|
|
686
790
|
return own;
|
|
@@ -707,7 +811,8 @@ var start = async (target, values, config) => {
|
|
|
707
811
|
adminPrefix,
|
|
708
812
|
adminKey: config.adminKey,
|
|
709
813
|
seed: config.seed,
|
|
710
|
-
onLog: formatLog(config.log)
|
|
814
|
+
onLog: formatLog(config.log),
|
|
815
|
+
...config.adminControls !== void 0 ? { adminControls: config.adminControls } : {}
|
|
711
816
|
});
|
|
712
817
|
const listening = await listen(runtime, { port: config.port, host: config.host });
|
|
713
818
|
target.listening?.(listening);
|
|
@@ -747,6 +852,9 @@ var serveCommand = (target) => ({
|
|
|
747
852
|
const adminKey2 = config.adminKey ?? asString(values["admin-key"]) ?? process.env.MOCKINGBIRD_ADMIN_KEY;
|
|
748
853
|
if (adminKey2 !== void 0)
|
|
749
854
|
config.adminKey = adminKey2;
|
|
855
|
+
const adminControls = await resolveServeControls(values, process.env, config.adminControls);
|
|
856
|
+
if (adminControls !== void 0)
|
|
857
|
+
config.adminControls = adminControls;
|
|
750
858
|
const fleet = await startFleet(config, {
|
|
751
859
|
load: (name) => loadTarget(name, target),
|
|
752
860
|
onLog: formatLog(config.log ?? log),
|
|
@@ -772,10 +880,12 @@ var serveCommand = (target) => ({
|
|
|
772
880
|
}
|
|
773
881
|
const port = asString(values.port);
|
|
774
882
|
const adminKey = asString(values["admin-key"]) ?? process.env.MOCKINGBIRD_ADMIN_KEY;
|
|
775
|
-
const seed = asString(values.seed);
|
|
883
|
+
const seed = asString(values.seed) ?? process.env.MOCKINGBIRD_SEED;
|
|
776
884
|
let listening;
|
|
777
885
|
try {
|
|
886
|
+
const adminControls = await resolveServeControls(values);
|
|
778
887
|
listening = await start(target, values, {
|
|
888
|
+
...adminControls !== void 0 ? { adminControls } : {},
|
|
779
889
|
adminPrefix: resolveAdminPrefix(asString(values["admin-prefix"]) ?? process.env.MOCKINGBIRD_ADMIN_PREFIX),
|
|
780
890
|
port: port === void 0 ? target.defaultPort : Number.parseInt(port, 10),
|
|
781
891
|
host: asString(values.host) ?? "127.0.0.1",
|
|
@@ -804,6 +914,7 @@ var serveTarget = {
|
|
|
804
914
|
create: (_values, common) => createRuntime({
|
|
805
915
|
...common.adminPrefix !== void 0 ? { adminPrefix: common.adminPrefix } : {},
|
|
806
916
|
...common.adminKey !== void 0 ? { adminKey: common.adminKey } : {},
|
|
917
|
+
...common.adminControls !== void 0 ? { adminControls: common.adminControls } : {},
|
|
807
918
|
...common.seed !== void 0 ? { seed: common.seed } : {},
|
|
808
919
|
...common.onLog ? { onLog: common.onLog } : {}
|
|
809
920
|
}),
|
|
@@ -817,4 +928,4 @@ export {
|
|
|
817
928
|
createServer2 as createServer,
|
|
818
929
|
serveTarget
|
|
819
930
|
};
|
|
820
|
-
//# sourceMappingURL=chunk-
|
|
931
|
+
//# sourceMappingURL=chunk-ITE3L2DF.js.map
|