@jterrazz/test 14.0.0 → 14.0.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/index.d.ts +3 -2
- package/dist/literate.js +15 -10
- package/dist/oxlint.cjs +18 -4
- package/dist/oxlint.js +18 -4
- package/dist/spec-document.js +83 -4
- package/package.json +1 -1
package/dist/index.d.ts
CHANGED
|
@@ -948,8 +948,9 @@ declare class HttpResult extends BaseResult {
|
|
|
948
948
|
* for each run, {@link ServeAdapter} for `serve:`, the `{{token}}` engine for
|
|
949
949
|
* every comparison, and {@link mergeTextPreservingPlaceholders} for the
|
|
950
950
|
* rewrite. What is new here is only the ORCHESTRATION the chain cannot express:
|
|
951
|
-
* several commands sharing one working directory, each
|
|
952
|
-
*
|
|
951
|
+
* several commands sharing one working directory, each judged the moment it
|
|
952
|
+
* ends — so what a run states about that directory is what IT left there, and
|
|
953
|
+
* the first run to disagree is the one the failure names.
|
|
953
954
|
*/
|
|
954
955
|
/**
|
|
955
956
|
* A server a document may start by name (`serve: mcp`), registered once per app
|
package/dist/literate.js
CHANGED
|
@@ -1547,7 +1547,11 @@ function atLine(error, filePath, line) {
|
|
|
1547
1547
|
error.stack = `${error.name}: ${error.message}\n at ${filePath}:${line}:1`;
|
|
1548
1548
|
return error;
|
|
1549
1549
|
}
|
|
1550
|
-
/**
|
|
1550
|
+
/**
|
|
1551
|
+
* Compare one run against what the command actually did — its exit code, its
|
|
1552
|
+
* streams and its `files:`, all read at the same instant: the moment that
|
|
1553
|
+
* command returned.
|
|
1554
|
+
*/
|
|
1551
1555
|
function assertRun(document, outcome, context, workDir, scope) {
|
|
1552
1556
|
const { actual, run } = outcome;
|
|
1553
1557
|
if (actual.exitCode !== run.exitCode) throw failure(document, run, context, [[
|
|
@@ -1682,9 +1686,9 @@ async function runSpecDocument(options) {
|
|
|
1682
1686
|
...servers.env,
|
|
1683
1687
|
...documentEnv(document, config, displayPath)
|
|
1684
1688
|
}, workDir);
|
|
1685
|
-
|
|
1689
|
+
const rewriting = shouldUpdateSnapshots() && options.frozen !== true;
|
|
1690
|
+
const outcomes = [];
|
|
1686
1691
|
try {
|
|
1687
|
-
outcomes = [];
|
|
1688
1692
|
for (const run of document.runs) {
|
|
1689
1693
|
const actual = run.waitFor === null ? await config.command.exec(run.command, workDir, env, {
|
|
1690
1694
|
stdin: run.stdin ?? void 0,
|
|
@@ -1693,21 +1697,22 @@ async function runSpecDocument(options) {
|
|
|
1693
1697
|
timeout: run.timeout ?? void 0,
|
|
1694
1698
|
waitFor: run.waitFor
|
|
1695
1699
|
}, env);
|
|
1696
|
-
|
|
1700
|
+
const outcome = {
|
|
1697
1701
|
actual,
|
|
1698
1702
|
run,
|
|
1699
1703
|
stderr: comparable(actual.stderr, config.transform),
|
|
1700
1704
|
stdout: comparable(actual.stdout, config.transform)
|
|
1701
|
-
}
|
|
1705
|
+
};
|
|
1706
|
+
outcomes.push(outcome);
|
|
1707
|
+
if (!rewriting) assertRun(document, outcome, {
|
|
1708
|
+
displayPath,
|
|
1709
|
+
filePath
|
|
1710
|
+
}, workDir, scope);
|
|
1702
1711
|
}
|
|
1703
1712
|
} finally {
|
|
1704
1713
|
await servers.stop();
|
|
1705
1714
|
}
|
|
1706
|
-
if (
|
|
1707
|
-
else for (const outcome of outcomes) assertRun(document, outcome, {
|
|
1708
|
-
displayPath,
|
|
1709
|
-
filePath
|
|
1710
|
-
}, workDir, scope);
|
|
1715
|
+
if (rewriting) writeFileSync(filePath, updateSpecFile(file, updates(outcomes, scope)));
|
|
1711
1716
|
return new CliResult({
|
|
1712
1717
|
commandOutput: outcomes.at(-1).actual,
|
|
1713
1718
|
config,
|
package/dist/oxlint.cjs
CHANGED
|
@@ -341,7 +341,7 @@ const RULE_DOCS = {
|
|
|
341
341
|
},
|
|
342
342
|
"c1-domain-structure": {
|
|
343
343
|
channel: "statique",
|
|
344
|
-
convention: "La forme de l’arbre `specs/` se déclare via l’option `depth` : `facet-domain` (défaut — `*.test.ts` à la profondeur facet/domain, `*.specification.ts` au root de la facette), `facet` (test au root de la facette OU un dossier domaine plus bas, jamais plus profond), `mirror` (test à toute profondeur ≥ 1, nommé d’après son dossier), `off`. Dans tous les modes, `off` compris : un dossier à underscore initial est du SOL, jamais un domaine — aucune spec ne vit dedans.",
|
|
344
|
+
convention: "La forme de l’arbre `specs/` se déclare via l’option `depth` : `facet-domain` (défaut — `*.test.ts` à la profondeur facet/domain, `*.specification.ts` au root de la facette), `facet` (test au root de la facette OU un dossier domaine plus bas, jamais plus profond), `mirror` (test à toute profondeur ≥ 1, nommé d’après son dossier), `off`. Dans tous les modes, `off` compris : un dossier à underscore initial est du SOL, jamais un domaine — aucune spec ne vit dedans. Une exception, et une seule : le sol peut être du CODE, et un `<module>.test.ts` posé À CÔTÉ du `<module>.ts` qu’il teste y est légal (c’est la loi I2) ; un test sans module voisin, ou un `*.specification.ts`, reste une violation.",
|
|
345
345
|
family: "C",
|
|
346
346
|
id: "C1",
|
|
347
347
|
rationale: "Une profondeur fixe rend la place de chaque fichier prévisible et détectable statiquement."
|
|
@@ -1604,6 +1604,14 @@ const TEST_SUFFIX = ".test.ts";
|
|
|
1604
1604
|
const SPECIFICATION_SUFFIX = ".specification.ts";
|
|
1605
1605
|
const DEFAULT_DEPTH = "facet-domain";
|
|
1606
1606
|
/**
|
|
1607
|
+
* Whether a file is the unit test of the module it sits beside — `x.test.ts`
|
|
1608
|
+
* next to `x.ts`, which is the whole of I2's pairing. This is what tells a
|
|
1609
|
+
* ground MODULE's test from a spec that wandered into the ground.
|
|
1610
|
+
*/
|
|
1611
|
+
function testsItsNeighbour(filename) {
|
|
1612
|
+
return filename.endsWith(TEST_SUFFIX) && isFile(`${filename.slice(0, -8)}.ts`);
|
|
1613
|
+
}
|
|
1614
|
+
/**
|
|
1607
1615
|
* CONVENTIONS C1' — the shape of a spec tree, DECLARED by the consumer.
|
|
1608
1616
|
*
|
|
1609
1617
|
* A project states which shape its `specs/` tree has; the rule checks that one.
|
|
@@ -1649,9 +1657,15 @@ const DEFAULT_DEPTH = "facet-domain";
|
|
|
1649
1657
|
*
|
|
1650
1658
|
* The ground clause holds in every mode, `off` included: a directory whose name
|
|
1651
1659
|
* carries a LEADING UNDERSCORE is ground — what the specs of a row stand on —
|
|
1652
|
-
* and never a domain, so no
|
|
1660
|
+
* and never a domain, so no SPEC may live inside one. That is not a question of
|
|
1653
1661
|
* depth, which is why no project's declared shape switches it off.
|
|
1654
1662
|
*
|
|
1663
|
+
* Ground is not always inert, though: it may be CODE the specs stand on — the
|
|
1664
|
+
* build of the subject under test, a harness the runner spawns. A `<module>.ts`
|
|
1665
|
+
* there keeps its own unit test under I2's law, `<module>.test.ts` NEXT to it,
|
|
1666
|
+
* and the clause lets that one pair through. Nothing else: a test with no module
|
|
1667
|
+
* beside it, or a `*.specification.ts`, is a spec that wandered into the ground.
|
|
1668
|
+
*
|
|
1655
1669
|
* Module tests under `src/` follow the neighbour rule (I2) and are out of scope
|
|
1656
1670
|
* in every mode.
|
|
1657
1671
|
*/
|
|
@@ -1666,7 +1680,7 @@ const c1DomainStructure = {
|
|
|
1666
1680
|
if (base.endsWith(TEST_SUFFIX) || base.endsWith(SPECIFICATION_SUFFIX)) {
|
|
1667
1681
|
const ground = anchor.relative.slice(0, -1).find((segment) => segment.startsWith("_"));
|
|
1668
1682
|
if (ground !== void 0) {
|
|
1669
|
-
context.report({
|
|
1683
|
+
if (!testsItsNeighbour(context.filename)) context.report({
|
|
1670
1684
|
data: { ground },
|
|
1671
1685
|
messageId: "specInGround",
|
|
1672
1686
|
node
|
|
@@ -1733,7 +1747,7 @@ const c1DomainStructure = {
|
|
|
1733
1747
|
defaultOptions: [{ depth: DEFAULT_DEPTH }],
|
|
1734
1748
|
docs: RULE_DOCS["c1-domain-structure"],
|
|
1735
1749
|
messages: {
|
|
1736
|
-
specInGround: "A spec must not live under \"{{ground}}/\" — a leading underscore marks GROUND (what the specs of a row stand on: _fixtures/, _expected/, _requests/, _seeds/), never a domain (C1 — see docs/10-linting.md).",
|
|
1750
|
+
specInGround: "A spec must not live under \"{{ground}}/\" — a leading underscore marks GROUND (what the specs of a row stand on: _fixtures/, _expected/, _requests/, _seeds/), never a domain (C1 — see docs/10-linting.md). Ground that is CODE keeps its own unit test: `<module>.test.ts` NEXT to the `<module>.ts` it tests (I2), and nothing else.",
|
|
1737
1751
|
specNotAtFacetRoot: "A `*.specification.ts` must sit at the facet root: `specs/<facet>/<name>.specification.ts` (C1 — see docs/10-linting.md).",
|
|
1738
1752
|
testAtFacetRoot: "A `*.test.ts` must live in a domain folder: `specs/<facet>/<domain>/<aspect>.test.ts` — tests directly at the facet root are forbidden (C1 — see docs/10-linting.md).",
|
|
1739
1753
|
testAtSpecsRoot: "A `*.test.ts` must live in a directory under specs/ — with `depth: \"mirror\"` the tree mirrors a structure, and the specs root mirrors nothing (C1 — see docs/10-linting.md).",
|
package/dist/oxlint.js
CHANGED
|
@@ -337,7 +337,7 @@ const RULE_DOCS = {
|
|
|
337
337
|
},
|
|
338
338
|
"c1-domain-structure": {
|
|
339
339
|
channel: "statique",
|
|
340
|
-
convention: "La forme de l’arbre `specs/` se déclare via l’option `depth` : `facet-domain` (défaut — `*.test.ts` à la profondeur facet/domain, `*.specification.ts` au root de la facette), `facet` (test au root de la facette OU un dossier domaine plus bas, jamais plus profond), `mirror` (test à toute profondeur ≥ 1, nommé d’après son dossier), `off`. Dans tous les modes, `off` compris : un dossier à underscore initial est du SOL, jamais un domaine — aucune spec ne vit dedans.",
|
|
340
|
+
convention: "La forme de l’arbre `specs/` se déclare via l’option `depth` : `facet-domain` (défaut — `*.test.ts` à la profondeur facet/domain, `*.specification.ts` au root de la facette), `facet` (test au root de la facette OU un dossier domaine plus bas, jamais plus profond), `mirror` (test à toute profondeur ≥ 1, nommé d’après son dossier), `off`. Dans tous les modes, `off` compris : un dossier à underscore initial est du SOL, jamais un domaine — aucune spec ne vit dedans. Une exception, et une seule : le sol peut être du CODE, et un `<module>.test.ts` posé À CÔTÉ du `<module>.ts` qu’il teste y est légal (c’est la loi I2) ; un test sans module voisin, ou un `*.specification.ts`, reste une violation.",
|
|
341
341
|
family: "C",
|
|
342
342
|
id: "C1",
|
|
343
343
|
rationale: "Une profondeur fixe rend la place de chaque fichier prévisible et détectable statiquement."
|
|
@@ -1600,6 +1600,14 @@ const TEST_SUFFIX = ".test.ts";
|
|
|
1600
1600
|
const SPECIFICATION_SUFFIX = ".specification.ts";
|
|
1601
1601
|
const DEFAULT_DEPTH = "facet-domain";
|
|
1602
1602
|
/**
|
|
1603
|
+
* Whether a file is the unit test of the module it sits beside — `x.test.ts`
|
|
1604
|
+
* next to `x.ts`, which is the whole of I2's pairing. This is what tells a
|
|
1605
|
+
* ground MODULE's test from a spec that wandered into the ground.
|
|
1606
|
+
*/
|
|
1607
|
+
function testsItsNeighbour(filename) {
|
|
1608
|
+
return filename.endsWith(TEST_SUFFIX) && isFile(`${filename.slice(0, -8)}.ts`);
|
|
1609
|
+
}
|
|
1610
|
+
/**
|
|
1603
1611
|
* CONVENTIONS C1' — the shape of a spec tree, DECLARED by the consumer.
|
|
1604
1612
|
*
|
|
1605
1613
|
* A project states which shape its `specs/` tree has; the rule checks that one.
|
|
@@ -1645,9 +1653,15 @@ const DEFAULT_DEPTH = "facet-domain";
|
|
|
1645
1653
|
*
|
|
1646
1654
|
* The ground clause holds in every mode, `off` included: a directory whose name
|
|
1647
1655
|
* carries a LEADING UNDERSCORE is ground — what the specs of a row stand on —
|
|
1648
|
-
* and never a domain, so no
|
|
1656
|
+
* and never a domain, so no SPEC may live inside one. That is not a question of
|
|
1649
1657
|
* depth, which is why no project's declared shape switches it off.
|
|
1650
1658
|
*
|
|
1659
|
+
* Ground is not always inert, though: it may be CODE the specs stand on — the
|
|
1660
|
+
* build of the subject under test, a harness the runner spawns. A `<module>.ts`
|
|
1661
|
+
* there keeps its own unit test under I2's law, `<module>.test.ts` NEXT to it,
|
|
1662
|
+
* and the clause lets that one pair through. Nothing else: a test with no module
|
|
1663
|
+
* beside it, or a `*.specification.ts`, is a spec that wandered into the ground.
|
|
1664
|
+
*
|
|
1651
1665
|
* Module tests under `src/` follow the neighbour rule (I2) and are out of scope
|
|
1652
1666
|
* in every mode.
|
|
1653
1667
|
*/
|
|
@@ -1662,7 +1676,7 @@ const c1DomainStructure = {
|
|
|
1662
1676
|
if (base.endsWith(TEST_SUFFIX) || base.endsWith(SPECIFICATION_SUFFIX)) {
|
|
1663
1677
|
const ground = anchor.relative.slice(0, -1).find((segment) => segment.startsWith("_"));
|
|
1664
1678
|
if (ground !== void 0) {
|
|
1665
|
-
context.report({
|
|
1679
|
+
if (!testsItsNeighbour(context.filename)) context.report({
|
|
1666
1680
|
data: { ground },
|
|
1667
1681
|
messageId: "specInGround",
|
|
1668
1682
|
node
|
|
@@ -1729,7 +1743,7 @@ const c1DomainStructure = {
|
|
|
1729
1743
|
defaultOptions: [{ depth: DEFAULT_DEPTH }],
|
|
1730
1744
|
docs: RULE_DOCS["c1-domain-structure"],
|
|
1731
1745
|
messages: {
|
|
1732
|
-
specInGround: "A spec must not live under \"{{ground}}/\" — a leading underscore marks GROUND (what the specs of a row stand on: _fixtures/, _expected/, _requests/, _seeds/), never a domain (C1 — see docs/10-linting.md).",
|
|
1746
|
+
specInGround: "A spec must not live under \"{{ground}}/\" — a leading underscore marks GROUND (what the specs of a row stand on: _fixtures/, _expected/, _requests/, _seeds/), never a domain (C1 — see docs/10-linting.md). Ground that is CODE keeps its own unit test: `<module>.test.ts` NEXT to the `<module>.ts` it tests (I2), and nothing else.",
|
|
1733
1747
|
specNotAtFacetRoot: "A `*.specification.ts` must sit at the facet root: `specs/<facet>/<name>.specification.ts` (C1 — see docs/10-linting.md).",
|
|
1734
1748
|
testAtFacetRoot: "A `*.test.ts` must live in a domain folder: `specs/<facet>/<domain>/<aspect>.test.ts` — tests directly at the facet root are forbidden (C1 — see docs/10-linting.md).",
|
|
1735
1749
|
testAtSpecsRoot: "A `*.test.ts` must live in a directory under specs/ — with `depth: \"mirror\"` the tree mirrors a structure, and the specs root mirrors nothing (C1 — see docs/10-linting.md).",
|
package/dist/spec-document.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { LineCounter, Scalar, Scalar as Scalar$1, isMap, isScalar, isSeq, parseDocument } from "yaml";
|
|
1
|
+
import { LineCounter, Scalar, Scalar as Scalar$1, isMap, isScalar, isSeq, parseDocument, visit } from "yaml";
|
|
2
2
|
//#region src/integrations/yaml/document.ts
|
|
3
3
|
/** A line that opens a mapping key — ` key:` or ` - key:`, never block-scalar text. */
|
|
4
4
|
const KEY_LINE = /^(?<indent> +)(?:- )?[A-Za-z_$'"][^\n]*:/;
|
|
@@ -65,7 +65,8 @@ function parseYamlSource(text) {
|
|
|
65
65
|
lineCounter
|
|
66
66
|
}),
|
|
67
67
|
indent: detectIndent(text),
|
|
68
|
-
lineAt: (offset) => lineCounter.linePos(offset).line
|
|
68
|
+
lineAt: (offset) => lineCounter.linePos(offset).line,
|
|
69
|
+
text
|
|
69
70
|
};
|
|
70
71
|
}
|
|
71
72
|
/** The YAML parser's own errors, with their line — an empty list when it parsed. */
|
|
@@ -88,16 +89,94 @@ function blockScalar(text) {
|
|
|
88
89
|
scalar.type = Scalar.BLOCK_LITERAL;
|
|
89
90
|
return scalar;
|
|
90
91
|
}
|
|
92
|
+
/** The first offset of the line `offset` sits on. */
|
|
93
|
+
function lineStart(text, offset) {
|
|
94
|
+
return text.lastIndexOf("\n", offset - 1) + 1;
|
|
95
|
+
}
|
|
96
|
+
/** Back over the whitespace before `offset`, to just past the `:` or `-` that owns it. */
|
|
97
|
+
function afterOwner(text, offset) {
|
|
98
|
+
let at = offset;
|
|
99
|
+
while (at > 0 && /\s/.test(text[at - 1])) at -= 1;
|
|
100
|
+
return at;
|
|
101
|
+
}
|
|
102
|
+
/** How many spaces a line opens with. */
|
|
103
|
+
function indentOf(text, offset) {
|
|
104
|
+
const start = lineStart(text, offset);
|
|
105
|
+
return /^ */.exec(text.slice(start))[0].length;
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Every OUTERMOST flow collection of a document, in source order. A nested one
|
|
109
|
+
* is part of its parent's text and is restored with it, so descending past a
|
|
110
|
+
* flow collection would splice the same span twice.
|
|
111
|
+
*/
|
|
112
|
+
function flowCollections(text) {
|
|
113
|
+
const found = [];
|
|
114
|
+
const take = (node) => {
|
|
115
|
+
if (node.flow !== true || !node.range) return;
|
|
116
|
+
const from = afterOwner(text, node.range[0]);
|
|
117
|
+
found.push({
|
|
118
|
+
ownerIndent: indentOf(text, from === 0 ? 0 : from - 1),
|
|
119
|
+
range: [from, node.range[1]],
|
|
120
|
+
text: text.slice(from, node.range[1]),
|
|
121
|
+
value: JSON.stringify(node.toJSON())
|
|
122
|
+
});
|
|
123
|
+
return visit.SKIP;
|
|
124
|
+
};
|
|
125
|
+
visit(parseDocument(text), {
|
|
126
|
+
Map: (_key, node) => take(node),
|
|
127
|
+
Seq: (_key, node) => take(node)
|
|
128
|
+
});
|
|
129
|
+
return found;
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Give every flow collection the rewrite did not change back exactly as it was
|
|
133
|
+
* written — padding, quotes and line breaks included.
|
|
134
|
+
*
|
|
135
|
+
* The `yaml` writer has ONE house style for `{ … }` and `[ … ]`, and it is not
|
|
136
|
+
* the repository formatter's: oxfmt pads a flow mapping and not a flow sequence,
|
|
137
|
+
* and explodes a long one over several lines, while the writer emits `[ 'a' ]`
|
|
138
|
+
* on one line whatever its width. Re-emitting an untouched collection therefore
|
|
139
|
+
* hands the formatter something to undo, and `check --fix` and `oxfmt` rewrite
|
|
140
|
+
* each other for ever. A collection nobody touched belongs to its author, not to
|
|
141
|
+
* either tool.
|
|
142
|
+
*
|
|
143
|
+
* A rendered collection is matched to a source one by VALUE and by the
|
|
144
|
+
* INDENTATION of the line that owns it: the same content under the same key is
|
|
145
|
+
* the same collection, and the indentation guard is what keeps the inner lines
|
|
146
|
+
* of a multi-line spelling true where a fix moved that key. Anything unmatched
|
|
147
|
+
* — added, removed, edited — keeps the writer's rendering.
|
|
148
|
+
*/
|
|
149
|
+
function restoreUntouchedFlow(rendered, text) {
|
|
150
|
+
const sources = flowCollections(text);
|
|
151
|
+
if (sources.length === 0) return rendered;
|
|
152
|
+
const taken = /* @__PURE__ */ new Set();
|
|
153
|
+
const splices = [];
|
|
154
|
+
for (const target of flowCollections(rendered)) {
|
|
155
|
+
const at = sources.findIndex((candidate, index) => !taken.has(index) && candidate.value === target.value && candidate.ownerIndent === target.ownerIndent);
|
|
156
|
+
if (at === -1) continue;
|
|
157
|
+
taken.add(at);
|
|
158
|
+
splices.push({
|
|
159
|
+
range: target.range,
|
|
160
|
+
text: sources[at].text
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
let out = rendered;
|
|
164
|
+
for (const splice of splices.toReversed()) out = out.slice(0, splice.range[0]) + splice.text + out.slice(splice.range[1]);
|
|
165
|
+
return out;
|
|
166
|
+
}
|
|
91
167
|
/**
|
|
92
168
|
* Render a document back to text, in the indentation it was read with.
|
|
93
169
|
* `lineWidth: 0` disables the folding of long lines — a golden is compared byte
|
|
94
170
|
* for byte, so nothing may decide on its own where to break one.
|
|
171
|
+
*
|
|
172
|
+
* What the caller did not change comes back as it was written — see
|
|
173
|
+
* {@link restoreUntouchedFlow} for the one construct the writer restyles.
|
|
95
174
|
*/
|
|
96
175
|
function renderYamlSource(source) {
|
|
97
|
-
return source.document.toString({
|
|
176
|
+
return restoreUntouchedFlow(source.document.toString({
|
|
98
177
|
indent: source.indent,
|
|
99
178
|
lineWidth: 0
|
|
100
|
-
});
|
|
179
|
+
}), source.text);
|
|
101
180
|
}
|
|
102
181
|
//#endregion
|
|
103
182
|
//#region src/core/literate/spec-document.ts
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jterrazz/test",
|
|
3
|
-
"version": "14.0.
|
|
3
|
+
"version": "14.0.1",
|
|
4
4
|
"description": "Declarative testing framework for HTTP APIs, CLIs, and background jobs — one entry point, real infrastructure, golden-file assertions, plus an oxlint convention plugin.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"api-testing",
|