Skip to content

Commit f4b0183

Browse files
thymikeeclaude
andauthored
chore(gates): make test guards and CI selectors fail on the violations they exist for (#4356)
* fix(platforms): build exec failure details through execFailureDetails simctl boot/bootstatus readiness failures dropped the processExitError flag, so normalizeError never surfaced the stderr excerpt. The Android discovery and Vega inventory wraps set the flag by hand; route all four through the host-kit helpers. Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_016qAu53rMak1bXMuR5U2gF4 * test: skip chmod-forced failure tests when running as root Root ignores file modes, so these seven tests could not force their read or write failure. Apply the skip guard their sibling tests already use. Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_016qAu53rMak1bXMuR5U2gF4 * test(coverage): bind Android contract evidence to repository test files Every Android command-contract owner is now a repository path whose file carries the test name literally. Lifecycle evidence claims only the seven rows that cite it; the orphan evidence modules are gone and their test titles are inline. Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_016qAu53rMak1bXMuR5U2gF4 * test(interaction-contract): fail when a coverage claim names no registered test Each contract file now registers a test that compares its manifest claims with the tests the file registered. The Maestro direct-selector test derives its title from the manifest instead of a second literal copy. Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_016qAu53rMak1bXMuR5U2gF4 * test(daemon-client): route loopback skips through skipWhenLoopbackUnavailable 54 tests skipped through a raw supportsLoopbackBind probe, which ignores AGENT_DEVICE_REQUIRE_LOOPBACK_TESTS. They now use the guarded helper. Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_016qAu53rMak1bXMuR5U2gF4 * docs(providers): make the BrowserStack and AWS Device Farm snippets typecheck The client config has no platform or device field; pass them to apps.open. Narrow the artifacts result before reading cloudArtifacts. Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_016qAu53rMak1bXMuR5U2gF4 * fix(check-affected): derive public-surface sources from build entries Both selectors mapped ./dist/src/X.js to src/X.ts, which names files that do not exist, so an edit to a real entry such as src/sdk/index.ts never selected the package gate. One helper now maps exports through buildEntries. Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_016qAu53rMak1bXMuR5U2gF4 * fix(ci): keep both sides of a rename in device-lane selection A file moved out of a device-owned root reported only its destination and could skip the lane its source owned. Match check:affected with --no-renames. Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_016qAu53rMak1bXMuR5U2gF4 * fix(mutation): fail selection when git diff against the base fails An unknown base ref produced an empty change set, so the PR lane selected no mutants and passed. Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_016qAu53rMak1bXMuR5U2gF4 * build: drop tsdown chunk groups for removed source paths Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_016qAu53rMak1bXMuR5U2gF4 * ci: run provider integration once and sweep fresh torture seeds The Coverage job already runs the provider-integration project under coverage, so the Integration job no longer repeats it. The nightly torture lane now starts at run number x runs unless a seed start is dispatched. Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_016qAu53rMak1bXMuR5U2gF4 * chore(gates): make guards fail on the violations they exist for - exec-wrap-guard scans packages/*/src as well as src/. - package-exports compares the full exports map with the supported list. - Android command-contract evidence must claim exactly the rows citing it. - The interaction-contract gate requires every manifest's contract file to check its claims are registered. - Output-economy waivers name the base value they raise from, so a spent waiver is inert; the spent waivers are removed. - The doc-snippet guard compiles the provider pages with complete programs. - CI sets AGENT_DEVICE_REQUIRE_LOOPBACK_TESTS for the unit suite. Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_016qAu53rMak1bXMuR5U2gF4 * test: inject the write and read failures instead of skipping as root The seven chmod-forced failure tests now fail through a seam that works under any uid: a failing store write, a readFileSync or unlinkSync spy on the one claim file, or a plain file where the resource directory belongs. No root skip remains. Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_016qAu53rMak1bXMuR5U2gF4 * fix(platform-android): keep the adb classification over xcrun details execFailureDetails spreads xcrun-not-found details last, which could replace the discovery hint. The adb hint and reason now win. Add a boot-failure test for the simctl readiness path. Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_016qAu53rMak1bXMuR5U2gF4 * ci(torture): validate dispatch inputs and use a fixed seed stride runs and seed-start must be digits before they reach bash arithmetic or GITHUB_ENV. Each run owns [run_number * 20000, +runs), and runs is capped at the stride, so ranges stay disjoint when dispatches change runs. Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_016qAu53rMak1bXMuR5U2gF4 * chore(gates): tighten review follow-ups on the new guards - The interaction-contract gate checks the AST: the sibling manifest is imported and passed to a top-level assertCoverageClaimsRegistered call. - Coverage registration counts only tests that execute (run or only). - packageEntrySources reads only own buildEntries keys. - The device-lane rename test blanks GITHUB_OUTPUT and GITHUB_STEP_SUMMARY. - The output-economy README shows the waiver structure; the doc-snippet guard states why replay-e2e.md is left out. Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_016qAu53rMak1bXMuR5U2gF4 * fix(platform-android): keep the missing-tool hint when no adb pattern matches Hint precedence is now the adb classification, then the xcrun missing-tool hint, then the caller's fallback, so the hint agrees with the reported reason. The adoption test asserts the exact unconfirmed-cleanup reason. Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_016qAu53rMak1bXMuR5U2gF4 * test(test-kit): make skipWhenLoopbackUnavailable the only loopback check supportsLoopbackBind is no longer exported. The remaining 12 raw probes (root src, MCP and recorded-daemon tests) use the guarded helper, so AGENT_DEVICE_REQUIRE_LOOPBACK_TESTS fails them instead of skipping. Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_016qAu53rMak1bXMuR5U2gF4 * chore(gates): check interaction-contract claims in a project setup hook A setup file's afterAll loads the sibling <name>.coverage.ts of every *.contract.test.ts and diffs its claims against the tests that ran. A missing manifest fails the file, and an active -t filter skips the diff. The per-file calls and the AST meta-test are gone. The two contract tests that claim no matrix cell drop the .contract suffix. Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_016qAu53rMak1bXMuR5U2gF4 * chore(gates): register the contract setup files with Fallow The interaction-contract project loads its setup files by path, so Fallow lists them as dynamically loaded. listenOnLoopback awaits the listening event, and the Vega parser is no longer exported; nothing imported it. Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_016qAu53rMak1bXMuR5U2gF4 * test(interaction-contract): require a contract test beside every coverage manifest The registration hook checks a manifest only from its contract file, so an orphaned manifest would still count toward the matrix unchecked. Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_016qAu53rMak1bXMuR5U2gF4 --------- Co-authored-by: Claude <[email protected]>
1 parent 7a5b4eb commit f4b0183

71 files changed

Lines changed: 616 additions & 498 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎.fallowrc.json‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,9 @@
7676
],
7777
"dynamicallyLoaded": [
7878
"packages/daemon/src/execution/interaction/index.ts",
79+
// The interaction-contract Vitest project loads these by path (globalSetup, setupFiles).
80+
"test/integration/interaction-contract/coverage-registration.global-setup.ts",
81+
"test/integration/interaction-contract/coverage-registration.ts",
7982
// The site's modules that Astro loads without an import Fallow can read: from an .astro
8083
// <script> block (hero.ts, dither-host.ts), as a worker URL (dither.worker.ts), by a path
8184
// in astro.config.mjs (routeData.ts) or by convention (ec.config.mjs, Expressive Code).

‎.github/workflows/ci.yml‎

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -304,6 +304,8 @@ jobs:
304304
id: run-coverage
305305
env:
306306
OUTPUT_ECONOMY_BASE: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || github.event.before }}
307+
# A loopback-dependent test fails here instead of skipping when bind is denied.
308+
AGENT_DEVICE_REQUIRE_LOOPBACK_TESTS: '1'
307309
uses: ./.github/actions/run-gate
308310
with: { gate: unit-ci }
309311

@@ -388,10 +390,6 @@ jobs:
388390
uses: ./.github/actions/run-gate
389391
with: { gate: concurrency-torture }
390392

391-
- name: Run provider-backed integration tests
392-
uses: ./.github/actions/run-gate
393-
with: { gate: provider-integration }
394-
395393
- name: Check Provider-backed integration architecture progress
396394
uses: ./.github/actions/run-gate
397395
with: { gate: integration-progress }

‎.github/workflows/concurrency-torture-nightly.yml‎

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,13 +18,13 @@ on:
1818
workflow_dispatch:
1919
inputs:
2020
runs:
21-
description: 'Number of seeded interleavings to sweep'
21+
description: 'Number of seeded interleavings to sweep (at most 20000)'
2222
required: false
2323
default: '5000'
2424
seed-start:
25-
description: 'First seed in the sweep'
25+
description: 'First seed in the sweep (default: run number x 20000, a fresh range each run)'
2626
required: false
27-
default: '0'
27+
default: ''
2828

2929
permissions:
3030
contents: read
@@ -39,8 +39,11 @@ jobs:
3939
runs-on: blacksmith-4vcpu-ubuntu-2404
4040
timeout-minutes: 20
4141
env:
42-
TORTURE_RUNS: ${{ github.event.inputs.runs || '5000' }}
43-
TORTURE_SEED_START: ${{ github.event.inputs.seed-start || '0' }}
42+
# Job-level env would override a value written to GITHUB_ENV, so the step below sets both values.
43+
RUNS_INPUT: ${{ github.event.inputs.runs || '5000' }}
44+
SEED_START_INPUT: ${{ github.event.inputs.seed-start }}
45+
# Each run owns the seed range [run_number * stride, run_number * stride + runs).
46+
SEED_STRIDE: '20000'
4447
# Standard scheduled-lane artifact envelope (#1430) built via the shared
4548
# scripts/lib/lane-envelope.ts (commit, tool/configHash, seed range, result);
4649
# the seed sweep rides in the typed data payload. Emitted by the test and
@@ -53,6 +56,23 @@ jobs:
5356
- name: Setup toolchain
5457
uses: ./.github/actions/setup-node-pnpm
5558

59+
# A seed fully determines execution order, so a fixed range only re-proves old seeds.
60+
# Without an explicit start, each run sweeps its own disjoint range.
61+
- name: Choose the seed range
62+
run: |
63+
digits='^[0-9]+$'
64+
if [[ ! "$RUNS_INPUT" =~ $digits ]] || (( 10#$RUNS_INPUT < 1 || 10#$RUNS_INPUT > SEED_STRIDE )); then
65+
echo "::error::runs must be an integer from 1 to $SEED_STRIDE"
66+
exit 1
67+
fi
68+
if [[ -n "$SEED_START_INPUT" && ! "$SEED_START_INPUT" =~ $digits ]]; then
69+
echo "::error::seed-start must be a non-negative integer"
70+
exit 1
71+
fi
72+
seed_start="${SEED_START_INPUT:-$((GITHUB_RUN_NUMBER * SEED_STRIDE))}"
73+
echo "TORTURE_RUNS=$((10#$RUNS_INPUT))" >> "$GITHUB_ENV"
74+
echo "TORTURE_SEED_START=$((10#$seed_start))" >> "$GITHUB_ENV"
75+
5676
- name: Run concurrency torture sweep
5777
uses: ./.github/actions/run-gate
5878
with: { gate: concurrency-torture }

‎examples/sdk/tsconfig.json‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@
1313
"agent-device/remote-config": ["../../src/sdk/remote-config.ts"],
1414
"agent-device/install-source": ["../../src/sdk/install-source.ts"],
1515
"agent-device/android-adb": ["../../src/sdk/android-adb.ts"],
16+
"agent-device/limrun": ["../../src/sdk/limrun.ts"],
17+
"agent-device/plugins": ["../../src/sdk/plugins.ts"],
18+
"agent-device/plugins/webdriver": ["../../src/sdk/plugin-webdriver.ts"],
1619
"agent-device/contracts": ["../../src/sdk/contracts.ts"],
1720
"agent-device/finders": ["../../src/sdk/finders.ts"],
1821
"agent-device/selectors": ["../../src/sdk/selectors.ts"],

‎packages/capture-kit/src/capture-admission/__tests__/durable-capture-resource.test.ts‎

Lines changed: 20 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,7 @@
1-
import fs from 'node:fs';
2-
import path from 'node:path';
31
import { expect, test, vi } from 'vitest';
42
import { AppError } from '@agent-device/kernel/errors';
53
import {
4+
createTestCaptureResource,
65
makeDurableCaptureContext,
76
makeDurableCaptureStartResult,
87
testCaptureResource,
@@ -175,22 +174,25 @@ test('an unconfirmed failed-adoption cleanup blocks a replacement start in this
175174
cleanup: { status: 'cleanup-pending', reason: 'cleanup-unconfirmed' },
176175
});
177176
const primary = new AppError('CANCELED', 'request canceled');
178-
const resourceDir = path.dirname(context.resourcePath);
179-
180-
try {
181-
await expect(
182-
testCaptureResource.adoptStarted({
183-
...context,
184-
...start,
185-
throwIfCanceled: () => {
186-
fs.chmodSync(resourceDir, 0o500);
187-
throw primary;
188-
},
189-
}),
190-
).rejects.toBe(primary);
191-
} finally {
192-
fs.chmodSync(resourceDir, 0o700);
193-
}
177+
let writesFail = false;
178+
const resource = createTestCaptureResource({
179+
...testCaptureStore,
180+
write: (resourcePath, envelope) => {
181+
if (writesFail) throw Object.assign(new Error('test write denied'), { code: 'EACCES' });
182+
testCaptureStore.write(resourcePath, envelope);
183+
},
184+
});
185+
186+
await expect(
187+
resource.adoptStarted({
188+
...context,
189+
...start,
190+
throwIfCanceled: () => {
191+
writesFail = true;
192+
throw primary;
193+
},
194+
}),
195+
).rejects.toBe(primary);
194196

195197
expect(() => context.admissionLedger.assertStartAllowed(context.device)).toThrow(/process-local/);
196198
});

‎packages/capture-kit/src/durable-capture/adoption.test.ts‎

Lines changed: 23 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,8 @@
1-
import fs from 'node:fs';
2-
import path from 'node:path';
31
import { expect, test } from 'vitest';
42
import { AppError } from '@agent-device/kernel/errors';
53
import { adoptStartedDurableCapture } from './adoption.ts';
64
import {
5+
createTestCaptureDefinition,
76
makeDurableCaptureContext,
87
makeDurableCaptureStartResult,
98
testCaptureDefinition,
@@ -88,30 +87,33 @@ test('a failed terminal transition preserves the primary error and reports it un
8887
cleanup: { status: 'cleanup-pending', reason: 'cleanup-unconfirmed' },
8988
});
9089
const primary = new AppError('CANCELED', 'request canceled');
91-
const resourceDir = path.dirname(context.resourcePath);
90+
let writesFail = false;
91+
const definition = createTestCaptureDefinition({
92+
...testCaptureStore,
93+
write: (resourcePath, envelope) => {
94+
if (writesFail) throw Object.assign(new Error('test write denied'), { code: 'EACCES' });
95+
testCaptureStore.write(resourcePath, envelope);
96+
},
97+
});
9298

93-
try {
94-
await expect(
95-
adoptStartedDurableCapture(
96-
testCaptureDefinition,
97-
{
98-
...context,
99-
...start,
100-
throwIfCanceled: () => {
101-
fs.chmodSync(resourceDir, 0o500);
102-
throw primary;
103-
},
99+
await expect(
100+
adoptStartedDurableCapture(
101+
definition,
102+
{
103+
...context,
104+
...start,
105+
throwIfCanceled: () => {
106+
writesFail = true;
107+
throw primary;
104108
},
105-
context.resourcePath,
106-
),
107-
).rejects.toBe(primary);
108-
} finally {
109-
fs.chmodSync(resourceDir, 0o700);
110-
}
109+
},
110+
context.resourcePath,
111+
),
112+
).rejects.toBe(primary);
111113

112114
expect(context.reportUndurableCleanup).toHaveBeenCalledWith(context.device, {
113115
confirmed: false,
114-
reason: expect.stringMatching(/./),
116+
reason: 'Test capture cleanup could not be confirmed',
115117
});
116118
});
117119

‎packages/daemon-client/src/__tests__/daemon-client-ensure-drain.test.ts‎

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import assert from 'node:assert/strict';
22
import { afterEach, test, vi } from 'vitest';
3-
import { closeLoopbackServer, supportsLoopbackBind } from '@agent-device/test-kit';
3+
import { closeLoopbackServer, skipWhenLoopbackUnavailable } from '@agent-device/test-kit';
44
import { AppError } from '@agent-device/kernel/errors';
55
import {
66
isProcessAlive,
@@ -141,9 +141,7 @@ async function waitForExit(pid: number): Promise<void> {
141141
}
142142

143143
async function withLoopback(t: { skip: (note: string) => void }): Promise<boolean> {
144-
if (await supportsLoopbackBind()) return true;
145-
t.skip('loopback listeners are not permitted in this environment');
146-
return false;
144+
return !(await skipWhenLoopbackUnavailable(t));
147145
}
148146

149147
test('a drain that outlasts the old kill window is waited out, not signaled', async (t) => {

‎packages/daemon-client/src/__tests__/daemon-client-ensure-explicit-lifecycle.test.ts‎

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import assert from 'node:assert/strict';
22
import fs from 'node:fs';
33
import path from 'node:path';
44
import { afterEach, test, vi } from 'vitest';
5-
import { closeLoopbackServer, supportsLoopbackBind } from '@agent-device/test-kit';
5+
import { closeLoopbackServer, skipWhenLoopbackUnavailable } from '@agent-device/test-kit';
66
import { AppError } from '@agent-device/kernel/errors';
77
import { DAEMON_QUIESCE_COMMAND } from '@agent-device/daemon-contracts/daemon-resolution';
88
import { DAEMON_STARTUP_EXIT_CODES } from '@agent-device/daemon-contracts/daemon-registration-owner';
@@ -95,9 +95,7 @@ async function failureOf(run: Promise<unknown>): Promise<AppError> {
9595
}
9696

9797
async function withLoopback(t: { skip: (note: string) => void }): Promise<boolean> {
98-
if (await supportsLoopbackBind()) return true;
99-
t.skip('loopback listeners are not permitted in this environment');
100-
return false;
98+
return !(await skipWhenLoopbackUnavailable(t));
10199
}
102100

103101
test('attach reports a missing daemon without starting one', async (t) => {

‎packages/daemon-client/src/__tests__/daemon-client-ensure-scripted-daemon.test.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
11
import assert from 'node:assert/strict';
22
import { test } from 'vitest';
3-
import { closeLoopbackServer, supportsLoopbackBind } from '@agent-device/test-kit';
3+
import { closeLoopbackServer, skipWhenLoopbackUnavailable } from '@agent-device/test-kit';
44
import { okResult, startScriptedDaemon } from './explicit-lifecycle-fixture.ts';
55

66
// The stand-in daemon is a loopback server on an ephemeral port, so anything else on the host that
77
// probes listening ports can reach it. Only the client under test speaks `POST /rpc`.
88

99
test('a stray request that is not a daemon request is refused and never recorded', async (t) => {
10-
if (!(await supportsLoopbackBind())) return t.skip('loopback listeners are not permitted');
10+
if (await skipWhenLoopbackUnavailable(t)) return;
1111
const daemon = await startScriptedDaemon(okResult({}));
1212
const base = `http://127.0.0.1:${daemon.port}`;
1313
try {

‎packages/daemon-client/src/__tests__/daemon-client-ensuring.test.ts‎

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { afterEach, test, vi } from 'vitest';
44
import {
55
closeLoopbackServer,
66
createTestHostEnvironment,
7-
supportsLoopbackBind,
7+
skipWhenLoopbackUnavailable,
88
} from '@agent-device/test-kit';
99
import { AppError } from '@agent-device/kernel/errors';
1010
import { isProcessAlive } from '@agent-device/host-kit/process';
@@ -59,7 +59,7 @@ function request(command: string, flags: Record<string, unknown>) {
5959
}
6060

6161
test('the first request ensures a missing daemon once and every request attaches to it', async (t) => {
62-
if (!(await supportsLoopbackBind())) return t.skip('loopback listeners are not permitted');
62+
if (await skipWhenLoopbackUnavailable(t)) return;
6363
const { stateDir, paths } = stateDirAndPaths();
6464
const fresh = await startScriptedDaemon(okResult({}));
6565
launchesFreshDaemonAt(spawn, paths, fresh.port);
@@ -83,7 +83,7 @@ test('the first request ensures a missing daemon once and every request attaches
8383
});
8484

8585
test('an idle incompatible daemon is replaced, and the replacement is reported', async (t) => {
86-
if (!(await supportsLoopbackBind())) return t.skip('loopback listeners are not permitted');
86+
if (await skipWhenLoopbackUnavailable(t)) return;
8787
const { stateDir, paths } = stateDirAndPaths();
8888
const leaver = createDaemonLeaver();
8989
const stale = await startScriptedDaemon(draining, { afterRpc: leaver.leave });
@@ -117,7 +117,7 @@ test('an idle incompatible daemon is replaced, and the replacement is reported',
117117
});
118118

119119
test('a busy incompatible daemon fails the request before it is sent and keeps running', async (t) => {
120-
if (!(await supportsLoopbackBind())) return t.skip('loopback listeners are not permitted');
120+
if (await skipWhenLoopbackUnavailable(t)) return;
121121
const { stateDir, paths } = stateDirAndPaths();
122122
const stale = await startScriptedDaemon(
123123
busy({ inFlightRequests: 0, sessions: 1, repairSessions: 0, leases: 0 }),
@@ -163,7 +163,7 @@ test('a remote connection passes through without ensuring or choosing a lifecycl
163163
});
164164

165165
test('a one-shot replay on its own private daemon passes through, an explicit state dir does not', async (t) => {
166-
if (!(await supportsLoopbackBind())) return t.skip('loopback listeners are not permitted');
166+
if (await skipWhenLoopbackUnavailable(t)) return;
167167
const { stateDir, paths } = stateDirAndPaths();
168168
const fresh = await startScriptedDaemon(okResult({}));
169169
launchesFreshDaemonAt(spawn, paths, fresh.port);
@@ -184,7 +184,7 @@ test('a one-shot replay on its own private daemon passes through, an explicit st
184184
});
185185

186186
test('a daemon that disappears between requests is ensured again before the next one is sent', async (t) => {
187-
if (!(await supportsLoopbackBind())) return t.skip('loopback listeners are not permitted');
187+
if (await skipWhenLoopbackUnavailable(t)) return;
188188
const { stateDir, paths } = stateDirAndPaths();
189189
const fresh = await startScriptedDaemon(okResult({}));
190190
launchesFreshDaemonAt(spawn, paths, fresh.port);
@@ -218,7 +218,7 @@ test('a daemon that disappears between requests is ensured again before the next
218218
});
219219

220220
test('a registered daemon that answers nothing is replaced and reported as unreachable', async (t) => {
221-
if (!(await supportsLoopbackBind())) return t.skip('loopback listeners are not permitted');
221+
if (await skipWhenLoopbackUnavailable(t)) return;
222222
const { stateDir, paths } = stateDirAndPaths();
223223
// Both are bound before the dead one is closed, so the fresh daemon cannot be handed its port.
224224
const closed = await startScriptedDaemon(okResult({}));
@@ -242,7 +242,7 @@ test('a registered daemon that answers nothing is replaced and reported as unrea
242242
});
243243

244244
test('a failed ensure is retried by the next request instead of being remembered', async (t) => {
245-
if (!(await supportsLoopbackBind())) return t.skip('loopback listeners are not permitted');
245+
if (await skipWhenLoopbackUnavailable(t)) return;
246246
const { stateDir, paths } = stateDirAndPaths();
247247
const stale = await startScriptedDaemon(
248248
busy({ inFlightRequests: 1, sessions: 0, repairSessions: 0, leases: 0 }),
@@ -278,7 +278,7 @@ test('a failed ensure is retried by the next request instead of being remembered
278278
});
279279

280280
test('concurrent first requests share one ensure through the CLI and MCP transport', async (t) => {
281-
if (!(await supportsLoopbackBind())) return t.skip('loopback listeners are not permitted');
281+
if (await skipWhenLoopbackUnavailable(t)) return;
282282
const { stateDir, paths } = stateDirAndPaths();
283283
const fresh = await startScriptedDaemon(okResult({ via: 'fresh' }));
284284
launchesFreshDaemonAt(spawn, paths, fresh.port);
@@ -314,7 +314,7 @@ test('concurrent first requests share one ensure through the CLI and MCP transpo
314314
});
315315

316316
test('a request canceled before it starts does not start a daemon', async (t) => {
317-
if (!(await supportsLoopbackBind())) return t.skip('loopback listeners are not permitted');
317+
if (await skipWhenLoopbackUnavailable(t)) return;
318318
const { stateDir, paths } = stateDirAndPaths();
319319
const fresh = await startScriptedDaemon(okResult({}));
320320
launchesFreshDaemonAt(spawn, paths, fresh.port);

0 commit comments

Comments
 (0)