@jterrazz/test 12.0.2 → 12.2.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 +18 -2
- package/dist/checker.js +73 -7
- package/dist/index.d.ts +179 -16
- package/dist/index.js +60 -1141
- package/dist/literate.js +1734 -0
- package/dist/match.js +193 -1
- package/dist/oxlint.cjs +129 -15
- package/dist/oxlint.js +129 -15
- package/dist/queue.js +3 -295
- package/dist/vitest.d.ts +41 -0
- package/dist/vitest.js +68 -0
- package/package.json +5 -1
package/dist/index.js
CHANGED
|
@@ -1,15 +1,13 @@
|
|
|
1
1
|
import { i as match, n as Matcher, t as CaptureScope } from "./match.js";
|
|
2
|
-
import { a as
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
2
|
+
import { A as mergeTextPreservingPlaceholders, C as formatDirectoryDiff, D as formatTableDiff, E as formatStdoutDiff, F as textEquals, M as renderExpected, N as structuralEquals, P as structuralSubset, S as walkDirectory, T as formatStartupReport, _ as text, a as UPDATE_HINT, b as DirectoryAccessor, c as CliResult, d as findContainersByLabel, f as inspectContainer, g as TextAccessor, h as JsonAccessor, i as copyPlan, k as mergePreservingPlaceholders, l as BaseResult, m as TableAccessor, n as runLiterateSpec, o as shouldUpdateSnapshots, p as removeContainers, r as ServeAdapter, s as ContainerAccessor, u as validateDatabaseOption, v as getCallerDir, w as formatResponseDiff, x as diffDirectories, y as FilesystemAccessor } from "./literate.js";
|
|
3
|
+
import { a as isContract, i as defineContracts, n as contractsOf, o as isContracts, r as defineContract, t as ContractQueue } from "./queue.js";
|
|
4
|
+
import { closeSync, copyFileSync, cpSync, existsSync, mkdirSync, mkdtempSync, openSync, readFileSync, readSync, readdirSync, rmSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
5
6
|
import { parse } from "yaml";
|
|
6
7
|
import { execFile, execSync, spawn, spawnSync } from "node:child_process";
|
|
7
8
|
import { Client } from "pg";
|
|
8
|
-
import { readdir } from "node:fs/promises";
|
|
9
|
-
import { fileURLToPath } from "node:url";
|
|
10
9
|
import { tmpdir } from "node:os";
|
|
11
10
|
import { createServer } from "node:http";
|
|
12
|
-
import { createServer as createServer$1 } from "node:net";
|
|
13
11
|
import { promisify } from "node:util";
|
|
14
12
|
import Database from "better-sqlite3";
|
|
15
13
|
import { createHash } from "node:crypto";
|
|
@@ -522,497 +520,6 @@ function isJsonLike(text) {
|
|
|
522
520
|
}
|
|
523
521
|
}
|
|
524
522
|
//#endregion
|
|
525
|
-
//#region src/core/specification/shared/reporter.ts
|
|
526
|
-
const GREEN = "\x1B[32m";
|
|
527
|
-
const RED = "\x1B[31m";
|
|
528
|
-
const DIM = "\x1B[2m";
|
|
529
|
-
const BOLD = "\x1B[1m";
|
|
530
|
-
const RESET = "\x1B[0m";
|
|
531
|
-
const BG_CYAN = "\x1B[46m";
|
|
532
|
-
const BLACK = "\x1B[30m";
|
|
533
|
-
const CHECK = "✓";
|
|
534
|
-
const CROSS = "×";
|
|
535
|
-
const ARROW = "→";
|
|
536
|
-
function formatStartupReport(mode, services, app) {
|
|
537
|
-
const lines = [];
|
|
538
|
-
lines.push("");
|
|
539
|
-
lines.push(`${BG_CYAN}${BLACK}${BOLD} INFRA ${RESET} Starting infrastructure...`);
|
|
540
|
-
lines.push("");
|
|
541
|
-
for (const service of services) if (service.error) {
|
|
542
|
-
lines.push(` ${RED}${CROSS}${RESET} ${service.type} (${service.name}) ${RED}${service.error}${RESET} ${DIM}${service.durationMs}ms${RESET}`);
|
|
543
|
-
if (service.logs) {
|
|
544
|
-
const logLines = service.logs.trim().split("\n").slice(-10);
|
|
545
|
-
for (const logLine of logLines) lines.push(` ${DIM}${logLine}${RESET}`);
|
|
546
|
-
}
|
|
547
|
-
} else {
|
|
548
|
-
const conn = service.connectionString ? `${DIM}${service.connectionString}${RESET}` : "";
|
|
549
|
-
lines.push(` ${GREEN}${CHECK}${RESET} ${service.type} (${service.name}) ${conn} ${DIM}${service.durationMs}ms${RESET}`);
|
|
550
|
-
}
|
|
551
|
-
if (app) {
|
|
552
|
-
lines.push("");
|
|
553
|
-
if (app.type === "in-process") lines.push(` ${DIM}${ARROW} app: in-process (Hono)${RESET}`);
|
|
554
|
-
else lines.push(` ${DIM}${ARROW} app: ${app.url}${RESET}`);
|
|
555
|
-
}
|
|
556
|
-
lines.push("");
|
|
557
|
-
return lines.join("\n");
|
|
558
|
-
}
|
|
559
|
-
function formatTableDiff(table, columns, expected, actual) {
|
|
560
|
-
const lines = [];
|
|
561
|
-
lines.push(`Table "${table}" mismatch`);
|
|
562
|
-
lines.push(`${DIM} query: ${columns.join(", ")}${RESET}`);
|
|
563
|
-
lines.push(`${DIM} expected: ${rowLabel(expected.length)}${RESET}`);
|
|
564
|
-
lines.push(`${DIM} received: ${rowLabel(actual.length)}${RESET}`);
|
|
565
|
-
lines.push("");
|
|
566
|
-
lines.push(`${GREEN}- Expected${RESET}`);
|
|
567
|
-
lines.push(`${RED}+ Received${RESET}`);
|
|
568
|
-
lines.push("");
|
|
569
|
-
const header = columns.join(" | ");
|
|
570
|
-
lines.push(`${DIM} ${header}${RESET}`);
|
|
571
|
-
const maxRows = Math.max(expected.length, actual.length);
|
|
572
|
-
for (let i = 0; i < maxRows; i++) {
|
|
573
|
-
const exp = expected[i];
|
|
574
|
-
const act = actual[i];
|
|
575
|
-
if (exp && !act) lines.push(`${GREEN}- ${formatRow(exp)}${RESET}`);
|
|
576
|
-
else if (!exp && act) lines.push(`${RED}+ ${formatRow(act)}${RESET}`);
|
|
577
|
-
else if (exp && act) if (JSON.stringify(exp) === JSON.stringify(act)) lines.push(` ${formatRow(act)}`);
|
|
578
|
-
else {
|
|
579
|
-
lines.push(`${GREEN}- ${formatRow(exp)}${RESET}`);
|
|
580
|
-
lines.push(`${RED}+ ${formatRow(act)}${RESET}`);
|
|
581
|
-
}
|
|
582
|
-
}
|
|
583
|
-
if (expected.length === 0 && actual.length === 0) lines.push(` (empty)`);
|
|
584
|
-
return lines.join("\n");
|
|
585
|
-
}
|
|
586
|
-
function formatResponseDiff(file, expected, actual) {
|
|
587
|
-
const lines = [];
|
|
588
|
-
lines.push(`Response mismatch (${file})`);
|
|
589
|
-
lines.push("");
|
|
590
|
-
lines.push(`${GREEN}- Expected${RESET}`);
|
|
591
|
-
lines.push(`${RED}+ Received${RESET}`);
|
|
592
|
-
lines.push("");
|
|
593
|
-
const expectedLines = JSON.stringify(expected, null, 2).split("\n");
|
|
594
|
-
const actualLines = JSON.stringify(actual, null, 2).split("\n");
|
|
595
|
-
const maxLines = Math.max(expectedLines.length, actualLines.length);
|
|
596
|
-
for (let i = 0; i < maxLines; i++) {
|
|
597
|
-
const exp = expectedLines[i];
|
|
598
|
-
const act = actualLines[i];
|
|
599
|
-
if (exp === act) lines.push(` ${exp}`);
|
|
600
|
-
else {
|
|
601
|
-
if (exp !== void 0) lines.push(`${GREEN}- ${exp}${RESET}`);
|
|
602
|
-
if (act !== void 0) lines.push(`${RED}+ ${act}${RESET}`);
|
|
603
|
-
}
|
|
604
|
-
}
|
|
605
|
-
return lines.join("\n");
|
|
606
|
-
}
|
|
607
|
-
function formatStdoutDiff(file, expected, actual) {
|
|
608
|
-
const lines = [];
|
|
609
|
-
lines.push(`Output mismatch (${file})`);
|
|
610
|
-
lines.push("");
|
|
611
|
-
lines.push(`${GREEN}- Expected${RESET}`);
|
|
612
|
-
lines.push(`${RED}+ Received${RESET}`);
|
|
613
|
-
lines.push("");
|
|
614
|
-
const expectedLines = expected.split("\n");
|
|
615
|
-
const actualLines = actual.split("\n");
|
|
616
|
-
const maxLines = Math.max(expectedLines.length, actualLines.length);
|
|
617
|
-
for (let i = 0; i < maxLines; i++) {
|
|
618
|
-
const exp = expectedLines[i];
|
|
619
|
-
const act = actualLines[i];
|
|
620
|
-
if (exp === act) lines.push(` ${exp}`);
|
|
621
|
-
else {
|
|
622
|
-
if (exp !== void 0) lines.push(`${GREEN}- ${exp}${RESET}`);
|
|
623
|
-
if (act !== void 0) lines.push(`${RED}+ ${act}${RESET}`);
|
|
624
|
-
}
|
|
625
|
-
}
|
|
626
|
-
return lines.join("\n");
|
|
627
|
-
}
|
|
628
|
-
function formatDirectoryDiff(fixtureName, diff, hint) {
|
|
629
|
-
const lines = [];
|
|
630
|
-
const total = diff.added.length + diff.removed.length + diff.changed.length;
|
|
631
|
-
lines.push(`Directory mismatch: ${BOLD}${fixtureName}${RESET}`);
|
|
632
|
-
lines.push(`${DIM} ${total} difference${total === 1 ? "" : "s"}: ${diff.added.length} added, ${diff.removed.length} removed, ${diff.changed.length} changed${RESET}`);
|
|
633
|
-
lines.push("");
|
|
634
|
-
lines.push(`${GREEN}- Expected (fixture)${RESET}`);
|
|
635
|
-
lines.push(`${RED}+ Received (generated)${RESET}`);
|
|
636
|
-
lines.push("");
|
|
637
|
-
for (const path of diff.added) lines.push(`${RED}+ added ${path}${RESET} ${DIM}(not in fixture)${RESET}`);
|
|
638
|
-
for (const path of diff.removed) lines.push(`${GREEN}- removed ${path}${RESET} ${DIM}(in fixture, not generated)${RESET}`);
|
|
639
|
-
for (const { path, expected, actual } of diff.changed) {
|
|
640
|
-
const expectedLines = expected.split("\n");
|
|
641
|
-
const actualLines = actual.split("\n");
|
|
642
|
-
const changedCount = countLineDifferences(expectedLines, actualLines);
|
|
643
|
-
lines.push(`${BOLD}~ changed ${path}${RESET} ${DIM}(${changedCount} line${changedCount === 1 ? "" : "s"} differ)${RESET}`);
|
|
644
|
-
let shown = 0;
|
|
645
|
-
const maxShown = 5;
|
|
646
|
-
const maxLines = Math.max(expectedLines.length, actualLines.length);
|
|
647
|
-
for (let i = 0; i < maxLines && shown < maxShown; i++) {
|
|
648
|
-
const exp = expectedLines[i];
|
|
649
|
-
const act = actualLines[i];
|
|
650
|
-
if (exp !== act) {
|
|
651
|
-
lines.push(`${DIM} line ${i + 1}:${RESET}`);
|
|
652
|
-
if (exp !== void 0) lines.push(` ${GREEN}- ${exp}${RESET}`);
|
|
653
|
-
if (act !== void 0) lines.push(` ${RED}+ ${act}${RESET}`);
|
|
654
|
-
shown++;
|
|
655
|
-
}
|
|
656
|
-
}
|
|
657
|
-
if (changedCount > maxShown) lines.push(` ${DIM}... ${changedCount - maxShown} more line(s)${RESET}`);
|
|
658
|
-
}
|
|
659
|
-
lines.push("");
|
|
660
|
-
lines.push(`${DIM}${hint}${RESET}`);
|
|
661
|
-
return lines.join("\n");
|
|
662
|
-
}
|
|
663
|
-
function countLineDifferences(expected, actual) {
|
|
664
|
-
let count = 0;
|
|
665
|
-
const max = Math.max(expected.length, actual.length);
|
|
666
|
-
for (let i = 0; i < max; i++) if (expected[i] !== actual[i]) count++;
|
|
667
|
-
return count;
|
|
668
|
-
}
|
|
669
|
-
function rowLabel(n) {
|
|
670
|
-
return n === 1 ? "1 row" : `${n} rows`;
|
|
671
|
-
}
|
|
672
|
-
function formatRow(row) {
|
|
673
|
-
return row.map((v) => String(v ?? "null")).join(" | ");
|
|
674
|
-
}
|
|
675
|
-
//#endregion
|
|
676
|
-
//#region src/core/specification/shared/result/directory.ts
|
|
677
|
-
const DEFAULT_IGNORES = [
|
|
678
|
-
".git",
|
|
679
|
-
".DS_Store",
|
|
680
|
-
"node_modules",
|
|
681
|
-
".next",
|
|
682
|
-
"dist",
|
|
683
|
-
".turbo",
|
|
684
|
-
".cache"
|
|
685
|
-
];
|
|
686
|
-
/**
|
|
687
|
-
* Recursively walk a directory, returning sorted relative paths of files only.
|
|
688
|
-
*/
|
|
689
|
-
async function walkDirectory(root, options = {}) {
|
|
690
|
-
const ignores = /* @__PURE__ */ new Set([...DEFAULT_IGNORES, ...options.ignore ?? []]);
|
|
691
|
-
const out = [];
|
|
692
|
-
async function walk(current) {
|
|
693
|
-
let entries;
|
|
694
|
-
try {
|
|
695
|
-
entries = await readdir(current);
|
|
696
|
-
} catch {
|
|
697
|
-
return;
|
|
698
|
-
}
|
|
699
|
-
for (const entry of entries) {
|
|
700
|
-
if (ignores.has(entry)) continue;
|
|
701
|
-
const abs = resolve(current, entry);
|
|
702
|
-
const stat = statSync(abs);
|
|
703
|
-
if (stat.isDirectory()) await walk(abs);
|
|
704
|
-
else if (stat.isFile()) out.push(relative(root, abs).split(sep).join("/"));
|
|
705
|
-
}
|
|
706
|
-
}
|
|
707
|
-
await walk(root);
|
|
708
|
-
out.sort();
|
|
709
|
-
return out;
|
|
710
|
-
}
|
|
711
|
-
/**
|
|
712
|
-
* Compare two directory trees file-by-file. Fixture file contents honor the
|
|
713
|
-
* unified `{{token}}` grammar (CONVENTIONS D4) — captures are recorded in
|
|
714
|
-
* `scope` in sorted-path order.
|
|
715
|
-
*/
|
|
716
|
-
async function diffDirectories(expectedRoot, actualRoot, options = {}) {
|
|
717
|
-
const expectedFiles = await walkDirectory(expectedRoot, options);
|
|
718
|
-
const actualFiles = await walkDirectory(actualRoot, options);
|
|
719
|
-
const expectedSet = new Set(expectedFiles);
|
|
720
|
-
const actualSet = new Set(actualFiles);
|
|
721
|
-
const scope = options.scope ?? new CaptureScope();
|
|
722
|
-
const added = actualFiles.filter((f) => !expectedSet.has(f));
|
|
723
|
-
const removed = expectedFiles.filter((f) => !actualSet.has(f));
|
|
724
|
-
const changed = [];
|
|
725
|
-
for (const file of expectedFiles) {
|
|
726
|
-
if (!actualSet.has(file)) continue;
|
|
727
|
-
const expected = readFileSync(resolve(expectedRoot, file), "utf8");
|
|
728
|
-
const actual = readFileSync(resolve(actualRoot, file), "utf8");
|
|
729
|
-
if (!textEquals(expected, actual, scope)) changed.push({
|
|
730
|
-
actual,
|
|
731
|
-
expected,
|
|
732
|
-
path: file
|
|
733
|
-
});
|
|
734
|
-
}
|
|
735
|
-
return {
|
|
736
|
-
added,
|
|
737
|
-
changed,
|
|
738
|
-
removed
|
|
739
|
-
};
|
|
740
|
-
}
|
|
741
|
-
/**
|
|
742
|
-
* Read-only accessor for a directory produced by a spec action.
|
|
743
|
-
*
|
|
744
|
-
* Assertions go through `expect()` (async — they walk the disk):
|
|
745
|
-
* `await expect(result.directory('out')).toMatch('scaffold/out')`
|
|
746
|
-
* compares the tree against the fixture directory `expected/<name>/`.
|
|
747
|
-
*/
|
|
748
|
-
var DirectoryAccessor = class {
|
|
749
|
-
/** @internal Ref-capture scope shared by the current spec execution. */
|
|
750
|
-
captures;
|
|
751
|
-
/** @internal Absolute path of the directory under assertion. */
|
|
752
|
-
root;
|
|
753
|
-
/** @internal Test-file directory — fixture resolution root for matchers. */
|
|
754
|
-
testDir;
|
|
755
|
-
constructor(absPath, testDir, captures) {
|
|
756
|
-
this.root = absPath;
|
|
757
|
-
this.testDir = testDir;
|
|
758
|
-
this.captures = captures ?? new CaptureScope();
|
|
759
|
-
}
|
|
760
|
-
/** List all files (recursively) under the directory, sorted. */
|
|
761
|
-
async files(options = {}) {
|
|
762
|
-
return walkDirectory(this.root, options);
|
|
763
|
-
}
|
|
764
|
-
};
|
|
765
|
-
//#endregion
|
|
766
|
-
//#region src/core/specification/shared/result/filesystem.ts
|
|
767
|
-
/**
|
|
768
|
-
* Read-only accessor for the whole temporary working directory used by a
|
|
769
|
-
* command spec.
|
|
770
|
-
*
|
|
771
|
-
* Assertions go through `expect()` (async — they walk the disk):
|
|
772
|
-
* `await expect(result.filesystem).toMatch('scaffolded')` compares the
|
|
773
|
-
* tree against the fixture directory `expected/<name>/`.
|
|
774
|
-
*/
|
|
775
|
-
var FilesystemAccessor = class {
|
|
776
|
-
/** @internal Ref-capture scope shared by the current spec execution. */
|
|
777
|
-
captures;
|
|
778
|
-
/** The absolute path of the temporary working directory. */
|
|
779
|
-
cwd;
|
|
780
|
-
/** @internal Test-file directory — fixture resolution root for matchers. */
|
|
781
|
-
testDir;
|
|
782
|
-
constructor(cwd, testDir, captures) {
|
|
783
|
-
this.cwd = cwd;
|
|
784
|
-
this.testDir = testDir;
|
|
785
|
-
this.captures = captures ?? new CaptureScope();
|
|
786
|
-
}
|
|
787
|
-
/** List all files (recursively) under the working directory, sorted. */
|
|
788
|
-
async files(options = {}) {
|
|
789
|
-
return walkDirectory(this.cwd, options);
|
|
790
|
-
}
|
|
791
|
-
};
|
|
792
|
-
//#endregion
|
|
793
|
-
//#region src/core/specification/shared/caller.ts
|
|
794
|
-
/** Sibling module tests are callers, not internals (CONVENTIONS I2). */
|
|
795
|
-
const TEST_FILE = /\.test\.[cm]?[jt]s$/;
|
|
796
|
-
/** Where this very module sits inside the framework's SOURCE tree. */
|
|
797
|
-
const SOURCE_LOCATION = join("core", "specification", "shared");
|
|
798
|
-
/** The real path of `path`, or `path` itself when it cannot be resolved. */
|
|
799
|
-
function realPath(path) {
|
|
800
|
-
try {
|
|
801
|
-
return realpathSync(path);
|
|
802
|
-
} catch {
|
|
803
|
-
return path;
|
|
804
|
-
}
|
|
805
|
-
}
|
|
806
|
-
/**
|
|
807
|
-
* Directory of the running framework module itself. From the built package
|
|
808
|
-
* this is `<package root>/dist`, whatever the resolution path — including a
|
|
809
|
-
* `file:` link, where the real path carries neither `node_modules` nor
|
|
810
|
-
* `/src/…` and would otherwise be mistaken for a caller frame.
|
|
811
|
-
*/
|
|
812
|
-
const FRAMEWORK_DIR = realPath(dirname(fileURLToPath(import.meta.url)));
|
|
813
|
-
/**
|
|
814
|
-
* The directory holding the framework's OWN modules: `<package>/dist` for a
|
|
815
|
-
* built install (everything is bundled there), `<repo>/src` when the framework
|
|
816
|
-
* runs from source — this module's known location inside that tree is what
|
|
817
|
-
* tells the two apart.
|
|
818
|
-
*/
|
|
819
|
-
const FRAMEWORK_TREE = FRAMEWORK_DIR.endsWith(`${sep}${SOURCE_LOCATION}`) ? resolve(FRAMEWORK_DIR, "../../..") : FRAMEWORK_DIR;
|
|
820
|
-
/** Is `path` inside `directory` (strictly — a directory is not inside itself)? */
|
|
821
|
-
function isInside(directory, path) {
|
|
822
|
-
const rest = relative(directory, path);
|
|
823
|
-
return rest !== "" && !rest.startsWith("..") && !isAbsolute(rest);
|
|
824
|
-
}
|
|
825
|
-
/**
|
|
826
|
-
* Is this stack frame a module of the framework itself, rather than of the code
|
|
827
|
-
* calling it?
|
|
828
|
-
*
|
|
829
|
-
* Answered by IDENTITY: the frame is inside the framework's own directory.
|
|
830
|
-
* Answering it by substring — `/src/core/`, `/src/integrations/`, `/src/vitest/`
|
|
831
|
-
* — was answering a different question, "does this path look like the framework's
|
|
832
|
-
* layout", and a consumer that happens to have its own `src/core/` matched it.
|
|
833
|
-
* Its files were then skipped as framework internals, and fixture resolution
|
|
834
|
-
* anchored on the wrong directory (or fell through to the cwd) with nothing
|
|
835
|
-
* reported.
|
|
836
|
-
*
|
|
837
|
-
* @internal
|
|
838
|
-
*/
|
|
839
|
-
function isFrameworkFrame(filePath, frameworkTree = FRAMEWORK_TREE) {
|
|
840
|
-
if (TEST_FILE.test(filePath)) return false;
|
|
841
|
-
return isInside(realPath(frameworkTree), realPath(filePath));
|
|
842
|
-
}
|
|
843
|
-
/**
|
|
844
|
-
* Detect the directory of the first stack frame that lives outside this
|
|
845
|
-
* package. Used to anchor fixture resolution (`seeds/`, `requests/`,
|
|
846
|
-
* `expected/`, …) on the calling test / specification file.
|
|
847
|
-
*
|
|
848
|
-
* @internal
|
|
849
|
-
*/
|
|
850
|
-
function getCallerDir() {
|
|
851
|
-
const stack = (/* @__PURE__ */ new Error("caller detection")).stack;
|
|
852
|
-
if (!stack) throw new Error("Cannot detect caller directory: no stack trace");
|
|
853
|
-
const lines = stack.split("\n");
|
|
854
|
-
for (const line of lines) {
|
|
855
|
-
const match = line.match(/at\s+(?:.*?\()?(?:file:\/\/)?(?<filePath>[^:)]+):\d+:\d+/);
|
|
856
|
-
if (!match?.groups?.filePath) continue;
|
|
857
|
-
const filePath = match.groups.filePath;
|
|
858
|
-
if (filePath.includes("node_modules")) continue;
|
|
859
|
-
if (isFrameworkFrame(filePath)) continue;
|
|
860
|
-
return resolve(filePath, "..");
|
|
861
|
-
}
|
|
862
|
-
return process.cwd();
|
|
863
|
-
}
|
|
864
|
-
//#endregion
|
|
865
|
-
//#region src/core/specification/shared/result/grep.ts
|
|
866
|
-
/**
|
|
867
|
-
* Extract text blocks from output that contain a pattern.
|
|
868
|
-
* Splits by blank lines (how linter/compiler output is structured),
|
|
869
|
-
* returns only blocks matching the pattern.
|
|
870
|
-
*
|
|
871
|
-
* @example
|
|
872
|
-
* expect(grep(result.stdout, "unused-var.ts")).toContain("no-unused-vars")
|
|
873
|
-
* expect(grep(result.stdout, "valid/sorted.ts")).not.toContain("sort-imports")
|
|
874
|
-
*/
|
|
875
|
-
function grep(output, pattern) {
|
|
876
|
-
return output.replace(/\x1b\[[0-9;]*m/g, "").split(/\n\s*\n/).filter((block) => block.includes(pattern)).join("\n\n");
|
|
877
|
-
}
|
|
878
|
-
//#endregion
|
|
879
|
-
//#region src/core/specification/shared/result/text.ts
|
|
880
|
-
const ANSI_RE = /\x1b\[[0-9;]*m/g;
|
|
881
|
-
/** Strip ANSI escape sequences from a string. */
|
|
882
|
-
function stripAnsiCodes(value) {
|
|
883
|
-
return value.replace(ANSI_RE, "");
|
|
884
|
-
}
|
|
885
|
-
/**
|
|
886
|
-
* Read-only accessor for a captured text handle — THE universal one: stdout,
|
|
887
|
-
* stderr, container logs, and file text all surface as a `TextAccessor`.
|
|
888
|
-
*
|
|
889
|
-
* Backed by a primitive string, exposed via {@link text}, but also provides
|
|
890
|
-
* `toString()` / `valueOf()` for string coercion, so common patterns like
|
|
891
|
-
* `String(result.stdout)` and template-literal interpolation still work.
|
|
892
|
-
*
|
|
893
|
-
* ANSI escape sequences are stripped by default before every comparison
|
|
894
|
-
* (CONVENTIONS D6) — `.text` stays raw. The runner `transform` option remains
|
|
895
|
-
* an escape hatch for application noise not covered by the `{{token}}`
|
|
896
|
-
* grammar.
|
|
897
|
-
*
|
|
898
|
-
* Text operations are **closed** over the type: `.grep(pattern)` returns a
|
|
899
|
-
* `TextAccessor` (not a bare string), preserving the `expected/`-resolution
|
|
900
|
-
* context and the `transform`, so results are chainable
|
|
901
|
-
* (`result.stdout.grep(a).grep(b)`) and snapshot-able
|
|
902
|
-
* (`expect(result.stdout.grep('users.ts')).toMatch('block.txt')`).
|
|
903
|
-
*
|
|
904
|
-
* Assertions go through `expect()` matchers: `expect(result.stdout).toContain(...)`
|
|
905
|
-
* and `expect(result.stdout).toMatch('name.txt')` (resolved against
|
|
906
|
-
* `expected/<name>`, flat — with `{{token}}` support, CONVENTIONS D4).
|
|
907
|
-
*/
|
|
908
|
-
var TextAccessor = class TextAccessor {
|
|
909
|
-
/** @internal Ref-capture scope shared by the current spec execution. */
|
|
910
|
-
captures;
|
|
911
|
-
/** @internal Stream label used in failure messages ("stdout" / "stderr"). */
|
|
912
|
-
streamName;
|
|
913
|
-
/** @internal Test-file directory — fixture resolution root for matchers. */
|
|
914
|
-
testDir;
|
|
915
|
-
/** The raw captured text (never transformed, ANSI preserved). */
|
|
916
|
-
text;
|
|
917
|
-
/** @internal Normaliser applied before comparisons, never to fixtures. */
|
|
918
|
-
transform;
|
|
919
|
-
constructor(value, streamName, testDir, options = {}) {
|
|
920
|
-
this.text = value;
|
|
921
|
-
this.streamName = streamName;
|
|
922
|
-
this.testDir = testDir;
|
|
923
|
-
this.transform = options.transform;
|
|
924
|
-
this.captures = options.captures ?? new CaptureScope();
|
|
925
|
-
}
|
|
926
|
-
/** @internal The text as compared by matchers — ANSI stripped, transform applied. */
|
|
927
|
-
get comparableText() {
|
|
928
|
-
const stripped = stripAnsiCodes(this.text);
|
|
929
|
-
return this.transform ? this.transform(stripped) : stripped;
|
|
930
|
-
}
|
|
931
|
-
/**
|
|
932
|
-
* Keep only the blank-line-separated blocks of the text that contain
|
|
933
|
-
* `pattern` (how linter/compiler output is structured), returned as a new
|
|
934
|
-
* `TextAccessor` — the same subject type, so the result is chainable and
|
|
935
|
-
* snapshot-able. The scalpel for probing large tool outputs; snapshot the
|
|
936
|
-
* whole surface by default, reach for `.grep()` for targeted checks.
|
|
937
|
-
*/
|
|
938
|
-
grep(pattern) {
|
|
939
|
-
return new TextAccessor(grep(this.text, pattern), this.streamName, this.testDir, {
|
|
940
|
-
captures: this.captures,
|
|
941
|
-
transform: this.transform
|
|
942
|
-
});
|
|
943
|
-
}
|
|
944
|
-
toString() {
|
|
945
|
-
return this.text;
|
|
946
|
-
}
|
|
947
|
-
valueOf() {
|
|
948
|
-
return this.text;
|
|
949
|
-
}
|
|
950
|
-
};
|
|
951
|
-
/**
|
|
952
|
-
* Wrap an arbitrary string into a {@link TextAccessor} anchored on the calling
|
|
953
|
-
* test's directory — the same caller-detection the builders use.
|
|
954
|
-
*
|
|
955
|
-
* The product surface of a test framework is its own error messages, checker
|
|
956
|
-
* output, and reports; those deserve the same goldening as any other output.
|
|
957
|
-
* `text()` makes an ad-hoc string a first-class snapshot subject:
|
|
958
|
-
*
|
|
959
|
-
* ```typescript
|
|
960
|
-
* const message = await catchMessage(() => expect(result.response).toMatch('wrong-body.http'));
|
|
961
|
-
* expect(text(message)).toMatch('wrong-body-error.txt'); // resolves to expected/
|
|
962
|
-
* ```
|
|
963
|
-
*
|
|
964
|
-
* ANSI is stripped before every comparison (the raw form stays on `.text`),
|
|
965
|
-
* the `{{token}}` grammar applies to the fixture, and `.grep()` composition
|
|
966
|
-
* works exactly as on any stream accessor.
|
|
967
|
-
*/
|
|
968
|
-
function text(value) {
|
|
969
|
-
return new TextAccessor(value, "text", getCallerDir());
|
|
970
|
-
}
|
|
971
|
-
//#endregion
|
|
972
|
-
//#region src/core/specification/shared/result/json.ts
|
|
973
|
-
/**
|
|
974
|
-
* Read-only accessor for a JSON payload parsed from a text stream (stdout).
|
|
975
|
-
*
|
|
976
|
-
* Lazily parses the JSON on first use; parse errors are deferred until the
|
|
977
|
-
* value is read so that callers can still read the raw stream text without
|
|
978
|
-
* triggering a throw.
|
|
979
|
-
*
|
|
980
|
-
* ANSI escape sequences are stripped by default before `JSON.parse`
|
|
981
|
-
* (CONVENTIONS D6). When a `transform` is configured on the spec runner, it
|
|
982
|
-
* runs on the stripped text — an escape hatch for application noise not
|
|
983
|
-
* covered by the `{{token}}` grammar.
|
|
984
|
-
*
|
|
985
|
-
* Assertions go through `expect()`: `expect(result.json).toMatch('name.json')`
|
|
986
|
-
* (deep-equal against `expected/<name>`, with `{{placeholder}}` support).
|
|
987
|
-
*/
|
|
988
|
-
var JsonAccessor = class {
|
|
989
|
-
/** @internal Ref-capture scope shared by the current spec execution. */
|
|
990
|
-
captures;
|
|
991
|
-
/** @internal */
|
|
992
|
-
rawText;
|
|
993
|
-
/** @internal Test-file directory — fixture resolution root for matchers. */
|
|
994
|
-
testDir;
|
|
995
|
-
/** @internal */
|
|
996
|
-
transform;
|
|
997
|
-
constructor(rawText, testDir, transform, captures) {
|
|
998
|
-
this.rawText = rawText;
|
|
999
|
-
this.testDir = testDir;
|
|
1000
|
-
this.transform = transform;
|
|
1001
|
-
this.captures = captures ?? new CaptureScope();
|
|
1002
|
-
}
|
|
1003
|
-
/** The parsed JSON value. Throws if the text is not valid JSON. */
|
|
1004
|
-
get value() {
|
|
1005
|
-
const stripped = stripAnsiCodes(this.rawText);
|
|
1006
|
-
const source = this.transform ? this.transform(stripped) : stripped;
|
|
1007
|
-
try {
|
|
1008
|
-
return JSON.parse(source);
|
|
1009
|
-
} catch {
|
|
1010
|
-
const preview = source.slice(0, 200);
|
|
1011
|
-
throw new Error(`stdout is not valid JSON: ${preview}`);
|
|
1012
|
-
}
|
|
1013
|
-
}
|
|
1014
|
-
};
|
|
1015
|
-
//#endregion
|
|
1016
523
|
//#region src/core/specification/shared/result/response.ts
|
|
1017
524
|
/**
|
|
1018
525
|
* Read-only accessor for an HTTP response.
|
|
@@ -1042,440 +549,6 @@ var ResponseAccessor = class {
|
|
|
1042
549
|
}
|
|
1043
550
|
};
|
|
1044
551
|
//#endregion
|
|
1045
|
-
//#region src/core/specification/shared/result/table.ts
|
|
1046
|
-
/**
|
|
1047
|
-
* Read-only accessor for a database table after a specification run.
|
|
1048
|
-
*
|
|
1049
|
-
* Assertions go through `expect()` (async — they query the database):
|
|
1050
|
-
*
|
|
1051
|
-
* await expect(result.table('users', { database: 'db' })).toMatchRows({
|
|
1052
|
-
* columns: ['name'],
|
|
1053
|
-
* rows: [['Alice']],
|
|
1054
|
-
* });
|
|
1055
|
-
* await expect(result.table('users', { database: 'db' })).toBeEmpty();
|
|
1056
|
-
*/
|
|
1057
|
-
var TableAccessor = class {
|
|
1058
|
-
/** @internal Ref-capture scope shared by the current spec execution. */
|
|
1059
|
-
captures;
|
|
1060
|
-
/** @internal */
|
|
1061
|
-
db;
|
|
1062
|
-
/** The table name this accessor reads. */
|
|
1063
|
-
name;
|
|
1064
|
-
constructor(name, db, captures) {
|
|
1065
|
-
this.name = name;
|
|
1066
|
-
this.db = db;
|
|
1067
|
-
this.captures = captures ?? new CaptureScope();
|
|
1068
|
-
}
|
|
1069
|
-
/** @internal Query the table — used by the toMatchRows / toBeEmpty matchers. */
|
|
1070
|
-
query(columns) {
|
|
1071
|
-
return this.db.query(this.name, columns);
|
|
1072
|
-
}
|
|
1073
|
-
};
|
|
1074
|
-
//#endregion
|
|
1075
|
-
//#region src/integrations/docker/docker-lookup.ts
|
|
1076
|
-
/**
|
|
1077
|
-
* Thin synchronous wrappers around the `docker` CLI used by the docker() spec
|
|
1078
|
-
* mode. Sync is deliberate — the calls are fast, run only on the host, and
|
|
1079
|
-
* keep the dispose path straightforward (`Symbol.asyncDispose` can still be
|
|
1080
|
-
* async without needing these to be).
|
|
1081
|
-
*/
|
|
1082
|
-
const DEFAULT_TIMEOUT = 1e4;
|
|
1083
|
-
/** Return all container IDs (running or stopped) that carry `key=value`. */
|
|
1084
|
-
function findContainersByLabel(key, value) {
|
|
1085
|
-
try {
|
|
1086
|
-
return execSync(`docker ps -aq -f label=${key}=${value}`, {
|
|
1087
|
-
encoding: "utf8",
|
|
1088
|
-
timeout: DEFAULT_TIMEOUT
|
|
1089
|
-
}).split("\n").map((line) => line.trim()).filter((line) => line.length > 0);
|
|
1090
|
-
} catch {
|
|
1091
|
-
return [];
|
|
1092
|
-
}
|
|
1093
|
-
}
|
|
1094
|
-
/** Return the raw `docker inspect` payload (object, not array) for a container. */
|
|
1095
|
-
function inspectContainer(id) {
|
|
1096
|
-
const raw = execSync(`docker inspect ${id}`, {
|
|
1097
|
-
encoding: "utf8",
|
|
1098
|
-
timeout: DEFAULT_TIMEOUT
|
|
1099
|
-
});
|
|
1100
|
-
const parsed = JSON.parse(raw);
|
|
1101
|
-
return Array.isArray(parsed) ? parsed[0] : parsed;
|
|
1102
|
-
}
|
|
1103
|
-
/** Force-remove the given container IDs in a single call. Errors are swallowed. */
|
|
1104
|
-
function removeContainers(ids) {
|
|
1105
|
-
if (ids.length === 0) return;
|
|
1106
|
-
try {
|
|
1107
|
-
execSync(`docker rm -f ${ids.join(" ")}`, {
|
|
1108
|
-
encoding: "utf8",
|
|
1109
|
-
stdio: "pipe",
|
|
1110
|
-
timeout: DEFAULT_TIMEOUT
|
|
1111
|
-
});
|
|
1112
|
-
} catch {}
|
|
1113
|
-
}
|
|
1114
|
-
//#endregion
|
|
1115
|
-
//#region src/core/specification/shared/result/result.ts
|
|
1116
|
-
/**
|
|
1117
|
-
* Base result - common accessors available after any action type.
|
|
1118
|
-
* Extended by HttpResult, CliResult, and used directly by job results.
|
|
1119
|
-
*
|
|
1120
|
-
* Every result carries a fresh {@link CaptureScope}: `match.ref()` captures
|
|
1121
|
-
* and `{{type#ref}}` placeholders are scoped to one spec execution.
|
|
1122
|
-
*/
|
|
1123
|
-
var BaseResult = class {
|
|
1124
|
-
/** @internal Ref-capture scope shared by every assertion on this result. */
|
|
1125
|
-
captures;
|
|
1126
|
-
config;
|
|
1127
|
-
testDir;
|
|
1128
|
-
workDir;
|
|
1129
|
-
constructor(options) {
|
|
1130
|
-
this.config = options.config;
|
|
1131
|
-
this.testDir = options.testDir;
|
|
1132
|
-
this.workDir = options.workDir;
|
|
1133
|
-
this.captures = new CaptureScope(options.workDir ? safeRealpath(options.workDir) : void 0);
|
|
1134
|
-
}
|
|
1135
|
-
/** Access a directory (relative to the working directory) for snapshot assertions. */
|
|
1136
|
-
directory(path = ".") {
|
|
1137
|
-
return new DirectoryAccessor(resolve(this.workDir ?? this.testDir, path), this.testDir, this.captures);
|
|
1138
|
-
}
|
|
1139
|
-
/** Access a single file (relative to the working directory) for content assertions. */
|
|
1140
|
-
file(path) {
|
|
1141
|
-
const resolvedPath = resolve(this.workDir ?? this.testDir, path);
|
|
1142
|
-
const exists = existsSync(resolvedPath);
|
|
1143
|
-
const { captures, testDir } = this;
|
|
1144
|
-
return {
|
|
1145
|
-
get content() {
|
|
1146
|
-
if (!exists) throw new Error(`File not found: ${path}`);
|
|
1147
|
-
return readFileSync(resolvedPath, "utf8");
|
|
1148
|
-
},
|
|
1149
|
-
exists,
|
|
1150
|
-
grep(pattern) {
|
|
1151
|
-
if (!exists) throw new Error(`File not found: ${path}`);
|
|
1152
|
-
return new TextAccessor(readFileSync(resolvedPath, "utf8"), path, testDir, { captures }).grep(pattern);
|
|
1153
|
-
}
|
|
1154
|
-
};
|
|
1155
|
-
}
|
|
1156
|
-
/** Access a database table for row-level assertions via expect() matchers. */
|
|
1157
|
-
table(tableName, options) {
|
|
1158
|
-
validateDatabaseOption("table", this.config, options?.database);
|
|
1159
|
-
const db = this.resolveDatabase(options?.database);
|
|
1160
|
-
if (!db) throw new Error(options?.database ? `table("${tableName}") requires database "${options.database}" but it was not found` : `table("${tableName}") requires a database adapter`);
|
|
1161
|
-
return new TableAccessor(tableName, db, this.captures);
|
|
1162
|
-
}
|
|
1163
|
-
resolveDatabase(databaseKey) {
|
|
1164
|
-
if (databaseKey && this.config.databases) return this.config.databases.get(databaseKey);
|
|
1165
|
-
return this.config.database;
|
|
1166
|
-
}
|
|
1167
|
-
};
|
|
1168
|
-
function safeRealpath(path) {
|
|
1169
|
-
try {
|
|
1170
|
-
return realpathSync(path);
|
|
1171
|
-
} catch {
|
|
1172
|
-
return path;
|
|
1173
|
-
}
|
|
1174
|
-
}
|
|
1175
|
-
/**
|
|
1176
|
-
* Enforce CONVENTIONS A7: with two or more databases in the services record
|
|
1177
|
-
* the `database` option is mandatory on `.seed()` / `.table()`; with exactly
|
|
1178
|
-
* one it is forbidden (redundant).
|
|
1179
|
-
*
|
|
1180
|
-
* @internal
|
|
1181
|
-
*/
|
|
1182
|
-
function validateDatabaseOption(method, config, database) {
|
|
1183
|
-
const keys = config.databaseKeys;
|
|
1184
|
-
if (!keys) return;
|
|
1185
|
-
if (keys.length >= 2 && !database) throw new Error(`${method}(): ${keys.length} databases are declared (${keys.map((k) => `"${k}"`).join(", ")}) — pass { database: <key> } to target one of them.`);
|
|
1186
|
-
if (keys.length === 1 && database) throw new Error(`${method}(): redundant database option — "${keys[0]}" is the only declared database.`);
|
|
1187
|
-
}
|
|
1188
|
-
//#endregion
|
|
1189
|
-
//#region src/core/specification/cli/result.ts
|
|
1190
|
-
/**
|
|
1191
|
-
* Result from a command action (`.exec()`).
|
|
1192
|
-
*
|
|
1193
|
-
* When the runner was configured with a `docker` option, the result also
|
|
1194
|
-
* exposes container accessors and participates in `Symbol.asyncDispose`
|
|
1195
|
-
* cleanup. The Docker shell-outs are **lazy**: a test that never calls
|
|
1196
|
-
* `.container(...)` never queries the Docker daemon, so command-only tests
|
|
1197
|
-
* pay zero Docker cost even when the runner is Docker-aware.
|
|
1198
|
-
*
|
|
1199
|
-
* Dispose always runs one final label-filtered `docker ps` to catch
|
|
1200
|
-
* containers that were spawned during `.exec` but never asserted on —
|
|
1201
|
-
* tests still get leak-free cleanup even if they forget to reach into
|
|
1202
|
-
* a container.
|
|
1203
|
-
*/
|
|
1204
|
-
var CliResult = class extends BaseResult {
|
|
1205
|
-
commandOutput;
|
|
1206
|
-
containersCache = null;
|
|
1207
|
-
dockerConfig;
|
|
1208
|
-
testRunId;
|
|
1209
|
-
transform;
|
|
1210
|
-
constructor(options) {
|
|
1211
|
-
super(options);
|
|
1212
|
-
this.commandOutput = options.commandOutput;
|
|
1213
|
-
this.dockerConfig = options.dockerConfig;
|
|
1214
|
-
this.testRunId = options.testRunId;
|
|
1215
|
-
this.transform = options.transform;
|
|
1216
|
-
}
|
|
1217
|
-
/** The process exit code. */
|
|
1218
|
-
get exitCode() {
|
|
1219
|
-
return this.commandOutput.exitCode;
|
|
1220
|
-
}
|
|
1221
|
-
/** Accessor for the captured standard output with file-based assertions. */
|
|
1222
|
-
get stdout() {
|
|
1223
|
-
return new TextAccessor(this.commandOutput.stdout, "stdout", this.testDir, {
|
|
1224
|
-
captures: this.captures,
|
|
1225
|
-
transform: this.transform
|
|
1226
|
-
});
|
|
1227
|
-
}
|
|
1228
|
-
/** Accessor for the captured standard error with file-based assertions. */
|
|
1229
|
-
get stderr() {
|
|
1230
|
-
return new TextAccessor(this.commandOutput.stderr, "stderr", this.testDir, {
|
|
1231
|
-
captures: this.captures,
|
|
1232
|
-
transform: this.transform
|
|
1233
|
-
});
|
|
1234
|
-
}
|
|
1235
|
-
/** Accessor for parsing stdout as JSON and asserting against JSON fixtures. */
|
|
1236
|
-
get json() {
|
|
1237
|
-
return new JsonAccessor(this.commandOutput.stdout, this.testDir, this.transform, this.captures);
|
|
1238
|
-
}
|
|
1239
|
-
/** Accessor for the temporary working directory the command ran in. */
|
|
1240
|
-
get filesystem() {
|
|
1241
|
-
if (!this.workDir) throw new Error("CliResult.filesystem requires a working directory");
|
|
1242
|
-
return new FilesystemAccessor(this.workDir, this.testDir, this.captures);
|
|
1243
|
-
}
|
|
1244
|
-
/**
|
|
1245
|
-
* Look up a container the command spawned during this run by the value of
|
|
1246
|
-
* its name-label (as declared in the `docker.nameLabel` of the
|
|
1247
|
-
* `specification.cli()` options).
|
|
1248
|
-
* First access triggers a one-shot `docker ps` + `docker inspect`
|
|
1249
|
-
* query and caches the result for the rest of the result's lifetime.
|
|
1250
|
-
* Tests that don't call this never touch Docker.
|
|
1251
|
-
*
|
|
1252
|
-
* Returns an accessor with `.exists === false` when no container
|
|
1253
|
-
* carries that name — callers can still assert absence without a try.
|
|
1254
|
-
*/
|
|
1255
|
-
container(name) {
|
|
1256
|
-
this.ensureDockerAware("container");
|
|
1257
|
-
this.loadContainers();
|
|
1258
|
-
const captured = this.containersCache.get(name);
|
|
1259
|
-
if (!captured) return new ContainerAccessor(null, null, this.testDir, this.transform);
|
|
1260
|
-
return new ContainerAccessor(captured.id, captured.inspect, this.testDir, this.transform);
|
|
1261
|
-
}
|
|
1262
|
-
/** All captured container IDs. Triggers the same lazy query as `container()`. */
|
|
1263
|
-
get containerIds() {
|
|
1264
|
-
this.ensureDockerAware("containerIds");
|
|
1265
|
-
this.loadContainers();
|
|
1266
|
-
return [...this.containersCache.values()].map((c) => c.id);
|
|
1267
|
-
}
|
|
1268
|
-
async [Symbol.asyncDispose]() {
|
|
1269
|
-
if (!this.dockerConfig || !this.testRunId) return;
|
|
1270
|
-
removeContainers(this.containersCache !== null ? [...this.containersCache.values()].map((c) => c.id) : findContainersByLabel(this.dockerConfig.testRunLabel, this.testRunId));
|
|
1271
|
-
}
|
|
1272
|
-
ensureDockerAware(member) {
|
|
1273
|
-
if (!this.dockerConfig || !this.testRunId) throw new Error(`CliResult.${member}: runner was not configured with a docker option. Pass \`docker: { envVar, nameLabel, testRunLabel }\` in the specification.cli() options.`);
|
|
1274
|
-
}
|
|
1275
|
-
loadContainers() {
|
|
1276
|
-
if (this.containersCache !== null) return;
|
|
1277
|
-
const ids = findContainersByLabel(this.dockerConfig.testRunLabel, this.testRunId);
|
|
1278
|
-
const map = /* @__PURE__ */ new Map();
|
|
1279
|
-
for (const id of ids) {
|
|
1280
|
-
let inspect;
|
|
1281
|
-
try {
|
|
1282
|
-
inspect = inspectContainer(id);
|
|
1283
|
-
} catch {
|
|
1284
|
-
continue;
|
|
1285
|
-
}
|
|
1286
|
-
const nameValue = (inspect?.Config?.Labels ?? inspect?.config?.labels ?? {})[this.dockerConfig.nameLabel];
|
|
1287
|
-
const key = typeof nameValue === "string" && nameValue.length > 0 ? nameValue : id;
|
|
1288
|
-
map.set(key, {
|
|
1289
|
-
id,
|
|
1290
|
-
inspect
|
|
1291
|
-
});
|
|
1292
|
-
}
|
|
1293
|
-
this.containersCache = map;
|
|
1294
|
-
}
|
|
1295
|
-
};
|
|
1296
|
-
//#endregion
|
|
1297
|
-
//#region src/integrations/docker/container-accessor.ts
|
|
1298
|
-
const EXEC_TIMEOUT = 1e4;
|
|
1299
|
-
function readInspectState(inspect) {
|
|
1300
|
-
const state = inspect?.State ?? inspect?.state;
|
|
1301
|
-
if (!state) return {
|
|
1302
|
-
running: false,
|
|
1303
|
-
status: "unknown"
|
|
1304
|
-
};
|
|
1305
|
-
return {
|
|
1306
|
-
running: Boolean(state.Running ?? state.running ?? false),
|
|
1307
|
-
status: String(state.Status ?? state.status ?? "unknown")
|
|
1308
|
-
};
|
|
1309
|
-
}
|
|
1310
|
-
/**
|
|
1311
|
-
* Assertion accessor for a single Docker container captured by the docker()
|
|
1312
|
-
* spec mode. Mirrors the shape of {@link CliResult} so tests use the same
|
|
1313
|
-
* vocabulary (`stdout.toContain`, `file(...).content`, etc.) regardless
|
|
1314
|
-
* of where output came from.
|
|
1315
|
-
*
|
|
1316
|
-
* Sync state (`exists`, `running`, `status`) is derived from the `docker
|
|
1317
|
-
* inspect` payload captured at result-construction time. Logs are fetched
|
|
1318
|
-
* lazily on first access to `stdout`/`stderr`.
|
|
1319
|
-
*/
|
|
1320
|
-
var ContainerAccessor = class {
|
|
1321
|
-
cachedLogs = null;
|
|
1322
|
-
containerId;
|
|
1323
|
-
exists;
|
|
1324
|
-
inspectData;
|
|
1325
|
-
running;
|
|
1326
|
-
status;
|
|
1327
|
-
testDir;
|
|
1328
|
-
transform;
|
|
1329
|
-
/**
|
|
1330
|
-
* Underlying Docker container ID, or `null` if no container was captured
|
|
1331
|
-
* for this name. Useful when a follow-up CLI command needs to reference
|
|
1332
|
-
* the container by id (e.g. `spwn world inspect <id>`). Prefer the other
|
|
1333
|
-
* accessors for common reads — pulling the raw id is the escape hatch.
|
|
1334
|
-
*/
|
|
1335
|
-
get id() {
|
|
1336
|
-
return this.containerId;
|
|
1337
|
-
}
|
|
1338
|
-
constructor(containerId, inspectData, testDir, transform) {
|
|
1339
|
-
this.containerId = containerId;
|
|
1340
|
-
this.inspectData = inspectData;
|
|
1341
|
-
this.testDir = testDir;
|
|
1342
|
-
this.transform = transform;
|
|
1343
|
-
this.exists = containerId !== null;
|
|
1344
|
-
if (this.exists) {
|
|
1345
|
-
const state = readInspectState(inspectData);
|
|
1346
|
-
this.running = state.running;
|
|
1347
|
-
this.status = state.status;
|
|
1348
|
-
} else {
|
|
1349
|
-
this.running = false;
|
|
1350
|
-
this.status = "absent";
|
|
1351
|
-
}
|
|
1352
|
-
}
|
|
1353
|
-
/**
|
|
1354
|
-
* Raw `docker inspect` object for this container. Throws if the container
|
|
1355
|
-
* was not captured (i.e. `.exists === false`).
|
|
1356
|
-
*/
|
|
1357
|
-
get inspect() {
|
|
1358
|
-
this.requireExists("inspect");
|
|
1359
|
-
return new JsonAccessor(JSON.stringify(this.inspectData), this.testDir, this.transform);
|
|
1360
|
-
}
|
|
1361
|
-
/** Captured logs (stdout+stderr combined for v1). */
|
|
1362
|
-
get stdout() {
|
|
1363
|
-
this.requireExists("stdout");
|
|
1364
|
-
return new TextAccessor(this.loadLogs(), "stdout", this.testDir, { transform: this.transform });
|
|
1365
|
-
}
|
|
1366
|
-
/** Captured logs (stdout+stderr combined for v1). */
|
|
1367
|
-
get stderr() {
|
|
1368
|
-
this.requireExists("stderr");
|
|
1369
|
-
return new TextAccessor(this.loadLogs(), "stderr", this.testDir, { transform: this.transform });
|
|
1370
|
-
}
|
|
1371
|
-
/**
|
|
1372
|
-
* Read a file from inside the container via `docker exec cat`. The
|
|
1373
|
-
* returned object satisfies the same {@link FileAccessor} shape used by
|
|
1374
|
-
* the host filesystem accessor, so tests do not need to learn a new API.
|
|
1375
|
-
*/
|
|
1376
|
-
file(path) {
|
|
1377
|
-
this.requireExists("file");
|
|
1378
|
-
const id = this.containerId;
|
|
1379
|
-
const exists = this.containerExec(id, [
|
|
1380
|
-
"test",
|
|
1381
|
-
"-e",
|
|
1382
|
-
path
|
|
1383
|
-
]).exitCode === 0;
|
|
1384
|
-
const { testDir } = this;
|
|
1385
|
-
const readContent = () => {
|
|
1386
|
-
if (!exists) throw new Error(`File not found in container: ${path}`);
|
|
1387
|
-
return execSync(`docker exec ${id} cat ${JSON.stringify(path)}`, {
|
|
1388
|
-
encoding: "utf8",
|
|
1389
|
-
timeout: EXEC_TIMEOUT
|
|
1390
|
-
});
|
|
1391
|
-
};
|
|
1392
|
-
return {
|
|
1393
|
-
get content() {
|
|
1394
|
-
return readContent();
|
|
1395
|
-
},
|
|
1396
|
-
exists,
|
|
1397
|
-
grep(pattern) {
|
|
1398
|
-
return new TextAccessor(readContent(), path, testDir).grep(pattern);
|
|
1399
|
-
}
|
|
1400
|
-
};
|
|
1401
|
-
}
|
|
1402
|
-
/**
|
|
1403
|
-
* Run a shell command inside the container and get back the same
|
|
1404
|
-
* {@link CliResult} used for host-side executions.
|
|
1405
|
-
*/
|
|
1406
|
-
async exec(cmd) {
|
|
1407
|
-
this.requireExists("exec");
|
|
1408
|
-
return new CliResult({
|
|
1409
|
-
commandOutput: this.containerExec(this.containerId, [
|
|
1410
|
-
"sh",
|
|
1411
|
-
"-c",
|
|
1412
|
-
cmd
|
|
1413
|
-
]),
|
|
1414
|
-
config: {},
|
|
1415
|
-
testDir: this.testDir,
|
|
1416
|
-
transform: this.transform,
|
|
1417
|
-
workDir: this.testDir
|
|
1418
|
-
});
|
|
1419
|
-
}
|
|
1420
|
-
requireExists(member) {
|
|
1421
|
-
if (!this.exists) throw new Error(`ContainerAccessor.${member}: container does not exist (check .exists first)`);
|
|
1422
|
-
}
|
|
1423
|
-
loadLogs() {
|
|
1424
|
-
if (this.cachedLogs !== null) return this.cachedLogs;
|
|
1425
|
-
try {
|
|
1426
|
-
const out = execSync(`docker logs ${this.containerId}`, {
|
|
1427
|
-
encoding: "utf8",
|
|
1428
|
-
stdio: [
|
|
1429
|
-
"ignore",
|
|
1430
|
-
"pipe",
|
|
1431
|
-
"pipe"
|
|
1432
|
-
],
|
|
1433
|
-
timeout: EXEC_TIMEOUT
|
|
1434
|
-
});
|
|
1435
|
-
this.cachedLogs = out;
|
|
1436
|
-
} catch (error) {
|
|
1437
|
-
this.cachedLogs = typeof error?.stdout === "string" ? error.stdout : String(error?.stderr ?? "");
|
|
1438
|
-
}
|
|
1439
|
-
return this.cachedLogs ?? "";
|
|
1440
|
-
}
|
|
1441
|
-
containerExec(id, argv) {
|
|
1442
|
-
try {
|
|
1443
|
-
return {
|
|
1444
|
-
exitCode: 0,
|
|
1445
|
-
stderr: "",
|
|
1446
|
-
stdout: execSync(`docker exec ${id} ${argv.map((a) => JSON.stringify(a)).join(" ")}`, {
|
|
1447
|
-
encoding: "utf8",
|
|
1448
|
-
stdio: [
|
|
1449
|
-
"ignore",
|
|
1450
|
-
"pipe",
|
|
1451
|
-
"pipe"
|
|
1452
|
-
],
|
|
1453
|
-
timeout: EXEC_TIMEOUT
|
|
1454
|
-
})
|
|
1455
|
-
};
|
|
1456
|
-
} catch (error) {
|
|
1457
|
-
return {
|
|
1458
|
-
exitCode: typeof error?.status === "number" ? error.status : 1,
|
|
1459
|
-
stderr: typeof error?.stderr === "string" ? error.stderr : String(error?.message ?? ""),
|
|
1460
|
-
stdout: typeof error?.stdout === "string" ? error.stdout : ""
|
|
1461
|
-
};
|
|
1462
|
-
}
|
|
1463
|
-
}
|
|
1464
|
-
};
|
|
1465
|
-
//#endregion
|
|
1466
|
-
//#region src/vitest/update.ts
|
|
1467
|
-
/**
|
|
1468
|
-
* Snapshot update-mode detection. `TEST_UPDATE=1` (framework convention, see
|
|
1469
|
-
* CONVENTIONS E1) or vitest's `-u` / `--update` flag turn fixture mismatches
|
|
1470
|
-
* into fixture rewrites.
|
|
1471
|
-
*/
|
|
1472
|
-
function shouldUpdateSnapshots() {
|
|
1473
|
-
if (process.env.TEST_UPDATE === "1") return true;
|
|
1474
|
-
return process.argv.includes("-u") || process.argv.includes("--update");
|
|
1475
|
-
}
|
|
1476
|
-
/** Standard hint appended to missing-fixture errors. */
|
|
1477
|
-
const UPDATE_HINT = "Run with TEST_UPDATE=1 (or vitest -u) to create it.";
|
|
1478
|
-
//#endregion
|
|
1479
552
|
//#region src/vitest/matchers.ts
|
|
1480
553
|
/**
|
|
1481
554
|
* Vitest custom matchers for `@jterrazz/test` accessors.
|
|
@@ -1515,7 +588,7 @@ function matchStreamFile(accessor, name, frozen) {
|
|
|
1515
588
|
if (!existsSync(filePath)) return FAIL(`${accessor.streamName} fixture "${name}" does not exist at ${filePath}.\n${UPDATE_HINT}`);
|
|
1516
589
|
const expected = readFileSync(filePath, "utf8");
|
|
1517
590
|
if (textEquals(expected, actual, accessor.captures)) return PASS(`expected ${accessor.streamName} not to match expected/${name}`);
|
|
1518
|
-
return FAIL(formatStdoutDiff(name, expected, actual));
|
|
591
|
+
return FAIL(formatStdoutDiff(name, expected, actual, { equals: (expectedLine, actualLine) => textEquals(expectedLine, actualLine, new CaptureScope(accessor.captures.workdir)) }));
|
|
1519
592
|
}
|
|
1520
593
|
function matchJsonFile(accessor, name, frozen) {
|
|
1521
594
|
requireExtension(name, "json");
|
|
@@ -1955,77 +1028,6 @@ function resolveComposeBinding(key, explicitComposeName, serviceNames) {
|
|
|
1955
1028
|
return key;
|
|
1956
1029
|
}
|
|
1957
1030
|
//#endregion
|
|
1958
|
-
//#region src/core/specification/shared/fixtures.ts
|
|
1959
|
-
/**
|
|
1960
|
-
* Fixture path resolution + copy semantics for the `cli` facet's `.fixture()`.
|
|
1961
|
-
*
|
|
1962
|
-
* A fixture path is one of two shapes:
|
|
1963
|
-
*
|
|
1964
|
-
* - `$FIXTURES/<rest>` — the shared pool at `<specs-root>/fixtures/<rest>`,
|
|
1965
|
-
* where `<specs-root>` is the nearest ancestor directory named `specs`.
|
|
1966
|
-
* - `<path>` (no marker) — feature-local, at `<test-dir>/fixtures/<path>`.
|
|
1967
|
-
*
|
|
1968
|
-
* Any other `$`-prefixed marker is a usage error (listed against the known
|
|
1969
|
-
* markers). Copy semantics mirror rsync's trailing-slash rule (see
|
|
1970
|
-
* {@link copyPlan}).
|
|
1971
|
-
*/
|
|
1972
|
-
/**
|
|
1973
|
-
* Markers understood in a `.fixture()` path. Extend here + in
|
|
1974
|
-
* {@link resolveFixtureSource}. Exported so the lint layer (rule
|
|
1975
|
-
* `jterrazz/b2-known-fixture-marker`) validates literals against the same list.
|
|
1976
|
-
*/
|
|
1977
|
-
const KNOWN_FIXTURE_MARKERS = ["$FIXTURES"];
|
|
1978
|
-
/**
|
|
1979
|
-
* Walk up from `startDir` to the nearest ancestor directory named `specs`.
|
|
1980
|
-
* Throws with guidance if none is found up to the filesystem root — the
|
|
1981
|
-
* `$FIXTURES` marker is meaningless without a specs root.
|
|
1982
|
-
*/
|
|
1983
|
-
function discoverSpecsRoot(startDir) {
|
|
1984
|
-
let dir = startDir;
|
|
1985
|
-
for (;;) {
|
|
1986
|
-
if (basename(dir) === "specs") return dir;
|
|
1987
|
-
const parent = dirname(dir);
|
|
1988
|
-
if (parent === dir) break;
|
|
1989
|
-
dir = parent;
|
|
1990
|
-
}
|
|
1991
|
-
throw new Error(`.fixture(): the $FIXTURES marker resolves to <specs-root>/fixtures, but no directory named "specs" was found walking up from ${startDir}. Move the specification under a specs/ directory, or use a feature-local fixtures/ path (no $FIXTURES marker).`);
|
|
1992
|
-
}
|
|
1993
|
-
/**
|
|
1994
|
-
* Resolve the absolute source path of a fixture, applying marker resolution.
|
|
1995
|
-
* The trailing slash (spread semantics) is preserved by callers via
|
|
1996
|
-
* {@link copyPlan} — it is irrelevant to source resolution.
|
|
1997
|
-
*/
|
|
1998
|
-
function resolveFixtureSource(path, testDir) {
|
|
1999
|
-
const clean = path.replace(/\/+$/, "");
|
|
2000
|
-
if (clean === "$FIXTURES" || path.startsWith("$FIXTURES/")) {
|
|
2001
|
-
const rest = clean.slice(9).replace(/^\/+/, "");
|
|
2002
|
-
return resolve(discoverSpecsRoot(testDir), "fixtures", rest);
|
|
2003
|
-
}
|
|
2004
|
-
if (path.startsWith("$")) {
|
|
2005
|
-
const marker = clean.split("/")[0];
|
|
2006
|
-
throw new Error(`.fixture("${path}"): unknown marker "${marker}". Known markers: ${KNOWN_FIXTURE_MARKERS.join(", ")}. A path without a marker is feature-local (resolved under <test-dir>/fixtures/).`);
|
|
2007
|
-
}
|
|
2008
|
-
return resolve(testDir, "fixtures", clean);
|
|
2009
|
-
}
|
|
2010
|
-
/**
|
|
2011
|
-
* Compute the source + destination for copying a fixture into the working
|
|
2012
|
-
* directory. Copy semantics follow rsync's trailing-slash rule:
|
|
2013
|
-
*
|
|
2014
|
-
* - `dir/` (trailing slash) — the CONTENTS are spread into the cwd.
|
|
2015
|
-
* - `dir` (no slash, a directory) — the directory is copied as `<cwd>/<basename>`.
|
|
2016
|
-
* - `file` — copied as `<cwd>/<basename>`.
|
|
2017
|
-
*
|
|
2018
|
-
* Chained `.fixture()` calls layer in order: a later fixture overwrites files
|
|
2019
|
-
* written by an earlier one.
|
|
2020
|
-
*/
|
|
2021
|
-
function copyPlan(path, testDir, workDir) {
|
|
2022
|
-
const src = resolveFixtureSource(path, testDir);
|
|
2023
|
-
return {
|
|
2024
|
-
dest: /\/+$/.test(path) ? workDir : resolve(workDir, basename(src)),
|
|
2025
|
-
src
|
|
2026
|
-
};
|
|
2027
|
-
}
|
|
2028
|
-
//#endregion
|
|
2029
1031
|
//#region src/core/specification/shared/builder.ts
|
|
2030
1032
|
/**
|
|
2031
1033
|
* Fluent builder for declaring a single test specification.
|
|
@@ -2220,6 +1222,25 @@ var SpecificationBuilder = class {
|
|
|
2220
1222
|
});
|
|
2221
1223
|
}
|
|
2222
1224
|
/**
|
|
1225
|
+
* Run a literate `<case>.cli` spec and resolve with the LAST block's
|
|
1226
|
+
* result. The whole file runs in ONE working directory with ONE set of
|
|
1227
|
+
* servers, and every block is asserted — unlike `.exec([...])`, a non-zero
|
|
1228
|
+
* exit does not stop the sequence, because here each exit code is part of
|
|
1229
|
+
* what the file states.
|
|
1230
|
+
*
|
|
1231
|
+
* Setup chained BEFORE the call layers underneath the file's own header: a
|
|
1232
|
+
* chained `.fixture()` is copied first, the header's `fixture:` lines over
|
|
1233
|
+
* it, and the header's `env:` wins over a chained `.env()`.
|
|
1234
|
+
*
|
|
1235
|
+
* @example
|
|
1236
|
+
* const result = await cli.run('no-estate.cli');
|
|
1237
|
+
* await expect(result.directory('out')).toMatch('scaffold');
|
|
1238
|
+
*/
|
|
1239
|
+
run(file, options) {
|
|
1240
|
+
const workDir = this.prepareWorkDir();
|
|
1241
|
+
return this.executeSetup(workDir, () => this.runLiterateAction(workDir, file, options));
|
|
1242
|
+
}
|
|
1243
|
+
/**
|
|
2223
1244
|
* Perform one raw HTTP GET against the website under test and resolve
|
|
2224
1245
|
* with the exchange. Redirects are never followed — a 308 IS the result,
|
|
2225
1246
|
* with its `location` readable on the result. The scalpel for wire-level
|
|
@@ -2450,20 +1471,42 @@ var SpecificationBuilder = class {
|
|
|
2450
1471
|
if (redisHandles.length === 1) env.REDIS_URL = redisHandles[0].connectionString;
|
|
2451
1472
|
return env;
|
|
2452
1473
|
}
|
|
2453
|
-
|
|
2454
|
-
|
|
2455
|
-
|
|
2456
|
-
|
|
1474
|
+
/**
|
|
1475
|
+
* The environment every child of this chain starts with. Merge order:
|
|
1476
|
+
* injected service URLs (CONVENTIONS B6), then the docker run-id var, then
|
|
1477
|
+
* the chain's own `.env()` — which always wins, `null` unsetting.
|
|
1478
|
+
*/
|
|
1479
|
+
childEnv(workDir) {
|
|
2457
1480
|
let env = this.serviceEnv();
|
|
2458
|
-
|
|
1481
|
+
const dockerConfig = this.config.dockerConfig;
|
|
1482
|
+
if (dockerConfig && this.config.dockerTestRunId) env = {
|
|
2459
1483
|
...env,
|
|
2460
|
-
[dockerConfig.envVar]:
|
|
1484
|
+
[dockerConfig.envVar]: this.config.dockerTestRunId
|
|
2461
1485
|
};
|
|
2462
1486
|
const userEnv = this.resolveEnv(workDir);
|
|
2463
1487
|
if (userEnv) env = {
|
|
2464
1488
|
...env,
|
|
2465
1489
|
...userEnv
|
|
2466
1490
|
};
|
|
1491
|
+
return env;
|
|
1492
|
+
}
|
|
1493
|
+
runLiterateAction(workDir, file, options) {
|
|
1494
|
+
const filePath = resolve(this.testDir, file);
|
|
1495
|
+
return runLiterateSpec({
|
|
1496
|
+
baseEnv: this.childEnv(workDir),
|
|
1497
|
+
frozen: options?.frozen,
|
|
1498
|
+
config: this.config,
|
|
1499
|
+
displayPath: relative(process.cwd(), filePath) || file,
|
|
1500
|
+
filePath,
|
|
1501
|
+
testDir: this.testDir,
|
|
1502
|
+
workDir
|
|
1503
|
+
});
|
|
1504
|
+
}
|
|
1505
|
+
async runCommandAction(workDir, action) {
|
|
1506
|
+
if (!this.config.command) throw new Error("Command actions require a command adapter");
|
|
1507
|
+
const dockerConfig = this.config.dockerConfig;
|
|
1508
|
+
const testRunId = this.config.dockerTestRunId;
|
|
1509
|
+
const env = this.childEnv(workDir);
|
|
2467
1510
|
let commandOutput;
|
|
2468
1511
|
if (action.options) commandOutput = await this.config.command.watch(action.args, workDir, action.options, env);
|
|
2469
1512
|
else if (Array.isArray(action.args)) {
|
|
@@ -2563,6 +1606,7 @@ function createCliFacet(config) {
|
|
|
2563
1606
|
env: (env) => start().env(env),
|
|
2564
1607
|
exec: (args, options) => start().exec(args, options),
|
|
2565
1608
|
fixture: (path) => start().fixture(path),
|
|
1609
|
+
run: (file, options) => start().run(file, options),
|
|
2566
1610
|
seed: (file, options) => start().seed(file, options)
|
|
2567
1611
|
};
|
|
2568
1612
|
}
|
|
@@ -2958,7 +2002,7 @@ function isTransientConnectionError(error) {
|
|
|
2958
2002
|
const cause = error.cause;
|
|
2959
2003
|
return cause?.code !== void 0 && TRANSIENT_CONNECTION_CODES.has(cause.code) || error.message.includes("fetch failed");
|
|
2960
2004
|
}
|
|
2961
|
-
const delay
|
|
2005
|
+
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
2962
2006
|
/**
|
|
2963
2007
|
* Server adapter that sends real HTTP requests via the Fetch API.
|
|
2964
2008
|
* Used by the `e2e()` specification runner to hit a live server.
|
|
@@ -2999,7 +2043,7 @@ var FetchAdapter = class {
|
|
|
2999
2043
|
return await fetch(url, init);
|
|
3000
2044
|
} catch (error) {
|
|
3001
2045
|
if (attempt >= MAX_ATTEMPTS || !isTransientConnectionError(error)) throw error;
|
|
3002
|
-
await delay
|
|
2046
|
+
await delay(BASE_BACKOFF_MS * 2 ** (attempt - 1));
|
|
3003
2047
|
}
|
|
3004
2048
|
}
|
|
3005
2049
|
};
|
|
@@ -3062,7 +2106,7 @@ async function startApi(options) {
|
|
|
3062
2106
|
//#region src/core/specification/cli/exec.adapter.ts
|
|
3063
2107
|
const DEFAULT_WATCH_TIMEOUT = 1e4;
|
|
3064
2108
|
/** Grace period between SIGTERM and the SIGKILL escalation. */
|
|
3065
|
-
const KILL_GRACE_MS
|
|
2109
|
+
const KILL_GRACE_MS = 2e3;
|
|
3066
2110
|
/**
|
|
3067
2111
|
* Build a child-process env from the parent env plus user overrides.
|
|
3068
2112
|
* `null` overrides delete keys (e.g. `INIT_CWD: null`).
|
|
@@ -3105,7 +2149,7 @@ function observeProcess(child, options) {
|
|
|
3105
2149
|
child.kill("SIGTERM");
|
|
3106
2150
|
killTimer = setTimeout(() => {
|
|
3107
2151
|
if (!exited) child.kill("SIGKILL");
|
|
3108
|
-
}, KILL_GRACE_MS
|
|
2152
|
+
}, KILL_GRACE_MS);
|
|
3109
2153
|
};
|
|
3110
2154
|
const finish = (exitCode) => {
|
|
3111
2155
|
if (resolved) return;
|
|
@@ -3231,6 +2275,9 @@ async function startCli(bin, options = {}) {
|
|
|
3231
2275
|
databaseKeys,
|
|
3232
2276
|
databases: started?.databases,
|
|
3233
2277
|
dockerConfig: options.docker,
|
|
2278
|
+
envSets: options.env,
|
|
2279
|
+
root,
|
|
2280
|
+
serveRegistry: options.serve,
|
|
3234
2281
|
services: Object.keys(services).length > 0 ? services : void 0,
|
|
3235
2282
|
transform: options.transform
|
|
3236
2283
|
}),
|
|
@@ -3436,134 +2483,6 @@ async function readBody(request) {
|
|
|
3436
2483
|
}
|
|
3437
2484
|
}
|
|
3438
2485
|
//#endregion
|
|
3439
|
-
//#region src/core/specification/website/serve.adapter.ts
|
|
3440
|
-
/** Grace period between SIGTERM and the SIGKILL escalation. */
|
|
3441
|
-
const KILL_GRACE_MS = 2e3;
|
|
3442
|
-
const DEFAULT_READY_TIMEOUT = 3e4;
|
|
3443
|
-
const READY_POLL_INTERVAL_MS = 250;
|
|
3444
|
-
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
3445
|
-
/** Ask the OS for a free TCP port. */
|
|
3446
|
-
function findFreePort() {
|
|
3447
|
-
return new Promise((resolve, reject) => {
|
|
3448
|
-
const server = createServer$1();
|
|
3449
|
-
server.listen(0, () => {
|
|
3450
|
-
const address = server.address();
|
|
3451
|
-
if (address === null || typeof address === "string") {
|
|
3452
|
-
server.close();
|
|
3453
|
-
reject(/* @__PURE__ */ new Error("could not determine a free port"));
|
|
3454
|
-
return;
|
|
3455
|
-
}
|
|
3456
|
-
const { port } = address;
|
|
3457
|
-
server.close(() => resolve(port));
|
|
3458
|
-
});
|
|
3459
|
-
server.on("error", reject);
|
|
3460
|
-
});
|
|
3461
|
-
}
|
|
3462
|
-
/**
|
|
3463
|
-
* Starts the site under test as a child process and waits until it answers
|
|
3464
|
-
* HTTP. Any HTTP status counts as ready — a 404 on the ready path is still a
|
|
3465
|
-
* listening server; only connection failures keep the poll going.
|
|
3466
|
-
*
|
|
3467
|
-
* The child runs through the shell in its own process group (POSIX) so
|
|
3468
|
-
* `stop()` can terminate the whole tree — a `next start` grandchild must not
|
|
3469
|
-
* outlive the spec run (same escalation as the cli exec adapter: SIGTERM,
|
|
3470
|
-
* then SIGKILL after a 2 s grace).
|
|
3471
|
-
*/
|
|
3472
|
-
var ServeAdapter = class {
|
|
3473
|
-
child = null;
|
|
3474
|
-
/** Extra environment injected into the child (e.g. the stub backend URL). */
|
|
3475
|
-
extraEnv;
|
|
3476
|
-
/** The constructor named in error messages — `website`, or `mobile` (appium server). */
|
|
3477
|
-
facet;
|
|
3478
|
-
options;
|
|
3479
|
-
output = "";
|
|
3480
|
-
root;
|
|
3481
|
-
constructor(options, root, facet = "website", extraEnv = {}) {
|
|
3482
|
-
this.extraEnv = extraEnv;
|
|
3483
|
-
this.facet = facet;
|
|
3484
|
-
this.options = options;
|
|
3485
|
-
this.root = root;
|
|
3486
|
-
}
|
|
3487
|
-
/** Start the server and resolve with its base URL once it answers HTTP. */
|
|
3488
|
-
async start() {
|
|
3489
|
-
const port = this.options.port ?? await findFreePort();
|
|
3490
|
-
const baseUrl = `http://127.0.0.1:${port}`;
|
|
3491
|
-
const readyUrl = `${baseUrl}${this.options.ready ?? "/"}`;
|
|
3492
|
-
const timeout = this.options.timeout ?? DEFAULT_READY_TIMEOUT;
|
|
3493
|
-
this.child = spawn(this.options.command, [], {
|
|
3494
|
-
cwd: this.root,
|
|
3495
|
-
detached: process.platform !== "win32",
|
|
3496
|
-
env: {
|
|
3497
|
-
...process.env,
|
|
3498
|
-
...this.extraEnv,
|
|
3499
|
-
PORT: String(port)
|
|
3500
|
-
},
|
|
3501
|
-
shell: true,
|
|
3502
|
-
stdio: [
|
|
3503
|
-
"ignore",
|
|
3504
|
-
"pipe",
|
|
3505
|
-
"pipe"
|
|
3506
|
-
]
|
|
3507
|
-
});
|
|
3508
|
-
this.child.stdout?.on("data", (data) => {
|
|
3509
|
-
this.output += data.toString();
|
|
3510
|
-
});
|
|
3511
|
-
this.child.stderr?.on("data", (data) => {
|
|
3512
|
-
this.output += data.toString();
|
|
3513
|
-
});
|
|
3514
|
-
let exited = false;
|
|
3515
|
-
this.child.on("exit", () => {
|
|
3516
|
-
exited = true;
|
|
3517
|
-
});
|
|
3518
|
-
const deadline = Date.now() + timeout;
|
|
3519
|
-
for (;;) {
|
|
3520
|
-
if (exited) throw new Error(`specification.${this.facet}(): server command exited before answering HTTP.\nCommand: ${this.options.command}\nOutput:\n${this.output}`);
|
|
3521
|
-
try {
|
|
3522
|
-
await fetch(readyUrl, { redirect: "manual" });
|
|
3523
|
-
return baseUrl;
|
|
3524
|
-
} catch {
|
|
3525
|
-
if (Date.now() >= deadline) {
|
|
3526
|
-
await this.stop();
|
|
3527
|
-
throw new Error(`specification.${this.facet}(): server did not answer on ${readyUrl} within ${timeout}ms.\nCommand: ${this.options.command}\nOutput:\n${this.output}`);
|
|
3528
|
-
}
|
|
3529
|
-
await delay(READY_POLL_INTERVAL_MS);
|
|
3530
|
-
}
|
|
3531
|
-
}
|
|
3532
|
-
}
|
|
3533
|
-
/** Terminate the server process group (idempotent). */
|
|
3534
|
-
async stop() {
|
|
3535
|
-
const child = this.child;
|
|
3536
|
-
if (!child) return;
|
|
3537
|
-
this.child = null;
|
|
3538
|
-
await new Promise((resolve) => {
|
|
3539
|
-
let exited = false;
|
|
3540
|
-
child.on("exit", () => {
|
|
3541
|
-
exited = true;
|
|
3542
|
-
resolve();
|
|
3543
|
-
});
|
|
3544
|
-
if (child.exitCode !== null || child.signalCode !== null) {
|
|
3545
|
-
resolve();
|
|
3546
|
-
return;
|
|
3547
|
-
}
|
|
3548
|
-
this.kill(child, "SIGTERM");
|
|
3549
|
-
setTimeout(() => {
|
|
3550
|
-
if (!exited) {
|
|
3551
|
-
this.kill(child, "SIGKILL");
|
|
3552
|
-
resolve();
|
|
3553
|
-
}
|
|
3554
|
-
}, KILL_GRACE_MS).unref?.();
|
|
3555
|
-
});
|
|
3556
|
-
}
|
|
3557
|
-
/** Group-aware kill: signal the process group, fall back to the child. */
|
|
3558
|
-
kill(child, signal) {
|
|
3559
|
-
if (child.pid !== void 0 && process.platform !== "win32") try {
|
|
3560
|
-
process.kill(-child.pid, signal);
|
|
3561
|
-
return;
|
|
3562
|
-
} catch {}
|
|
3563
|
-
child.kill(signal);
|
|
3564
|
-
}
|
|
3565
|
-
};
|
|
3566
|
-
//#endregion
|
|
3567
2486
|
//#region src/core/specification/mobile/appium-server.ts
|
|
3568
2487
|
const APPIUM_READY_TIMEOUT_MS = 6e4;
|
|
3569
2488
|
/**
|