@specific.dev/spectest 0.8.0 → 0.9.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/package.json +1 -1
- package/src/daemon.ts +35 -8
- package/src/index.ts +39 -3
- package/src/recorder.ts +6 -0
package/package.json
CHANGED
package/src/daemon.ts
CHANGED
|
@@ -3016,11 +3016,27 @@ function installFetchWrapper(): () => void {
|
|
|
3016
3016
|
};
|
|
3017
3017
|
}
|
|
3018
3018
|
|
|
3019
|
-
|
|
3019
|
+
/** Build the `docker exec` argv for a service command. An optional `cwd`
|
|
3020
|
+
* becomes `-w <cwd>` (the working-directory option of `ctx.exec`), so the
|
|
3021
|
+
* command runs from that directory without it being baked into the command
|
|
3022
|
+
* string. Used by both the buffered and streaming variants so they stay in
|
|
3023
|
+
* lockstep. */
|
|
3024
|
+
function dockerExecArgs(service: string, command: string, cwd?: string): string[] {
|
|
3025
|
+
const args = ["exec"];
|
|
3026
|
+
if (cwd) args.push("-w", cwd);
|
|
3027
|
+
args.push(service, "sh", "-lc", command);
|
|
3028
|
+
return args;
|
|
3029
|
+
}
|
|
3030
|
+
|
|
3031
|
+
function execInService(
|
|
3032
|
+
service: string,
|
|
3033
|
+
command: string,
|
|
3034
|
+
cwd?: string,
|
|
3035
|
+
): Promise<ExecResult> {
|
|
3020
3036
|
return new Promise((resolve) => {
|
|
3021
3037
|
execFile(
|
|
3022
3038
|
"docker",
|
|
3023
|
-
|
|
3039
|
+
dockerExecArgs(service, command, cwd),
|
|
3024
3040
|
{ maxBuffer: 16 * 1024 * 1024 },
|
|
3025
3041
|
(err, stdout, stderr) => {
|
|
3026
3042
|
const exitCode =
|
|
@@ -3046,8 +3062,9 @@ function execInService(service: string, command: string): Promise<ExecResult> {
|
|
|
3046
3062
|
async function execInServiceWrapped(
|
|
3047
3063
|
service: string,
|
|
3048
3064
|
command: string,
|
|
3065
|
+
opts?: { cwd?: string },
|
|
3049
3066
|
): Promise<Wrapped<ExecResult>> {
|
|
3050
|
-
const res = await execInService(service, command);
|
|
3067
|
+
const res = await execInService(service, command, opts?.cwd);
|
|
3051
3068
|
return wrap(res, undefined) as unknown as Wrapped<ExecResult>;
|
|
3052
3069
|
}
|
|
3053
3070
|
|
|
@@ -3074,9 +3091,10 @@ function execInServiceStreaming(
|
|
|
3074
3091
|
service: string,
|
|
3075
3092
|
command: string,
|
|
3076
3093
|
onChunk: (stream: "stdout" | "stderr", data: string) => void,
|
|
3094
|
+
cwd?: string,
|
|
3077
3095
|
): Promise<ExecResult> {
|
|
3078
3096
|
return new Promise((resolve) => {
|
|
3079
|
-
const child = spawn("docker",
|
|
3097
|
+
const child = spawn("docker", dockerExecArgs(service, command, cwd), {
|
|
3080
3098
|
stdio: ["ignore", "pipe", "pipe"],
|
|
3081
3099
|
});
|
|
3082
3100
|
const acc = { stdout: "", stderr: "" };
|
|
@@ -3267,8 +3285,13 @@ async function runOne(testCase: TestCase<unknown>): Promise<RunResult> {
|
|
|
3267
3285
|
// or animated output replays with real timing in the web UI. The
|
|
3268
3286
|
// frames are presentation-only; the ExecResult (and any assertions on
|
|
3269
3287
|
// it) still sees the plain separated stdout/stderr.
|
|
3270
|
-
const recordedExec = async (
|
|
3288
|
+
const recordedExec = async (
|
|
3289
|
+
service: string,
|
|
3290
|
+
command: string,
|
|
3291
|
+
opts?: { cwd?: string },
|
|
3292
|
+
): Promise<ExecResult> => {
|
|
3271
3293
|
const t = Date.now();
|
|
3294
|
+
const cwd = opts?.cwd;
|
|
3272
3295
|
const resv = reserveEvent();
|
|
3273
3296
|
const session = newTerminalSession(
|
|
3274
3297
|
start,
|
|
@@ -3280,8 +3303,11 @@ async function runOne(testCase: TestCase<unknown>): Promise<RunResult> {
|
|
|
3280
3303
|
);
|
|
3281
3304
|
terminalSessions.push(session.record);
|
|
3282
3305
|
// Synthetic prompt frame so the replay is self-describing — the
|
|
3283
|
-
// program's own output starts on the next line, like a real shell.
|
|
3284
|
-
|
|
3306
|
+
// program's own output starts on the next line, like a real shell. A
|
|
3307
|
+
// working directory rides in the prompt sigil (`svc:/dir $`) the way a
|
|
3308
|
+
// real shell prompt shows it, so the recording stays self-describing.
|
|
3309
|
+
const sigil = cwd ? `${service}:${cwd}` : service;
|
|
3310
|
+
session.pushFrame(0, `\x1b[32m${sigil} $\x1b[0m \x1b[1m${command}\x1b[0m\r\n`);
|
|
3285
3311
|
let frameBytes = 0;
|
|
3286
3312
|
let frameCapped = false;
|
|
3287
3313
|
const res = await execInServiceStreaming(service, command, (_stream, data) => {
|
|
@@ -3302,13 +3328,14 @@ async function runOne(testCase: TestCase<unknown>): Promise<RunResult> {
|
|
|
3302
3328
|
// `\r\n` too keeps a CR|LF split across chunk boundaries
|
|
3303
3329
|
// harmless (`\r\r\n` renders identically).
|
|
3304
3330
|
session.pushFrame((Date.now() - t) / 1000, data.replace(/\r?\n/g, "\r\n"));
|
|
3305
|
-
});
|
|
3331
|
+
}, cwd);
|
|
3306
3332
|
session.markClosed();
|
|
3307
3333
|
const stdout = truncateUtf8(res.stdout);
|
|
3308
3334
|
const stderr = truncateUtf8(res.stderr);
|
|
3309
3335
|
const seq = recordExec({
|
|
3310
3336
|
service,
|
|
3311
3337
|
command,
|
|
3338
|
+
cwd,
|
|
3312
3339
|
exitCode: res.exitCode,
|
|
3313
3340
|
stdout: stdout.value,
|
|
3314
3341
|
stdoutTruncated: stdout.truncated,
|
package/src/index.ts
CHANGED
|
@@ -572,8 +572,17 @@ export interface TestContext<
|
|
|
572
572
|
* program sees plain pipes (`isatty` false), so stdout/stderr stay
|
|
573
573
|
* byte-identical to what `exec` always returned and TTY-gated
|
|
574
574
|
* spinners/colour won't be emitted — reach for `terminal` when the
|
|
575
|
-
* CLI needs to believe it's on a TTY.
|
|
576
|
-
|
|
575
|
+
* CLI needs to believe it's on a TTY.
|
|
576
|
+
*
|
|
577
|
+
* Pass `{ cwd }` to run the command from a working directory instead of
|
|
578
|
+
* prefixing it with `cd <dir> && ` — the cwd is kept off the command
|
|
579
|
+
* string, so the timeline sidebar shows just the command and the
|
|
580
|
+
* directory surfaces in the detail view. */
|
|
581
|
+
exec(
|
|
582
|
+
service: string,
|
|
583
|
+
command: string,
|
|
584
|
+
opts?: ExecOpts,
|
|
585
|
+
): Promise<Wrapped<ExecResult>>;
|
|
577
586
|
/**
|
|
578
587
|
* Run a command inside a service container under a PTY and record the
|
|
579
588
|
* full terminal session as an asciicast for replay in the web UI.
|
|
@@ -756,6 +765,20 @@ export interface ExecResult {
|
|
|
756
765
|
exitCode: number;
|
|
757
766
|
}
|
|
758
767
|
|
|
768
|
+
/** Options for {@link TestContext.exec}. */
|
|
769
|
+
export interface ExecOpts {
|
|
770
|
+
/**
|
|
771
|
+
* Working directory inside the container to run the command from.
|
|
772
|
+
* Equivalent to prefixing the command with `cd <cwd> && `, but the
|
|
773
|
+
* directory is kept off the command string: the recorded step's
|
|
774
|
+
* sidebar summary shows just the command, while the working directory
|
|
775
|
+
* is surfaced in the detail view (the prompt line of the captured
|
|
776
|
+
* terminal and the step's panel header). Implemented as `docker exec
|
|
777
|
+
* -w <cwd>`, so a relative path resolves against the image's WORKDIR.
|
|
778
|
+
*/
|
|
779
|
+
cwd?: string;
|
|
780
|
+
}
|
|
781
|
+
|
|
759
782
|
export interface TestSuite<
|
|
760
783
|
S extends ServicesMap = ServicesMap,
|
|
761
784
|
F extends FakesMap = FakesMap,
|
|
@@ -997,7 +1020,11 @@ export interface ProjectSetupContext<
|
|
|
997
1020
|
F extends FakesMap = FakesMap,
|
|
998
1021
|
> {
|
|
999
1022
|
fetch: SpectestFetch;
|
|
1000
|
-
exec(
|
|
1023
|
+
exec(
|
|
1024
|
+
service: string,
|
|
1025
|
+
command: string,
|
|
1026
|
+
opts?: ExecOpts,
|
|
1027
|
+
): Promise<Wrapped<ExecResult>>;
|
|
1001
1028
|
readonly svc: ServiceHandlesFor<S>;
|
|
1002
1029
|
/** Same surface tests see — fakes are already up by setup time. Typed
|
|
1003
1030
|
* against the declared fakes map (see {@link TestContext.fakes}). */
|
|
@@ -1341,6 +1368,15 @@ interface Transforms {
|
|
|
1341
1368
|
/** URL-decode the value, mirroring `decodeURIComponent(x)`. Chains after
|
|
1342
1369
|
* `base64Decoded()` for base64-then-URL-encoded values. */
|
|
1343
1370
|
urlDecoded(): Expectation;
|
|
1371
|
+
/** Parse the value as JSON (`JSON.parse(x)`) and assert on the result with the
|
|
1372
|
+
* full matcher vocabulary — `toEqual` against the decoded object/array,
|
|
1373
|
+
* `toContain`/`toHaveLength` against a decoded array. Throws-as-failed-assertion
|
|
1374
|
+
* if the value isn't a string or isn't valid JSON. Chains after other
|
|
1375
|
+
* transforms (e.g. `base64Decoded().jsonDecoded()` for a base64-wrapped JSON
|
|
1376
|
+
* payload). The optional type parameter `T` annotates the decoded shape at the
|
|
1377
|
+
* call site (`jsonDecoded<User>()`) — it casts the parsed value, but, like the
|
|
1378
|
+
* matchers, is not enforced at runtime. */
|
|
1379
|
+
jsonDecoded<T = unknown>(): Expectation;
|
|
1344
1380
|
/**
|
|
1345
1381
|
* Generic escape hatch: apply an arbitrary `fn` to the raw value and assert on
|
|
1346
1382
|
* the result, keeping provenance. `label` is a plain word (`"json"`,
|
package/src/recorder.ts
CHANGED
|
@@ -47,6 +47,12 @@ export interface ExecEvent extends BaseEvent {
|
|
|
47
47
|
kind: "exec";
|
|
48
48
|
service: string;
|
|
49
49
|
command: string;
|
|
50
|
+
/**
|
|
51
|
+
* Working directory the command ran from (`ctx.exec(svc, cmd, { cwd })`),
|
|
52
|
+
* if any. Kept off `command` so the sidebar shows just the command; the
|
|
53
|
+
* detail view surfaces it. Absent when the call used no `cwd`.
|
|
54
|
+
*/
|
|
55
|
+
cwd?: string;
|
|
50
56
|
exitCode: number;
|
|
51
57
|
/** stdout truncated to OUTPUT_SNIPPET_BYTES. */
|
|
52
58
|
stdout: string;
|