@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/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
- for (let i = 0; i < test.steps.length; i++) {
1522
- const step = test.steps[i];
1523
- // If a previous step failed, skip remaining steps
1524
- if (stepFailed) {
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: "skipped",
1530
- durationMs: 0,
1531
- assertions: 0,
1532
- failedAssertions: 0,
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
- continue;
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
- // Reset per-step assertion counters and set step scope
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
- type: "step_start",
1546
- index: i,
1547
- name: step.meta.name,
1548
- total: test.steps.length,
1549
- });
1550
- let stepError;
1551
- let stepReturnState = undefined;
1552
- const retries = step.meta.retries;
1553
- const configuredRetries = typeof retries === "number" && Number.isFinite(retries)
1554
- ? Math.max(0, Math.floor(retries))
1555
- : 0;
1556
- const retryDelayMs = typeof step.meta.retryDelay === "number" && Number.isFinite(step.meta.retryDelay)
1557
- ? Math.max(0, step.meta.retryDelay)
1558
- : (configuredRetries > 0 ? 1000 : 0);
1559
- const backoffMultiplier = typeof step.meta.backoff === "number" && Number.isFinite(step.meta.backoff)
1560
- ? Math.max(1, step.meta.backoff)
1561
- : 1;
1562
- const stepTimeout = step.meta.timeout;
1563
- const configuredStepTimeout = typeof stepTimeout === "number" && Number.isFinite(stepTimeout)
1564
- ? Math.floor(stepTimeout)
1565
- : 0;
1566
- const stepTimeoutMs = configuredStepTimeout > 0 ? configuredStepTimeout : undefined;
1567
- const maxAttempts = configuredRetries + 1;
1568
- let attemptsUsed = 0;
1569
- let lastFailedAssertions = 0;
1570
- let lastAssertions = 0;
1571
- let timeoutFailure = false;
1572
- for (let attempt = 1; attempt <= maxAttempts; attempt++) {
1573
- attemptsUsed = attempt;
1574
- stepError = undefined;
1575
- stepReturnState = undefined;
1576
- {
1577
- const trc = currentTestCtx();
1578
- trc.stepFailedAssertions = 0;
1579
- trc.stepAssertionTotal = 0;
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
- timeoutFailure = false;
1582
- let stepTimeoutId;
1842
+ const attemptBudget = Math.min(perAttempt, remainingTotal);
1843
+ const attemptStart = performance.now();
1844
+ // Attempt fn (best-effort raced).
1583
1845
  try {
1584
- const stepResult = step.fn(effectiveCtx, state);
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
- stepError = err instanceof Error ? err.message : String(err);
1603
- timeoutFailure = err instanceof StepTimeoutError;
1604
- }
1605
- finally {
1606
- if (stepTimeoutId !== undefined) {
1607
- clearTimeout(stepTimeoutId);
1849
+ if (err instanceof SkipError) {
1850
+ skipRequest = err;
1851
+ break;
1608
1852
  }
1609
- }
1610
- lastFailedAssertions = getStepFailedAssertions();
1611
- lastAssertions = getStepAssertionTotal();
1612
- const attemptFailed = !!stepError || getStepFailedAssertions() > 0;
1613
- if (!attemptFailed) {
1853
+ if (err instanceof PollBudgetTimeout) {
1854
+ exhausted = true;
1855
+ break;
1856
+ }
1857
+ pollError = pollErrMsg(err);
1614
1858
  break;
1615
1859
  }
1616
- // Timeouts are terminal to avoid overlapping attempts from
1617
- // dangling async operations in the timed-out step body.
1618
- if (timeoutFailure) {
1860
+ if (performance.now() > deadline || performance.now() - attemptStart > perAttempt) {
1861
+ exhausted = true;
1619
1862
  break;
1620
1863
  }
1621
- if (attempt < maxAttempts) {
1622
- const delay = Math.min(retryDelayMs * backoffMultiplier ** (attempt - 1), 30_000);
1623
- const reason = stepError ? stepError : `${getStepFailedAssertions()} failed assertion(s)`;
1624
- emitEvent({
1625
- type: "log",
1626
- stepIndex: i,
1627
- message: `Retrying step "${step.meta.name}" (${attempt + 1}/${maxAttempts}) after failure: ${reason}${delay > 0 ? ` (waiting ${delay}ms)` : ""}`,
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
- const durationMs = Math.round(performance.now() - stepStart);
1635
- const failed = !!stepError || lastFailedAssertions > 0;
1636
- // Serialize return state with size guard (max 4 KB)
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
- else {
1645
- returnStatePayload = `[truncated: ${serialized.length} bytes]`;
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
- catch {
1649
- returnStatePayload = "[non-serializable]";
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: lastAssertions,
1659
- failedAssertions: lastFailedAssertions,
1660
- attempts: attemptsUsed,
1661
- retriesUsed: Math.max(0, attemptsUsed - 1),
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
- // Don't throw here — let the loop continue to emit skip events
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
  };