@specific.dev/spectest 0.108.0 → 0.109.1
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/dist/daemon.js +24 -12
- package/dist/harness/protocol.d.ts +1 -1
- package/dist/harness/protocol.js +1 -0
- package/dist/helper-tracking.js +4 -2
- package/dist/index.d.ts +15 -5
- package/dist/index.js +2 -0
- package/package.json +1 -1
- package/src/daemon.ts +32 -12
- package/src/harness/protocol.ts +2 -0
- package/src/helper-tracking.test.ts +36 -0
- package/src/helper-tracking.ts +4 -2
- package/src/index.ts +16 -5
- package/src/interactive-setup.test.ts +89 -0
package/dist/daemon.js
CHANGED
|
@@ -2925,31 +2925,34 @@ async function bootstrapInner(prepared, identity) {
|
|
|
2925
2925
|
logBootstrapTimings(result);
|
|
2926
2926
|
return result;
|
|
2927
2927
|
}
|
|
2928
|
+
// Captured as false in the shared template, true in a seeded interactive
|
|
2929
|
+
// snapshot. Repeated requests must not apply a successful seed twice.
|
|
2930
|
+
let interactiveSetupCompleted = false;
|
|
2928
2931
|
/**
|
|
2929
|
-
* Run
|
|
2930
|
-
*
|
|
2931
|
-
*
|
|
2932
|
-
* exactly once and inherited by every later snapshot/fork.
|
|
2932
|
+
* Run a project setup hook. Normal `setup` runs before the shared warm
|
|
2933
|
+
* snapshot; `setupInteractive` runs only after it, in a fresh user start.
|
|
2934
|
+
* Both use the same context and boot-log capture.
|
|
2933
2935
|
*
|
|
2934
2936
|
* Unlike test runs, this is NOT instrumented — no recorder, no event
|
|
2935
2937
|
* timeline, no timeout from the test runner. Setup failures abort the
|
|
2936
2938
|
* env bring-up; the control plane surfaces them as a start failure.
|
|
2937
2939
|
*/
|
|
2938
|
-
async function runProjectSetup() {
|
|
2940
|
+
async function runProjectSetup(hook = "setup") {
|
|
2939
2941
|
// Same tee as bootstrap: a project `setup` hook's console output is
|
|
2940
2942
|
// part of explaining the boot, not something to lose on success.
|
|
2941
2943
|
const restoreBootLog = captureConsole(BOOT_LOG_SINK);
|
|
2942
2944
|
try {
|
|
2943
|
-
return await runProjectSetupInner();
|
|
2945
|
+
return await runProjectSetupInner(hook);
|
|
2944
2946
|
}
|
|
2945
2947
|
finally {
|
|
2946
2948
|
restoreBootLog();
|
|
2947
2949
|
}
|
|
2948
2950
|
}
|
|
2949
|
-
async function runProjectSetupInner() {
|
|
2950
|
-
const
|
|
2951
|
-
if (!
|
|
2951
|
+
async function runProjectSetupInner(hook) {
|
|
2952
|
+
const setup = requireLoaded().project[hook];
|
|
2953
|
+
if (!setup || (hook === "setupInteractive" && interactiveSetupCompleted)) {
|
|
2952
2954
|
return { ran: false, durationMs: 0 };
|
|
2955
|
+
}
|
|
2953
2956
|
const start = Date.now();
|
|
2954
2957
|
// Install the fetch wrapper for the duration of setup so `ctx.fetch` (and any
|
|
2955
2958
|
// client routed through `globalThis.fetch`) returns a wrapped Response, same
|
|
@@ -2960,9 +2963,11 @@ async function runProjectSetupInner() {
|
|
|
2960
2963
|
// The full context, unscoped: every service is up by project-setup time,
|
|
2961
2964
|
// so `ctx.svc` is the same map tests see (and shares helper instances with
|
|
2962
2965
|
// them — a Bun.SQL pool created here is reused later).
|
|
2963
|
-
const ctx = await spectestContext();
|
|
2964
2966
|
try {
|
|
2965
|
-
|
|
2967
|
+
const ctx = await spectestContext();
|
|
2968
|
+
await runInstrumented(fetchScope, () => setup(ctx));
|
|
2969
|
+
if (hook === "setupInteractive")
|
|
2970
|
+
interactiveSetupCompleted = true;
|
|
2966
2971
|
}
|
|
2967
2972
|
finally {
|
|
2968
2973
|
fetchScope.active = false;
|
|
@@ -5131,7 +5136,13 @@ export function harnessMethods(state) {
|
|
|
5131
5136
|
setRecordSecrets(secrets);
|
|
5132
5137
|
};
|
|
5133
5138
|
return {
|
|
5134
|
-
health: async () => ({
|
|
5139
|
+
health: async () => ({
|
|
5140
|
+
ok: true,
|
|
5141
|
+
capabilities: ["prepared-images-v1"],
|
|
5142
|
+
// Optional discovery: old SDKs omit this field and are never sent the
|
|
5143
|
+
// new method. Check the loaded project, including after warm restore.
|
|
5144
|
+
setupInteractive: typeof loaded?.project.setupInteractive === "function",
|
|
5145
|
+
}),
|
|
5135
5146
|
// Live bootstrap progress, polled by the control plane during
|
|
5136
5147
|
// `bootstrap` and streamed into the test-run row. `{}` before the
|
|
5137
5148
|
// first bootstrap of this harness process.
|
|
@@ -5248,6 +5259,7 @@ export function harnessMethods(state) {
|
|
|
5248
5259
|
return { ok: true, timings: await bootstrap(prepared) };
|
|
5249
5260
|
}),
|
|
5250
5261
|
projectSetup: async () => exclusive(state, "inFlightProjectSetup", "project-setup already in progress", () => runProjectSetup()),
|
|
5262
|
+
interactiveSetup: async () => exclusive(state, "inFlightProjectSetup", "project-setup already in progress", () => runProjectSetup("setupInteractive")),
|
|
5251
5263
|
eval: async (params) => {
|
|
5252
5264
|
const code = requireString(params, "code");
|
|
5253
5265
|
return exclusive(state, "inFlightTest", "a test or eval is already running", () => {
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
/** Protocol version this build speaks. Must match `PROTOCOL_VERSION` in protocol.rs. */
|
|
24
24
|
export declare const PROTOCOL_VERSION = 1;
|
|
25
25
|
/** Methods either side can send. */
|
|
26
|
-
export type Method = "hello" | "load" | "loadTests" | "fingerprint" | "bootstrap" | "run" | "eval" | "setRecordSecrets" | "teardown" | "shutdown" | "createArtifact" | "completeArtifact" | "progress" | "step";
|
|
26
|
+
export type Method = "hello" | "load" | "loadTests" | "fingerprint" | "bootstrap" | "interactiveSetup" | "run" | "eval" | "setRecordSecrets" | "teardown" | "shutdown" | "createArtifact" | "completeArtifact" | "progress" | "step";
|
|
27
27
|
/** Methods this build knows how to dispatch. A method outside this set still
|
|
28
28
|
* *parses* — it is answered with an error, never dropped. */
|
|
29
29
|
export declare const KNOWN_METHODS: ReadonlySet<string>;
|
package/dist/harness/protocol.js
CHANGED
package/dist/helper-tracking.js
CHANGED
|
@@ -83,9 +83,11 @@ function invokeHelper(kind, name, member, fn, receiver, callArgs) {
|
|
|
83
83
|
};
|
|
84
84
|
let result;
|
|
85
85
|
try {
|
|
86
|
+
// Reading a callable proxy's `.apply` can bind directly to its raw client
|
|
87
|
+
// and bypass instrumentation (SQL does this). Invoke the proxy itself.
|
|
86
88
|
result = recording.run(() => kind === "fake"
|
|
87
|
-
? runUninstrumented(() =>
|
|
88
|
-
:
|
|
89
|
+
? runUninstrumented(() => Reflect.apply(fn, receiver, args))
|
|
90
|
+
: Reflect.apply(fn, receiver, args));
|
|
89
91
|
}
|
|
90
92
|
catch (error) {
|
|
91
93
|
return fail(error);
|
package/dist/index.d.ts
CHANGED
|
@@ -1624,17 +1624,25 @@ export interface Project<S extends ServicesMap = ServicesMap, F extends FakesMap
|
|
|
1624
1624
|
* appear in the timeline.
|
|
1625
1625
|
*/
|
|
1626
1626
|
setup?: ProjectSetupFn<S, F>;
|
|
1627
|
+
/**
|
|
1628
|
+
* Prepare a new interactive environment, after normal setup and the shared
|
|
1629
|
+
* warm-template snapshot. Runs on both cold starts and warm restores from
|
|
1630
|
+
* the dashboard or `spectest env start`, before the environment is ready.
|
|
1631
|
+
* Never runs for tests, snapshot forks, or resuming a stopped environment.
|
|
1632
|
+
* Uses the same context as `setup`; throw to fail the start.
|
|
1633
|
+
*/
|
|
1634
|
+
setupInteractive?: ProjectSetupFn<S, F>;
|
|
1627
1635
|
tests?: TestSuite<S, F>;
|
|
1628
1636
|
}
|
|
1629
1637
|
/**
|
|
1630
|
-
* Context handed to the project-level `setup`
|
|
1638
|
+
* Context handed to the project-level `setup` and `setupInteractive` hooks: the full
|
|
1631
1639
|
* {@link SpectestContext}, minus only what a timeline gives a test
|
|
1632
1640
|
* (testName, parent, browser/mobile/terminal). Everything a test can do
|
|
1633
1641
|
* to the environment — exec, fetch, `svc`/`fakes` helpers, `poll`,
|
|
1634
1642
|
* `dnsName`, `certificate`, `startService`, and project-file access via
|
|
1635
|
-
* `projectRoot`/`readProjectFile` — is available here too
|
|
1636
|
-
*
|
|
1637
|
-
*
|
|
1643
|
+
* `projectRoot`/`readProjectFile` — is available here too. Normal `setup`
|
|
1644
|
+
* state is captured into the shared warm template; `setupInteractive`
|
|
1645
|
+
* state belongs only to the new interactive environment.
|
|
1638
1646
|
*/
|
|
1639
1647
|
export interface ProjectSetupContext<S extends ServicesMap = ServicesMap, F extends FakesMap = FakesMap> extends SpectestContext<S, F> {
|
|
1640
1648
|
}
|
|
@@ -1673,6 +1681,8 @@ export type ProjectSetupFn<S extends ServicesMap = ServicesMap, F extends FakesM
|
|
|
1673
1681
|
export interface ProjectOpts<S extends ServicesMap = ServicesMap, F extends FakesMap = FakesMap> {
|
|
1674
1682
|
/** Optional project-level setup. See `Project.setup`. */
|
|
1675
1683
|
setup?: ProjectSetupFn<S, F>;
|
|
1684
|
+
/** Optional interactive-only setup. See `Project.setupInteractive`. */
|
|
1685
|
+
setupInteractive?: ProjectSetupFn<S, F>;
|
|
1676
1686
|
/** Test cases for this project's suite. */
|
|
1677
1687
|
tests?: TestCase<unknown, S, F>[];
|
|
1678
1688
|
}
|
|
@@ -1726,7 +1736,7 @@ export interface DefinedEnvironment<S extends ServicesMap, F extends FakesMap =
|
|
|
1726
1736
|
group<P = undefined>(name: string, opts?: GroupOpts<P, S, F>): TestGroup<P, S, F>;
|
|
1727
1737
|
/** Bundle this environment with a test suite into the project default
|
|
1728
1738
|
* export. Pass tests as a plain array for the common case, or an
|
|
1729
|
-
* options bag to attach
|
|
1739
|
+
* options bag to attach `setup` or `setupInteractive`. (Fakes are declared on
|
|
1730
1740
|
* `defineEnvironment`, not here.) */
|
|
1731
1741
|
project(tests?: TestCase<unknown, S, F>[]): Project<S, F>;
|
|
1732
1742
|
project(opts: ProjectOpts<S, F>): Project<S, F>;
|
package/dist/index.js
CHANGED
|
@@ -484,6 +484,8 @@ export function defineEnvironment(input) {
|
|
|
484
484
|
const proj = { environment: config };
|
|
485
485
|
if (opts.setup)
|
|
486
486
|
proj.setup = opts.setup;
|
|
487
|
+
if (opts.setupInteractive)
|
|
488
|
+
proj.setupInteractive = opts.setupInteractive;
|
|
487
489
|
if (fakes)
|
|
488
490
|
proj.fakes = fakes;
|
|
489
491
|
if (opts.tests && opts.tests.length > 0) {
|
package/package.json
CHANGED
package/src/daemon.ts
CHANGED
|
@@ -3473,30 +3473,35 @@ interface ProjectSetupResult {
|
|
|
3473
3473
|
durationMs: number;
|
|
3474
3474
|
}
|
|
3475
3475
|
|
|
3476
|
+
// Captured as false in the shared template, true in a seeded interactive
|
|
3477
|
+
// snapshot. Repeated requests must not apply a successful seed twice.
|
|
3478
|
+
let interactiveSetupCompleted = false;
|
|
3479
|
+
|
|
3476
3480
|
/**
|
|
3477
|
-
* Run
|
|
3478
|
-
*
|
|
3479
|
-
*
|
|
3480
|
-
* exactly once and inherited by every later snapshot/fork.
|
|
3481
|
+
* Run a project setup hook. Normal `setup` runs before the shared warm
|
|
3482
|
+
* snapshot; `setupInteractive` runs only after it, in a fresh user start.
|
|
3483
|
+
* Both use the same context and boot-log capture.
|
|
3481
3484
|
*
|
|
3482
3485
|
* Unlike test runs, this is NOT instrumented — no recorder, no event
|
|
3483
3486
|
* timeline, no timeout from the test runner. Setup failures abort the
|
|
3484
3487
|
* env bring-up; the control plane surfaces them as a start failure.
|
|
3485
3488
|
*/
|
|
3486
|
-
async function runProjectSetup(): Promise<ProjectSetupResult> {
|
|
3489
|
+
async function runProjectSetup(hook: "setup" | "setupInteractive" = "setup"): Promise<ProjectSetupResult> {
|
|
3487
3490
|
// Same tee as bootstrap: a project `setup` hook's console output is
|
|
3488
3491
|
// part of explaining the boot, not something to lose on success.
|
|
3489
3492
|
const restoreBootLog = captureConsole(BOOT_LOG_SINK);
|
|
3490
3493
|
try {
|
|
3491
|
-
return await runProjectSetupInner();
|
|
3494
|
+
return await runProjectSetupInner(hook);
|
|
3492
3495
|
} finally {
|
|
3493
3496
|
restoreBootLog();
|
|
3494
3497
|
}
|
|
3495
3498
|
}
|
|
3496
3499
|
|
|
3497
|
-
async function runProjectSetupInner(): Promise<ProjectSetupResult> {
|
|
3498
|
-
const
|
|
3499
|
-
if (!
|
|
3500
|
+
async function runProjectSetupInner(hook: "setup" | "setupInteractive"): Promise<ProjectSetupResult> {
|
|
3501
|
+
const setup = requireLoaded().project[hook];
|
|
3502
|
+
if (!setup || (hook === "setupInteractive" && interactiveSetupCompleted)) {
|
|
3503
|
+
return { ran: false, durationMs: 0 };
|
|
3504
|
+
}
|
|
3500
3505
|
const start = Date.now();
|
|
3501
3506
|
// Install the fetch wrapper for the duration of setup so `ctx.fetch` (and any
|
|
3502
3507
|
// client routed through `globalThis.fetch`) returns a wrapped Response, same
|
|
@@ -3507,9 +3512,10 @@ async function runProjectSetupInner(): Promise<ProjectSetupResult> {
|
|
|
3507
3512
|
// The full context, unscoped: every service is up by project-setup time,
|
|
3508
3513
|
// so `ctx.svc` is the same map tests see (and shares helper instances with
|
|
3509
3514
|
// them — a Bun.SQL pool created here is reused later).
|
|
3510
|
-
const ctx: ProjectSetupContext = await spectestContext();
|
|
3511
3515
|
try {
|
|
3512
|
-
|
|
3516
|
+
const ctx: ProjectSetupContext = await spectestContext();
|
|
3517
|
+
await runInstrumented(fetchScope, () => setup(ctx));
|
|
3518
|
+
if (hook === "setupInteractive") interactiveSetupCompleted = true;
|
|
3513
3519
|
} finally {
|
|
3514
3520
|
fetchScope.active = false;
|
|
3515
3521
|
restoreFetch();
|
|
@@ -6148,7 +6154,13 @@ export function harnessMethods(state: RouteState): MethodTable {
|
|
|
6148
6154
|
setRecordSecrets(secrets as Record<string, string>);
|
|
6149
6155
|
};
|
|
6150
6156
|
return {
|
|
6151
|
-
health: async () => ({
|
|
6157
|
+
health: async () => ({
|
|
6158
|
+
ok: true,
|
|
6159
|
+
capabilities: ["prepared-images-v1"],
|
|
6160
|
+
// Optional discovery: old SDKs omit this field and are never sent the
|
|
6161
|
+
// new method. Check the loaded project, including after warm restore.
|
|
6162
|
+
setupInteractive: typeof loaded?.project.setupInteractive === "function",
|
|
6163
|
+
}),
|
|
6152
6164
|
|
|
6153
6165
|
// Live bootstrap progress, polled by the control plane during
|
|
6154
6166
|
// `bootstrap` and streamed into the test-run row. `{}` before the
|
|
@@ -6285,6 +6297,14 @@ export function harnessMethods(state: RouteState): MethodTable {
|
|
|
6285
6297
|
() => runProjectSetup(),
|
|
6286
6298
|
),
|
|
6287
6299
|
|
|
6300
|
+
interactiveSetup: async () =>
|
|
6301
|
+
exclusive(
|
|
6302
|
+
state,
|
|
6303
|
+
"inFlightProjectSetup",
|
|
6304
|
+
"project-setup already in progress",
|
|
6305
|
+
() => runProjectSetup("setupInteractive"),
|
|
6306
|
+
),
|
|
6307
|
+
|
|
6288
6308
|
eval: async (params) => {
|
|
6289
6309
|
const code = requireString(params, "code");
|
|
6290
6310
|
return exclusive(state, "inFlightTest", "a test or eval is already running", () => {
|
package/src/harness/protocol.ts
CHANGED
|
@@ -32,6 +32,7 @@ export type Method =
|
|
|
32
32
|
| "loadTests"
|
|
33
33
|
| "fingerprint"
|
|
34
34
|
| "bootstrap"
|
|
35
|
+
| "interactiveSetup"
|
|
35
36
|
| "run"
|
|
36
37
|
| "eval"
|
|
37
38
|
| "setRecordSecrets"
|
|
@@ -51,6 +52,7 @@ export const KNOWN_METHODS: ReadonlySet<string> = new Set<Method>([
|
|
|
51
52
|
"loadTests",
|
|
52
53
|
"fingerprint",
|
|
53
54
|
"bootstrap",
|
|
55
|
+
"interactiveSetup",
|
|
54
56
|
"run",
|
|
55
57
|
"eval",
|
|
56
58
|
"setRecordSecrets",
|
|
@@ -3,6 +3,7 @@ import { annotate } from "./annotate.js";
|
|
|
3
3
|
import { expect as assertValue, type ServiceHandlesFor, type Wrapped } from "./index.js";
|
|
4
4
|
import { readRaw, readTag, wrap } from "./inspect.js";
|
|
5
5
|
import { trackHelpers } from "./helper-tracking.js";
|
|
6
|
+
import { SQL } from "./sql.js";
|
|
6
7
|
import {
|
|
7
8
|
recordHttp, recordWait, recorderEventCount, recorderMarkChildren, recorderTruncate,
|
|
8
9
|
startRecording, stopRecording,
|
|
@@ -175,6 +176,41 @@ test("live objects, callable clients, function properties and receivers survive"
|
|
|
175
176
|
expect<unknown>(readRaw(helper.latest.call({...raw, subject: "Other"}))).toEqual({subject: "Other"});
|
|
176
177
|
});
|
|
177
178
|
|
|
179
|
+
test("a tracked SQL client keeps tagged queries and attached methods instrumented", async () => {
|
|
180
|
+
const client = new SQL("sqlite://:memory:", { label: "db" });
|
|
181
|
+
const helper = trackHelpers("service", "db", { client });
|
|
182
|
+
startRecording();
|
|
183
|
+
try {
|
|
184
|
+
const rows = await helper.client<{ id: number }>`SELECT ${7} AS id`;
|
|
185
|
+
assertValue(rows.transform("ids", (rows) => Array.from(rows, (row) => row.id))).toEqual([7]);
|
|
186
|
+
const other = await helper.client.unsafe<{ id: number }>("SELECT ? AS id", [8]);
|
|
187
|
+
assertValue(other[0]!.id).toBe(8);
|
|
188
|
+
const events = stopRecording();
|
|
189
|
+
expect(events.map((event) => event.kind)).toEqual(["db", "assertion", "db", "assertion"]);
|
|
190
|
+
expect(events[0]).toMatchObject({ service: "db", query: "SELECT $1 AS id", params: [7], rows: [{ id: 7 }] });
|
|
191
|
+
expect(events[1]).toMatchObject({ sourceSeq: events[0]!.seq, path: ["<ids>"] });
|
|
192
|
+
expect(events[2]).toMatchObject({ service: "db", query: "SELECT ? AS id", params: [8], rows: [{ id: 8 }] });
|
|
193
|
+
expect(events[3]).toMatchObject({ sourceSeq: events[2]!.seq, path: ["0", "id"] });
|
|
194
|
+
} finally {
|
|
195
|
+
await helper.client.close!();
|
|
196
|
+
}
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
test("a tracked SQL client preserves query failure diagnostics", async () => {
|
|
200
|
+
const client = new SQL("sqlite://:memory:", { label: "db" });
|
|
201
|
+
const helper = trackHelpers("service", "db", { client });
|
|
202
|
+
startRecording();
|
|
203
|
+
try {
|
|
204
|
+
await expect(helper.client`SELECT * FROM missing_table`).rejects.toThrow("no such table");
|
|
205
|
+
const events = stopRecording();
|
|
206
|
+
expect(events).toHaveLength(1);
|
|
207
|
+
expect(events[0]).toMatchObject({ kind: "db", service: "db", query: "SELECT * FROM missing_table",
|
|
208
|
+
error: expect.stringContaining("no such table") });
|
|
209
|
+
} finally {
|
|
210
|
+
await helper.client.close!();
|
|
211
|
+
}
|
|
212
|
+
});
|
|
213
|
+
|
|
178
214
|
test("setup/eval unwrap annotated returns without recording", async () => {
|
|
179
215
|
const scope = createInstrumentationScope(false);
|
|
180
216
|
const helper = trackHelpers("service", "mail", { latest: () => annotate({subject: "Invoice"}, "email") });
|
package/src/helper-tracking.ts
CHANGED
|
@@ -93,9 +93,11 @@ function invokeHelper(
|
|
|
93
93
|
};
|
|
94
94
|
let result: unknown;
|
|
95
95
|
try {
|
|
96
|
+
// Reading a callable proxy's `.apply` can bind directly to its raw client
|
|
97
|
+
// and bypass instrumentation (SQL does this). Invoke the proxy itself.
|
|
96
98
|
result = recording.run(() => kind === "fake"
|
|
97
|
-
? runUninstrumented(() =>
|
|
98
|
-
:
|
|
99
|
+
? runUninstrumented(() => Reflect.apply(fn, receiver, args))
|
|
100
|
+
: Reflect.apply(fn, receiver, args));
|
|
99
101
|
} catch (error) {
|
|
100
102
|
return fail(error);
|
|
101
103
|
}
|
package/src/index.ts
CHANGED
|
@@ -2377,18 +2377,26 @@ export interface Project<
|
|
|
2377
2377
|
* appear in the timeline.
|
|
2378
2378
|
*/
|
|
2379
2379
|
setup?: ProjectSetupFn<S, F>;
|
|
2380
|
+
/**
|
|
2381
|
+
* Prepare a new interactive environment, after normal setup and the shared
|
|
2382
|
+
* warm-template snapshot. Runs on both cold starts and warm restores from
|
|
2383
|
+
* the dashboard or `spectest env start`, before the environment is ready.
|
|
2384
|
+
* Never runs for tests, snapshot forks, or resuming a stopped environment.
|
|
2385
|
+
* Uses the same context as `setup`; throw to fail the start.
|
|
2386
|
+
*/
|
|
2387
|
+
setupInteractive?: ProjectSetupFn<S, F>;
|
|
2380
2388
|
tests?: TestSuite<S, F>;
|
|
2381
2389
|
}
|
|
2382
2390
|
|
|
2383
2391
|
/**
|
|
2384
|
-
* Context handed to the project-level `setup`
|
|
2392
|
+
* Context handed to the project-level `setup` and `setupInteractive` hooks: the full
|
|
2385
2393
|
* {@link SpectestContext}, minus only what a timeline gives a test
|
|
2386
2394
|
* (testName, parent, browser/mobile/terminal). Everything a test can do
|
|
2387
2395
|
* to the environment — exec, fetch, `svc`/`fakes` helpers, `poll`,
|
|
2388
2396
|
* `dnsName`, `certificate`, `startService`, and project-file access via
|
|
2389
|
-
* `projectRoot`/`readProjectFile` — is available here too
|
|
2390
|
-
*
|
|
2391
|
-
*
|
|
2397
|
+
* `projectRoot`/`readProjectFile` — is available here too. Normal `setup`
|
|
2398
|
+
* state is captured into the shared warm template; `setupInteractive`
|
|
2399
|
+
* state belongs only to the new interactive environment.
|
|
2392
2400
|
*/
|
|
2393
2401
|
export interface ProjectSetupContext<
|
|
2394
2402
|
S extends ServicesMap = ServicesMap,
|
|
@@ -2437,6 +2445,8 @@ export interface ProjectOpts<
|
|
|
2437
2445
|
> {
|
|
2438
2446
|
/** Optional project-level setup. See `Project.setup`. */
|
|
2439
2447
|
setup?: ProjectSetupFn<S, F>;
|
|
2448
|
+
/** Optional interactive-only setup. See `Project.setupInteractive`. */
|
|
2449
|
+
setupInteractive?: ProjectSetupFn<S, F>;
|
|
2440
2450
|
/** Test cases for this project's suite. */
|
|
2441
2451
|
tests?: TestCase<unknown, S, F>[];
|
|
2442
2452
|
}
|
|
@@ -2501,7 +2511,7 @@ export interface DefinedEnvironment<
|
|
|
2501
2511
|
): TestGroup<P, S, F>;
|
|
2502
2512
|
/** Bundle this environment with a test suite into the project default
|
|
2503
2513
|
* export. Pass tests as a plain array for the common case, or an
|
|
2504
|
-
* options bag to attach
|
|
2514
|
+
* options bag to attach `setup` or `setupInteractive`. (Fakes are declared on
|
|
2505
2515
|
* `defineEnvironment`, not here.) */
|
|
2506
2516
|
project(tests?: TestCase<unknown, S, F>[]): Project<S, F>;
|
|
2507
2517
|
project(opts: ProjectOpts<S, F>): Project<S, F>;
|
|
@@ -2653,6 +2663,7 @@ export function defineEnvironment<
|
|
|
2653
2663
|
: (arg ?? {});
|
|
2654
2664
|
const proj: Project<S, F> = { environment: config };
|
|
2655
2665
|
if (opts.setup) proj.setup = opts.setup;
|
|
2666
|
+
if (opts.setupInteractive) proj.setupInteractive = opts.setupInteractive;
|
|
2656
2667
|
if (fakes) proj.fakes = fakes;
|
|
2657
2668
|
if (opts.tests && opts.tests.length > 0) {
|
|
2658
2669
|
// Explicit suite (single-file / legacy layout): the tests are listed
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
|
|
6
|
+
// The daemon owns globals and snapshot-resident state. Use a fresh process,
|
|
7
|
+
// just as each freshly built environment has its own harness.
|
|
8
|
+
for (const withHook of [false, true]) {
|
|
9
|
+
test(`interactive setup: ${withHook ? "context, failure, exclusion and one-shot state" : "optional hook"}`, async () => {
|
|
10
|
+
const root = await mkdtemp(join(tmpdir(), "spectest-interactive-setup-"));
|
|
11
|
+
try {
|
|
12
|
+
await mkdir(join(root, "spectest"));
|
|
13
|
+
await mkdir(join(root, "bin"));
|
|
14
|
+
await writeFile(join(root, "bin/docker"), '#!/bin/sh\nprintf "%s\\n" "$*"\n', { mode: 0o755 });
|
|
15
|
+
const entry = join(root, "spectest/index.ts");
|
|
16
|
+
await writeFile(entry, `
|
|
17
|
+
import { defineEnvironment } from ${JSON.stringify(new URL("./index.ts", import.meta.url).href)};
|
|
18
|
+
export const calls = [];
|
|
19
|
+
const env = defineEnvironment({ name: "seed", services: {
|
|
20
|
+
app: { image: { type: "registry", reference: "busybox" },
|
|
21
|
+
helpers: () => ({ label: "typed helper" }) },
|
|
22
|
+
}});
|
|
23
|
+
export default env.project({
|
|
24
|
+
setup: () => { calls.push("baseline"); },
|
|
25
|
+
${withHook ? `setupInteractive: async (ctx) => {
|
|
26
|
+
calls.push("interactive");
|
|
27
|
+
console.log("seeding demo data");
|
|
28
|
+
await globalThis.seedGate;
|
|
29
|
+
if (globalThis.failSeed) throw new Error("seed rejected");
|
|
30
|
+
const response = await ctx.fetch("data:application/json,%7B%22ok%22%3Atrue%7D");
|
|
31
|
+
if (response.status.unwrap() !== 200) throw new Error("fetch not wrapped");
|
|
32
|
+
if (ctx.svc.app.label !== "typed helper") throw new Error("missing helpers");
|
|
33
|
+
if ("browser" in ctx || "testName" in ctx) throw new Error("test context leaked");
|
|
34
|
+
const result = await ctx.exec("app", ["seed", "demo"]);
|
|
35
|
+
if (typeof result.exitCode !== "number" || !result.stdout.includes("seed demo")) {
|
|
36
|
+
throw new Error("exec result not plain: " + JSON.stringify(result));
|
|
37
|
+
}
|
|
38
|
+
},` : ""}
|
|
39
|
+
});
|
|
40
|
+
`);
|
|
41
|
+
const script = `
|
|
42
|
+
import { strict as assert } from "node:assert";
|
|
43
|
+
import { METHODS } from ${JSON.stringify(new URL("./daemon.ts", import.meta.url).href)};
|
|
44
|
+
import { calls } from ${JSON.stringify(entry)};
|
|
45
|
+
const nativeFetch = globalThis.fetch;
|
|
46
|
+
await METHODS.load({});
|
|
47
|
+
assert.equal((await METHODS.health({})).setupInteractive, ${withHook});
|
|
48
|
+
await METHODS.projectSetup({});
|
|
49
|
+
assert.deepEqual(calls, ["baseline"]);
|
|
50
|
+
${withHook ? `
|
|
51
|
+
let release;
|
|
52
|
+
globalThis.seedGate = new Promise(r => { release = r; });
|
|
53
|
+
globalThis.failSeed = true;
|
|
54
|
+
const failed = METHODS.interactiveSetup({});
|
|
55
|
+
await assert.rejects(METHODS.interactiveSetup({}), /already in progress/);
|
|
56
|
+
await assert.rejects(METHODS.projectSetup({}), /already in progress/);
|
|
57
|
+
release();
|
|
58
|
+
await assert.rejects(failed, /seed rejected/);
|
|
59
|
+
assert.equal(globalThis.fetch, nativeFetch);
|
|
60
|
+
globalThis.failSeed = false;
|
|
61
|
+
assert.equal((await METHODS.interactiveSetup({})).ran, true);
|
|
62
|
+
assert.deepEqual(calls, ["baseline", "interactive", "interactive"]);
|
|
63
|
+
// A repeated request (or a resumed seeded snapshot) cannot reseed.
|
|
64
|
+
assert.equal((await METHODS.interactiveSetup({})).ran, false);
|
|
65
|
+
assert.deepEqual(calls, ["baseline", "interactive", "interactive"]);
|
|
66
|
+
const logs = await METHODS.envLogs({});
|
|
67
|
+
assert.ok(logs.bootLog.includes("seeding demo data"));
|
|
68
|
+
` : `
|
|
69
|
+
assert.equal((await METHODS.interactiveSetup({})).ran, false);
|
|
70
|
+
assert.deepEqual(calls, ["baseline"]);
|
|
71
|
+
`}
|
|
72
|
+
assert.equal(globalThis.fetch, nativeFetch);
|
|
73
|
+
// envLogs' subprocess timeout keeps a timer alive after it returns;
|
|
74
|
+
// this fixture tests hook behavior, not daemon process lifetime.
|
|
75
|
+
process.exit(0);
|
|
76
|
+
`;
|
|
77
|
+
const child = Bun.spawn([process.execPath, "--eval", script], {
|
|
78
|
+
env: { ...process.env, SPECTEST_APP_DIR: root, PATH: `${root}/bin:${process.env.PATH}` },
|
|
79
|
+
stdout: "pipe", stderr: "pipe",
|
|
80
|
+
});
|
|
81
|
+
const [code, stdout, stderr] = await Promise.all([
|
|
82
|
+
child.exited, new Response(child.stdout).text(), new Response(child.stderr).text(),
|
|
83
|
+
]);
|
|
84
|
+
expect(code, stdout + stderr).toBe(0);
|
|
85
|
+
} finally {
|
|
86
|
+
await rm(root, { recursive: true, force: true });
|
|
87
|
+
}
|
|
88
|
+
}, 20_000);
|
|
89
|
+
}
|