@glubean/runner 0.3.2 → 0.4.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/executor.d.ts +50 -0
- package/dist/executor.d.ts.map +1 -1
- package/dist/executor.js +6 -0
- package/dist/executor.js.map +1 -1
- package/dist/generate_summary.d.ts.map +1 -1
- package/dist/generate_summary.js +9 -0
- package/dist/generate_summary.js.map +1 -1
- package/dist/harness.js +568 -114
- package/dist/harness.js.map +1 -1
- package/package.json +3 -3
package/dist/harness.js
CHANGED
|
@@ -12,6 +12,7 @@ import { bootstrap } from "./bootstrap.js";
|
|
|
12
12
|
import { loadProjectOverlays } from "@glubean/scanner";
|
|
13
13
|
import { setRuntime, setExplicitInput, setBootstrapInput, setForceStandalone, } from "@glubean/sdk/internal";
|
|
14
14
|
import ky from "ky";
|
|
15
|
+
import { isTestBranchStep, isTestPollStep } from "@glubean/sdk";
|
|
15
16
|
import { Expectation } from "@glubean/sdk/expect";
|
|
16
17
|
// Global error handlers for async errors that escape try/catch
|
|
17
18
|
process.on("uncaughtException", (error) => {
|
|
@@ -590,6 +591,10 @@ const ctx = {
|
|
|
590
591
|
passed: false,
|
|
591
592
|
message,
|
|
592
593
|
});
|
|
594
|
+
// Also bump the step-failed-assertion counter (like ctx.assert) so the
|
|
595
|
+
// failure is recorded even if the FailError is caught — e.g. inside a branch
|
|
596
|
+
// predicate that swallows it; the step/branch must still fail.
|
|
597
|
+
incrAssertions(false);
|
|
593
598
|
throw new FailError(message);
|
|
594
599
|
},
|
|
595
600
|
/**
|
|
@@ -1509,6 +1514,14 @@ async function executeNewTest(test) {
|
|
|
1509
1514
|
else {
|
|
1510
1515
|
let state = undefined;
|
|
1511
1516
|
let stepFailed = false;
|
|
1517
|
+
// First branch-decision failure message (predicate/lens threw or failed
|
|
1518
|
+
// an assertion). A branch decision has no `step_end`, so result
|
|
1519
|
+
// renderers that surface step/status errors would otherwise lose it;
|
|
1520
|
+
// we promote it into the final status error below.
|
|
1521
|
+
let branchDecisionError;
|
|
1522
|
+
// Set when a step calls ctx.skip(): skips the remaining steps and
|
|
1523
|
+
// marks the whole test as skipped (not failed) after teardown runs.
|
|
1524
|
+
let skipRequest;
|
|
1512
1525
|
try {
|
|
1513
1526
|
if (test.setup) {
|
|
1514
1527
|
emitEvent({
|
|
@@ -1518,22 +1531,267 @@ async function executeNewTest(test) {
|
|
|
1518
1531
|
state = await test.setup(effectiveCtx);
|
|
1519
1532
|
}
|
|
1520
1533
|
if (test.steps) {
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1534
|
+
// Monotonic step index across the (possibly branching) execution.
|
|
1535
|
+
// For a branchless test this counts 0,1,2,… == array position, so
|
|
1536
|
+
// non-branch behavior is identical to before. Counts ONLY leaf steps
|
|
1537
|
+
// (step_start/step_end), NOT branch decisions — so these indices line
|
|
1538
|
+
// up 1:1 with the leaves-only registry metadata, and consumers can
|
|
1539
|
+
// join runtime step events to discovered steps by index.
|
|
1540
|
+
let stepSeq = 0;
|
|
1541
|
+
// Separate ordinal for branch decision events (kept out of the leaf
|
|
1542
|
+
// step-index space so it can't desync leaf indices from metadata).
|
|
1543
|
+
let branchSeq = 0;
|
|
1544
|
+
// "step N of TOTAL" denominator: every LEAF step in the whole tree
|
|
1545
|
+
// (branch decisions are not steps). A leaf emits exactly once on any
|
|
1546
|
+
// path, so `stepSeq` indices are 0..leafTotal-1 and `index < total`
|
|
1547
|
+
// always holds. Branchless → test.steps.length (unchanged).
|
|
1548
|
+
const leafTotal = (list) => {
|
|
1549
|
+
let n = 0;
|
|
1550
|
+
for (const s of list) {
|
|
1551
|
+
if (isTestBranchStep(s)) {
|
|
1552
|
+
for (const c of s.branch.cases)
|
|
1553
|
+
n += leafTotal(c.steps);
|
|
1554
|
+
n += leafTotal(s.branch.default);
|
|
1555
|
+
}
|
|
1556
|
+
else {
|
|
1557
|
+
n += 1;
|
|
1558
|
+
}
|
|
1559
|
+
}
|
|
1560
|
+
return n;
|
|
1561
|
+
};
|
|
1562
|
+
const stepTotal = leafTotal(test.steps);
|
|
1563
|
+
// Emit a skipped step_end for every leaf step in a list, recursing
|
|
1564
|
+
// into branch sub-steps. Used by the skip-cascade and for the
|
|
1565
|
+
// non-taken cases/default of a branch.
|
|
1566
|
+
const emitSkippedTree = (list) => {
|
|
1567
|
+
for (const s of list) {
|
|
1568
|
+
if (isTestBranchStep(s)) {
|
|
1569
|
+
for (const c of s.branch.cases)
|
|
1570
|
+
emitSkippedTree(c.steps);
|
|
1571
|
+
emitSkippedTree(s.branch.default);
|
|
1572
|
+
}
|
|
1573
|
+
else {
|
|
1574
|
+
emitEvent({
|
|
1575
|
+
type: "step_end",
|
|
1576
|
+
index: stepSeq++,
|
|
1577
|
+
name: s.meta.name,
|
|
1578
|
+
status: "skipped",
|
|
1579
|
+
durationMs: 0,
|
|
1580
|
+
assertions: 0,
|
|
1581
|
+
failedAssertions: 0,
|
|
1582
|
+
});
|
|
1583
|
+
}
|
|
1584
|
+
}
|
|
1585
|
+
};
|
|
1586
|
+
// Recursive step-list runner. Branch sub-steps run through the same
|
|
1587
|
+
// path (first-class steps, incremental state commit), mirroring the
|
|
1588
|
+
// flow-side `runSteps` so teardown always sees the last committed state.
|
|
1589
|
+
const runStepList = async (list) => {
|
|
1590
|
+
for (const step of list) {
|
|
1591
|
+
// If a previous step failed (or a step called ctx.skip()),
|
|
1592
|
+
// skip the remaining steps (and all their branch descendants).
|
|
1593
|
+
if (stepFailed || skipRequest) {
|
|
1594
|
+
emitSkippedTree([step]);
|
|
1595
|
+
continue;
|
|
1596
|
+
}
|
|
1597
|
+
// Branch step (condition / switchOn / switchCond): evaluate the
|
|
1598
|
+
// decision, run the taken case's sub-steps, skip the rest.
|
|
1599
|
+
if (isTestBranchStep(step)) {
|
|
1600
|
+
await runBranchStep(step);
|
|
1601
|
+
continue;
|
|
1602
|
+
}
|
|
1603
|
+
// Poll step (test().poll): bounded retry of the attempt fn until
|
|
1604
|
+
// the exit predicate holds (or a bound exhausts → the step fails).
|
|
1605
|
+
if (isTestPollStep(step)) {
|
|
1606
|
+
await runPollStep(step);
|
|
1607
|
+
continue;
|
|
1608
|
+
}
|
|
1609
|
+
const i = stepSeq++;
|
|
1610
|
+
// Reset per-step assertion counters and set step scope
|
|
1611
|
+
{
|
|
1612
|
+
const trc = currentTestCtx();
|
|
1613
|
+
trc.stepFailedAssertions = 0;
|
|
1614
|
+
trc.stepAssertionTotal = 0;
|
|
1615
|
+
trc.currentStepIndex = i;
|
|
1616
|
+
}
|
|
1617
|
+
const stepStart = performance.now();
|
|
1618
|
+
emitEvent({
|
|
1619
|
+
type: "step_start",
|
|
1620
|
+
index: i,
|
|
1621
|
+
name: step.meta.name,
|
|
1622
|
+
total: stepTotal,
|
|
1623
|
+
});
|
|
1624
|
+
let stepError;
|
|
1625
|
+
let stepReturnState = undefined;
|
|
1626
|
+
const retries = step.meta.retries;
|
|
1627
|
+
const configuredRetries = typeof retries === "number" && Number.isFinite(retries)
|
|
1628
|
+
? Math.max(0, Math.floor(retries))
|
|
1629
|
+
: 0;
|
|
1630
|
+
const retryDelayMs = typeof step.meta.retryDelay === "number" && Number.isFinite(step.meta.retryDelay)
|
|
1631
|
+
? Math.max(0, step.meta.retryDelay)
|
|
1632
|
+
: (configuredRetries > 0 ? 1000 : 0);
|
|
1633
|
+
const backoffMultiplier = typeof step.meta.backoff === "number" && Number.isFinite(step.meta.backoff)
|
|
1634
|
+
? Math.max(1, step.meta.backoff)
|
|
1635
|
+
: 1;
|
|
1636
|
+
const stepTimeout = step.meta.timeout;
|
|
1637
|
+
const configuredStepTimeout = typeof stepTimeout === "number" && Number.isFinite(stepTimeout)
|
|
1638
|
+
? Math.floor(stepTimeout)
|
|
1639
|
+
: 0;
|
|
1640
|
+
const stepTimeoutMs = configuredStepTimeout > 0 ? configuredStepTimeout : undefined;
|
|
1641
|
+
const maxAttempts = configuredRetries + 1;
|
|
1642
|
+
let attemptsUsed = 0;
|
|
1643
|
+
let lastFailedAssertions = 0;
|
|
1644
|
+
let lastAssertions = 0;
|
|
1645
|
+
let timeoutFailure = false;
|
|
1646
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
1647
|
+
attemptsUsed = attempt;
|
|
1648
|
+
stepError = undefined;
|
|
1649
|
+
stepReturnState = undefined;
|
|
1650
|
+
{
|
|
1651
|
+
const trc = currentTestCtx();
|
|
1652
|
+
trc.stepFailedAssertions = 0;
|
|
1653
|
+
trc.stepAssertionTotal = 0;
|
|
1654
|
+
}
|
|
1655
|
+
timeoutFailure = false;
|
|
1656
|
+
let stepTimeoutId;
|
|
1657
|
+
try {
|
|
1658
|
+
const stepResult = step.fn(effectiveCtx, state);
|
|
1659
|
+
// Note: timed-out step bodies cannot be force-cancelled in JS.
|
|
1660
|
+
// We treat timeout as terminal (no further retries) to avoid
|
|
1661
|
+
// overlapping attempts mutating shared step context.
|
|
1662
|
+
const result = stepTimeoutMs === undefined ? await stepResult : await Promise.race([
|
|
1663
|
+
stepResult,
|
|
1664
|
+
new Promise((_, reject) => {
|
|
1665
|
+
stepTimeoutId = setTimeout(() => {
|
|
1666
|
+
reject(new StepTimeoutError(step.meta.name, stepTimeoutMs));
|
|
1667
|
+
}, stepTimeoutMs);
|
|
1668
|
+
}),
|
|
1669
|
+
]);
|
|
1670
|
+
if (result !== undefined) {
|
|
1671
|
+
state = result;
|
|
1672
|
+
stepReturnState = result;
|
|
1673
|
+
}
|
|
1674
|
+
}
|
|
1675
|
+
catch (err) {
|
|
1676
|
+
if (err instanceof SkipError) {
|
|
1677
|
+
// ctx.skip() inside a step skips the WHOLE test. It is
|
|
1678
|
+
// not a step failure and must not be retried.
|
|
1679
|
+
skipRequest = err;
|
|
1680
|
+
}
|
|
1681
|
+
else {
|
|
1682
|
+
stepError = err instanceof Error ? err.message : String(err);
|
|
1683
|
+
timeoutFailure = err instanceof StepTimeoutError;
|
|
1684
|
+
}
|
|
1685
|
+
}
|
|
1686
|
+
finally {
|
|
1687
|
+
if (stepTimeoutId !== undefined) {
|
|
1688
|
+
clearTimeout(stepTimeoutId);
|
|
1689
|
+
}
|
|
1690
|
+
}
|
|
1691
|
+
lastFailedAssertions = getStepFailedAssertions();
|
|
1692
|
+
lastAssertions = getStepAssertionTotal();
|
|
1693
|
+
// Skip is terminal — stop attempting, never retry a skip.
|
|
1694
|
+
if (skipRequest) {
|
|
1695
|
+
break;
|
|
1696
|
+
}
|
|
1697
|
+
const attemptFailed = !!stepError || getStepFailedAssertions() > 0;
|
|
1698
|
+
if (!attemptFailed) {
|
|
1699
|
+
break;
|
|
1700
|
+
}
|
|
1701
|
+
// Timeouts are terminal to avoid overlapping attempts from
|
|
1702
|
+
// dangling async operations in the timed-out step body.
|
|
1703
|
+
if (timeoutFailure) {
|
|
1704
|
+
break;
|
|
1705
|
+
}
|
|
1706
|
+
if (attempt < maxAttempts) {
|
|
1707
|
+
const delay = Math.min(retryDelayMs * backoffMultiplier ** (attempt - 1), 30_000);
|
|
1708
|
+
const reason = stepError ? stepError : `${getStepFailedAssertions()} failed assertion(s)`;
|
|
1709
|
+
emitEvent({
|
|
1710
|
+
type: "log",
|
|
1711
|
+
stepIndex: i,
|
|
1712
|
+
message: `Retrying step "${step.meta.name}" (${attempt + 1}/${maxAttempts}) after failure: ${reason}${delay > 0 ? ` (waiting ${delay}ms)` : ""}`,
|
|
1713
|
+
});
|
|
1714
|
+
if (delay > 0) {
|
|
1715
|
+
await new Promise((r) => setTimeout(r, delay));
|
|
1716
|
+
}
|
|
1717
|
+
}
|
|
1718
|
+
}
|
|
1719
|
+
// ctx.skip() called in this step. If the step already recorded a
|
|
1720
|
+
// failed assertion BEFORE skipping, the failure wins — a skip must
|
|
1721
|
+
// not mask a real failure (consistent with simple-mode tests,
|
|
1722
|
+
// where generateSummary fails on a failed assertion regardless of
|
|
1723
|
+
// a later skip). Otherwise emit a skipped step_end and stop running
|
|
1724
|
+
// steps; the test is reported as skipped after teardown via the
|
|
1725
|
+
// throw below the loop.
|
|
1726
|
+
if (skipRequest && lastFailedAssertions === 0) {
|
|
1727
|
+
emitEvent({
|
|
1728
|
+
type: "step_end",
|
|
1729
|
+
index: i,
|
|
1730
|
+
name: step.meta.name,
|
|
1731
|
+
status: "skipped",
|
|
1732
|
+
durationMs: Math.round(performance.now() - stepStart),
|
|
1733
|
+
assertions: lastAssertions,
|
|
1734
|
+
failedAssertions: 0,
|
|
1735
|
+
attempts: attemptsUsed,
|
|
1736
|
+
retriesUsed: Math.max(0, attemptsUsed - 1),
|
|
1737
|
+
});
|
|
1738
|
+
currentTestCtx().currentStepIndex = null;
|
|
1739
|
+
continue;
|
|
1740
|
+
}
|
|
1741
|
+
// Prior failure overrides the skip — fall through and report this
|
|
1742
|
+
// step (and the test) as failed.
|
|
1743
|
+
skipRequest = undefined;
|
|
1744
|
+
const durationMs = Math.round(performance.now() - stepStart);
|
|
1745
|
+
const failed = !!stepError || lastFailedAssertions > 0;
|
|
1746
|
+
// Serialize return state with size guard (max 4 KB)
|
|
1747
|
+
let returnStatePayload = undefined;
|
|
1748
|
+
if (stepReturnState !== undefined) {
|
|
1749
|
+
try {
|
|
1750
|
+
const serialized = JSON.stringify(stepReturnState);
|
|
1751
|
+
if (serialized.length <= 4096) {
|
|
1752
|
+
returnStatePayload = stepReturnState;
|
|
1753
|
+
}
|
|
1754
|
+
else {
|
|
1755
|
+
returnStatePayload = `[truncated: ${serialized.length} bytes]`;
|
|
1756
|
+
}
|
|
1757
|
+
}
|
|
1758
|
+
catch {
|
|
1759
|
+
returnStatePayload = "[non-serializable]";
|
|
1760
|
+
}
|
|
1761
|
+
}
|
|
1525
1762
|
emitEvent({
|
|
1526
1763
|
type: "step_end",
|
|
1527
1764
|
index: i,
|
|
1528
1765
|
name: step.meta.name,
|
|
1529
|
-
status: "
|
|
1530
|
-
durationMs
|
|
1531
|
-
assertions:
|
|
1532
|
-
failedAssertions:
|
|
1766
|
+
status: failed ? "failed" : "passed",
|
|
1767
|
+
durationMs,
|
|
1768
|
+
assertions: lastAssertions,
|
|
1769
|
+
failedAssertions: lastFailedAssertions,
|
|
1770
|
+
attempts: attemptsUsed,
|
|
1771
|
+
retriesUsed: Math.max(0, attemptsUsed - 1),
|
|
1772
|
+
...(stepError && { error: stepError }),
|
|
1773
|
+
...(returnStatePayload !== undefined && {
|
|
1774
|
+
returnState: returnStatePayload,
|
|
1775
|
+
}),
|
|
1533
1776
|
});
|
|
1534
|
-
|
|
1777
|
+
currentTestCtx().currentStepIndex = null;
|
|
1778
|
+
if (failed) {
|
|
1779
|
+
stepFailed = true;
|
|
1780
|
+
// Don't throw here — let the loop continue to emit skip events
|
|
1781
|
+
}
|
|
1535
1782
|
}
|
|
1536
|
-
|
|
1783
|
+
};
|
|
1784
|
+
// Execute a poll step (test().poll): a first-class leaf step whose
|
|
1785
|
+
// body is a bounded retry of `fn` until `until` holds (or a bound
|
|
1786
|
+
// exhausts → the step fails). Each attempt is RACED against its
|
|
1787
|
+
// budget (best-effort: arbitrary user `fn`/`until` can't be force-
|
|
1788
|
+
// cancelled, but the step never waits past the budget). Emits a
|
|
1789
|
+
// `poll` timeline event (attempts/elapsed/satisfied/exhausted) plus
|
|
1790
|
+
// the normal step_start/step_end. fn/until run against the live ctx
|
|
1791
|
+
// (test-side, like ctx.pollUntil — assertions count).
|
|
1792
|
+
const runPollStep = async (step) => {
|
|
1793
|
+
const poll = step.poll;
|
|
1794
|
+
const i = stepSeq++;
|
|
1537
1795
|
{
|
|
1538
1796
|
const trc = currentTestCtx();
|
|
1539
1797
|
trc.stepFailedAssertions = 0;
|
|
@@ -1541,135 +1799,326 @@ async function executeNewTest(test) {
|
|
|
1541
1799
|
trc.currentStepIndex = i;
|
|
1542
1800
|
}
|
|
1543
1801
|
const stepStart = performance.now();
|
|
1544
|
-
emitEvent({
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
const
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1802
|
+
emitEvent({ type: "step_start", index: i, name: step.meta.name, total: stepTotal });
|
|
1803
|
+
// Local budget sentinel so a budget timeout is distinguishable from
|
|
1804
|
+
// a genuine fn/until error or a SkipError.
|
|
1805
|
+
class PollBudgetTimeout extends Error {
|
|
1806
|
+
}
|
|
1807
|
+
const raceBudget = (p, budgetMs) => {
|
|
1808
|
+
if (!Number.isFinite(budgetMs))
|
|
1809
|
+
return p;
|
|
1810
|
+
return new Promise((resolve, reject) => {
|
|
1811
|
+
const t = setTimeout(() => reject(new PollBudgetTimeout()), Math.max(0, budgetMs));
|
|
1812
|
+
p.then((v) => { clearTimeout(t); resolve(v); }, (e) => { clearTimeout(t); reject(e); });
|
|
1813
|
+
});
|
|
1814
|
+
};
|
|
1815
|
+
const everyMs = poll.every ?? 1000;
|
|
1816
|
+
const backoff = poll.backoff ?? 1;
|
|
1817
|
+
const perAttempt = poll.perAttemptTimeout ?? Infinity;
|
|
1818
|
+
const start = performance.now();
|
|
1819
|
+
const deadline = poll.timeout !== undefined ? start + poll.timeout : Infinity;
|
|
1820
|
+
let attempt = 0;
|
|
1821
|
+
let delay = everyMs;
|
|
1822
|
+
let satisfied = false;
|
|
1823
|
+
let exhausted = false;
|
|
1824
|
+
let pollError;
|
|
1825
|
+
let lastRes;
|
|
1826
|
+
// A poll-phase throw (fn / until / out-mapper) must surface a
|
|
1827
|
+
// non-empty message: pollError is tested for truthiness below to
|
|
1828
|
+
// decide failure AND whether to emit an `error`, so an empty
|
|
1829
|
+
// message (`throw new Error("")` / `throw ""`) would otherwise be
|
|
1830
|
+
// silently treated as a pass.
|
|
1831
|
+
const pollErrMsg = (e) => {
|
|
1832
|
+
const m = e instanceof Error ? e.message : String(e);
|
|
1833
|
+
return m || `poll "${step.meta.name}" threw an empty error`;
|
|
1834
|
+
};
|
|
1835
|
+
for (;;) {
|
|
1836
|
+
attempt += 1;
|
|
1837
|
+
const remainingTotal = deadline - performance.now();
|
|
1838
|
+
if (remainingTotal <= 0) {
|
|
1839
|
+
exhausted = true;
|
|
1840
|
+
break;
|
|
1580
1841
|
}
|
|
1581
|
-
|
|
1582
|
-
|
|
1842
|
+
const attemptBudget = Math.min(perAttempt, remainingTotal);
|
|
1843
|
+
const attemptStart = performance.now();
|
|
1844
|
+
// Attempt fn (best-effort raced).
|
|
1583
1845
|
try {
|
|
1584
|
-
|
|
1585
|
-
// Note: timed-out step bodies cannot be force-cancelled in JS.
|
|
1586
|
-
// We treat timeout as terminal (no further retries) to avoid
|
|
1587
|
-
// overlapping attempts mutating shared step context.
|
|
1588
|
-
const result = stepTimeoutMs === undefined ? await stepResult : await Promise.race([
|
|
1589
|
-
stepResult,
|
|
1590
|
-
new Promise((_, reject) => {
|
|
1591
|
-
stepTimeoutId = setTimeout(() => {
|
|
1592
|
-
reject(new StepTimeoutError(step.meta.name, stepTimeoutMs));
|
|
1593
|
-
}, stepTimeoutMs);
|
|
1594
|
-
}),
|
|
1595
|
-
]);
|
|
1596
|
-
if (result !== undefined) {
|
|
1597
|
-
state = result;
|
|
1598
|
-
stepReturnState = result;
|
|
1599
|
-
}
|
|
1846
|
+
lastRes = await raceBudget(Promise.resolve(poll.fn(effectiveCtx, state)), attemptBudget);
|
|
1600
1847
|
}
|
|
1601
1848
|
catch (err) {
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
finally {
|
|
1606
|
-
if (stepTimeoutId !== undefined) {
|
|
1607
|
-
clearTimeout(stepTimeoutId);
|
|
1849
|
+
if (err instanceof SkipError) {
|
|
1850
|
+
skipRequest = err;
|
|
1851
|
+
break;
|
|
1608
1852
|
}
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1853
|
+
if (err instanceof PollBudgetTimeout) {
|
|
1854
|
+
exhausted = true;
|
|
1855
|
+
break;
|
|
1856
|
+
}
|
|
1857
|
+
pollError = pollErrMsg(err);
|
|
1614
1858
|
break;
|
|
1615
1859
|
}
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
if (timeoutFailure) {
|
|
1860
|
+
if (performance.now() > deadline || performance.now() - attemptStart > perAttempt) {
|
|
1861
|
+
exhausted = true;
|
|
1619
1862
|
break;
|
|
1620
1863
|
}
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
if (delay > 0) {
|
|
1630
|
-
await new Promise((r) => setTimeout(r, delay));
|
|
1864
|
+
// Exit predicate (raced to the remaining attempt budget).
|
|
1865
|
+
let done;
|
|
1866
|
+
try {
|
|
1867
|
+
const predBudget = Math.min(deadline - performance.now(), perAttempt - (performance.now() - attemptStart));
|
|
1868
|
+
const out = await raceBudget(Promise.resolve(poll.until(effectiveCtx, lastRes, state)), Math.max(0, predBudget));
|
|
1869
|
+
if (typeof out !== "boolean") {
|
|
1870
|
+
pollError = `poll "${step.meta.name}": until must return a boolean; got ${out === null ? "null" : typeof out}`;
|
|
1871
|
+
break;
|
|
1631
1872
|
}
|
|
1873
|
+
done = out;
|
|
1632
1874
|
}
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
let returnStatePayload = undefined;
|
|
1638
|
-
if (stepReturnState !== undefined) {
|
|
1639
|
-
try {
|
|
1640
|
-
const serialized = JSON.stringify(stepReturnState);
|
|
1641
|
-
if (serialized.length <= 4096) {
|
|
1642
|
-
returnStatePayload = stepReturnState;
|
|
1875
|
+
catch (err) {
|
|
1876
|
+
if (err instanceof SkipError) {
|
|
1877
|
+
skipRequest = err;
|
|
1878
|
+
break;
|
|
1643
1879
|
}
|
|
1644
|
-
|
|
1645
|
-
|
|
1880
|
+
if (err instanceof PollBudgetTimeout) {
|
|
1881
|
+
exhausted = true;
|
|
1882
|
+
break;
|
|
1883
|
+
}
|
|
1884
|
+
pollError = pollErrMsg(err);
|
|
1885
|
+
break;
|
|
1886
|
+
}
|
|
1887
|
+
if (done) {
|
|
1888
|
+
satisfied = true;
|
|
1889
|
+
if (poll.out) {
|
|
1890
|
+
// A throwing out-mapper must fail the poll through the normal
|
|
1891
|
+
// path (poll event + step_end + ctx reset below), not escape
|
|
1892
|
+
// and leave a dangling started step — like fn/until errors.
|
|
1893
|
+
try {
|
|
1894
|
+
const next = poll.out(state, lastRes);
|
|
1895
|
+
if (next !== undefined)
|
|
1896
|
+
state = next;
|
|
1897
|
+
}
|
|
1898
|
+
catch (err) {
|
|
1899
|
+
pollError = pollErrMsg(err);
|
|
1900
|
+
}
|
|
1646
1901
|
}
|
|
1902
|
+
break;
|
|
1903
|
+
}
|
|
1904
|
+
// Not satisfied → check bounds, then wait.
|
|
1905
|
+
if (poll.maxAttempts && attempt >= poll.maxAttempts) {
|
|
1906
|
+
exhausted = true;
|
|
1907
|
+
break;
|
|
1647
1908
|
}
|
|
1648
|
-
|
|
1649
|
-
|
|
1909
|
+
if (Number.isFinite(deadline) && performance.now() + delay >= deadline) {
|
|
1910
|
+
exhausted = true;
|
|
1911
|
+
break;
|
|
1650
1912
|
}
|
|
1913
|
+
await new Promise((r) => setTimeout(r, delay));
|
|
1914
|
+
delay = Math.min(delay * backoff, 30_000);
|
|
1915
|
+
}
|
|
1916
|
+
const durationMs = Math.round(performance.now() - stepStart);
|
|
1917
|
+
const failedAssertions = getStepFailedAssertions();
|
|
1918
|
+
const assertions = getStepAssertionTotal();
|
|
1919
|
+
// ctx.skip() in fn/until skips the whole test (unless a failure was
|
|
1920
|
+
// already recorded — failure wins, mirroring the normal step path).
|
|
1921
|
+
if (skipRequest && failedAssertions === 0 && !pollError && !exhausted) {
|
|
1922
|
+
emitEvent({
|
|
1923
|
+
type: "step_end",
|
|
1924
|
+
index: i,
|
|
1925
|
+
name: step.meta.name,
|
|
1926
|
+
status: "skipped",
|
|
1927
|
+
durationMs,
|
|
1928
|
+
assertions,
|
|
1929
|
+
failedAssertions: 0,
|
|
1930
|
+
attempts: attempt,
|
|
1931
|
+
});
|
|
1932
|
+
currentTestCtx().currentStepIndex = null;
|
|
1933
|
+
return;
|
|
1934
|
+
}
|
|
1935
|
+
skipRequest = undefined;
|
|
1936
|
+
if (exhausted && !pollError) {
|
|
1937
|
+
pollError = `poll "${step.meta.name}" exhausted: condition not met after ${attempt} attempt(s)`;
|
|
1651
1938
|
}
|
|
1939
|
+
const failed = !!pollError || failedAssertions > 0 || !satisfied;
|
|
1940
|
+
emitEvent({
|
|
1941
|
+
type: "poll",
|
|
1942
|
+
index: i,
|
|
1943
|
+
name: step.meta.name,
|
|
1944
|
+
attempts: attempt,
|
|
1945
|
+
elapsedMs: Math.round(performance.now() - start),
|
|
1946
|
+
satisfied,
|
|
1947
|
+
exhausted,
|
|
1948
|
+
...(pollError && { error: pollError }),
|
|
1949
|
+
});
|
|
1652
1950
|
emitEvent({
|
|
1653
1951
|
type: "step_end",
|
|
1654
1952
|
index: i,
|
|
1655
1953
|
name: step.meta.name,
|
|
1656
1954
|
status: failed ? "failed" : "passed",
|
|
1657
1955
|
durationMs,
|
|
1658
|
-
assertions
|
|
1659
|
-
failedAssertions
|
|
1660
|
-
attempts:
|
|
1661
|
-
|
|
1662
|
-
...(stepError && { error: stepError }),
|
|
1663
|
-
...(returnStatePayload !== undefined && {
|
|
1664
|
-
returnState: returnStatePayload,
|
|
1665
|
-
}),
|
|
1956
|
+
assertions,
|
|
1957
|
+
failedAssertions,
|
|
1958
|
+
attempts: attempt,
|
|
1959
|
+
...(pollError && { error: pollError }),
|
|
1666
1960
|
});
|
|
1667
1961
|
currentTestCtx().currentStepIndex = null;
|
|
1668
|
-
if (failed)
|
|
1962
|
+
if (failed)
|
|
1669
1963
|
stepFailed = true;
|
|
1670
|
-
|
|
1964
|
+
};
|
|
1965
|
+
// Execute a branch step: evaluate the decision (value-switch subject
|
|
1966
|
+
// once, or first-match predicate), emit a `branch` decision event,
|
|
1967
|
+
// run the taken case's sub-steps as first-class steps, and emit
|
|
1968
|
+
// skipped for every non-taken case + (if a case matched) the default.
|
|
1969
|
+
const runBranchStep = async (step) => {
|
|
1970
|
+
const branch = step.branch;
|
|
1971
|
+
const ctxNow = currentTestCtx();
|
|
1972
|
+
if (ctxNow) {
|
|
1973
|
+
ctxNow.currentStepIndex = null;
|
|
1974
|
+
// Reset the assertion counters so we can detect ctx.assert /
|
|
1975
|
+
// ctx.validate failures made DURING the decision (predicates get
|
|
1976
|
+
// the full ctx) and fail the branch instead of silently deciding.
|
|
1977
|
+
ctxNow.stepFailedAssertions = 0;
|
|
1978
|
+
ctxNow.stepAssertionTotal = 0;
|
|
1671
1979
|
}
|
|
1672
|
-
|
|
1980
|
+
let takenIndex = "default";
|
|
1981
|
+
let takenValue;
|
|
1982
|
+
try {
|
|
1983
|
+
if (branch.mode === "value") {
|
|
1984
|
+
// Subject lens evaluated EXACTLY ONCE (test lenses may be impure).
|
|
1985
|
+
// `await` so an async lens resolves to its scalar before
|
|
1986
|
+
// matching (and a rejection is caught below as a decision
|
|
1987
|
+
// failure) — a bare Promise would never === a case value.
|
|
1988
|
+
const subject = branch.subject
|
|
1989
|
+
? await branch.subject(effectiveCtx, state)
|
|
1990
|
+
: undefined;
|
|
1991
|
+
for (let ci = 0; ci < branch.cases.length; ci++) {
|
|
1992
|
+
if (subject === branch.cases[ci].value) {
|
|
1993
|
+
takenIndex = ci;
|
|
1994
|
+
takenValue = branch.cases[ci].value;
|
|
1995
|
+
break;
|
|
1996
|
+
}
|
|
1997
|
+
}
|
|
1998
|
+
}
|
|
1999
|
+
else {
|
|
2000
|
+
for (let ci = 0; ci < branch.cases.length; ci++) {
|
|
2001
|
+
const pred = branch.cases[ci].predicate;
|
|
2002
|
+
if (!pred)
|
|
2003
|
+
continue;
|
|
2004
|
+
const result = await pred(effectiveCtx, state);
|
|
2005
|
+
// Predicate contract is boolean. A JS / `as any` caller could
|
|
2006
|
+
// return a truthy non-boolean ("false", an object); fail fast
|
|
2007
|
+
// instead of coercing (mirrors the flow-side guarantee).
|
|
2008
|
+
if (typeof result !== "boolean") {
|
|
2009
|
+
throw new Error(`condition / switchCond predicate must return a boolean; ` +
|
|
2010
|
+
`got ${result === null ? "null" : typeof result}`);
|
|
2011
|
+
}
|
|
2012
|
+
if (result) {
|
|
2013
|
+
takenIndex = ci;
|
|
2014
|
+
break;
|
|
2015
|
+
}
|
|
2016
|
+
}
|
|
2017
|
+
}
|
|
2018
|
+
}
|
|
2019
|
+
catch (err) {
|
|
2020
|
+
// ctx.skip() inside a predicate/lens skips the WHOLE test (like
|
|
2021
|
+
// inside a step) — UNLESS a failed assertion was already recorded
|
|
2022
|
+
// during the decision, in which case the failure wins over the
|
|
2023
|
+
// skip (mirrors the step loop's d675fc9 semantics: a skip must
|
|
2024
|
+
// not mask a real failure).
|
|
2025
|
+
if (err instanceof SkipError && getStepFailedAssertions() === 0) {
|
|
2026
|
+
skipRequest = err;
|
|
2027
|
+
emitEvent({
|
|
2028
|
+
type: "branch",
|
|
2029
|
+
index: branchSeq++,
|
|
2030
|
+
name: step.meta.name,
|
|
2031
|
+
takenIndex: "default",
|
|
2032
|
+
...(branch.message ? { message: branch.message } : {}),
|
|
2033
|
+
total: branch.cases.length,
|
|
2034
|
+
});
|
|
2035
|
+
for (const c of branch.cases)
|
|
2036
|
+
emitSkippedTree(c.steps);
|
|
2037
|
+
emitSkippedTree(branch.default);
|
|
2038
|
+
return;
|
|
2039
|
+
}
|
|
2040
|
+
// Branch decision failure (§7.4): the test fails; do not silently
|
|
2041
|
+
// take a branch. Emit a failed branch event + skip all sub-steps.
|
|
2042
|
+
// A skip that reaches here was preceded by a failed assertion.
|
|
2043
|
+
const failedDuring = getStepFailedAssertions();
|
|
2044
|
+
const errMessage = err instanceof SkipError
|
|
2045
|
+
? `${failedDuring} failed assertion(s) before ctx.skip() in branch decision`
|
|
2046
|
+
: err instanceof Error
|
|
2047
|
+
? err.message
|
|
2048
|
+
: String(err);
|
|
2049
|
+
emitEvent({
|
|
2050
|
+
type: "branch",
|
|
2051
|
+
index: branchSeq++,
|
|
2052
|
+
name: step.meta.name,
|
|
2053
|
+
takenIndex: "default",
|
|
2054
|
+
...(branch.message ? { message: branch.message } : {}),
|
|
2055
|
+
total: branch.cases.length,
|
|
2056
|
+
error: errMessage,
|
|
2057
|
+
});
|
|
2058
|
+
stepFailed = true;
|
|
2059
|
+
if (branchDecisionError === undefined) {
|
|
2060
|
+
branchDecisionError = `branch "${step.meta.name}": ${errMessage}`;
|
|
2061
|
+
}
|
|
2062
|
+
for (const c of branch.cases)
|
|
2063
|
+
emitSkippedTree(c.steps);
|
|
2064
|
+
emitSkippedTree(branch.default);
|
|
2065
|
+
return;
|
|
2066
|
+
}
|
|
2067
|
+
// A ctx.assert(false) / ctx.validate(...) failure recorded while
|
|
2068
|
+
// evaluating the decision fails the branch (the assertion event is
|
|
2069
|
+
// outside any step, so step-authoritative success would miss it).
|
|
2070
|
+
const decisionFailedAssertions = getStepFailedAssertions();
|
|
2071
|
+
if (decisionFailedAssertions > 0) {
|
|
2072
|
+
const assertErr = `${decisionFailedAssertions} failed assertion(s) during branch decision`;
|
|
2073
|
+
emitEvent({
|
|
2074
|
+
type: "branch",
|
|
2075
|
+
index: branchSeq++,
|
|
2076
|
+
name: step.meta.name,
|
|
2077
|
+
takenIndex: "default",
|
|
2078
|
+
...(branch.message ? { message: branch.message } : {}),
|
|
2079
|
+
total: branch.cases.length,
|
|
2080
|
+
error: assertErr,
|
|
2081
|
+
});
|
|
2082
|
+
stepFailed = true;
|
|
2083
|
+
if (branchDecisionError === undefined) {
|
|
2084
|
+
branchDecisionError = `branch "${step.meta.name}": ${assertErr}`;
|
|
2085
|
+
}
|
|
2086
|
+
for (const c of branch.cases)
|
|
2087
|
+
emitSkippedTree(c.steps);
|
|
2088
|
+
emitSkippedTree(branch.default);
|
|
2089
|
+
return;
|
|
2090
|
+
}
|
|
2091
|
+
emitEvent({
|
|
2092
|
+
type: "branch",
|
|
2093
|
+
index: branchSeq++,
|
|
2094
|
+
name: step.meta.name,
|
|
2095
|
+
takenIndex,
|
|
2096
|
+
...(takenValue !== undefined ? { takenValue } : {}),
|
|
2097
|
+
...(branch.message ? { message: branch.message } : {}),
|
|
2098
|
+
total: branch.cases.length,
|
|
2099
|
+
});
|
|
2100
|
+
// Emit/run leaves in REGISTRY (source) order — cases 0..N then
|
|
2101
|
+
// default — running the taken one as first-class steps and
|
|
2102
|
+
// skipping the rest IN PLACE. This keeps the monotonic leaf
|
|
2103
|
+
// `stepSeq` aligned with `flattenStepsForRegistry` order, so
|
|
2104
|
+
// consumers join runtime events to discovered steps by index even
|
|
2105
|
+
// when a later case (or the default) is taken.
|
|
2106
|
+
for (let ci = 0; ci < branch.cases.length; ci++) {
|
|
2107
|
+
if (ci === takenIndex) {
|
|
2108
|
+
await runStepList(branch.cases[ci].steps);
|
|
2109
|
+
}
|
|
2110
|
+
else {
|
|
2111
|
+
emitSkippedTree(branch.cases[ci].steps);
|
|
2112
|
+
}
|
|
2113
|
+
}
|
|
2114
|
+
if (takenIndex === "default") {
|
|
2115
|
+
await runStepList(branch.default);
|
|
2116
|
+
}
|
|
2117
|
+
else {
|
|
2118
|
+
emitSkippedTree(branch.default);
|
|
2119
|
+
}
|
|
2120
|
+
};
|
|
2121
|
+
await runStepList(test.steps);
|
|
1673
2122
|
}
|
|
1674
2123
|
}
|
|
1675
2124
|
finally {
|
|
@@ -1689,9 +2138,14 @@ async function executeNewTest(test) {
|
|
|
1689
2138
|
}
|
|
1690
2139
|
}
|
|
1691
2140
|
}
|
|
2141
|
+
// A step called ctx.skip() → propagate so the test is reported as
|
|
2142
|
+
// skipped (teardown has already run in the finally above).
|
|
2143
|
+
if (skipRequest) {
|
|
2144
|
+
throw skipRequest;
|
|
2145
|
+
}
|
|
1692
2146
|
// If any step failed (assertion or throw), mark overall test as failed
|
|
1693
2147
|
if (stepFailed) {
|
|
1694
|
-
throw new Error("One or more steps failed");
|
|
2148
|
+
throw new Error(branchDecisionError ?? "One or more steps failed");
|
|
1695
2149
|
}
|
|
1696
2150
|
}
|
|
1697
2151
|
};
|