Skip to content

feat: add withMockedFetch and withMockedCommand to swamp-testing#968

Merged
stack72 merged 1 commit intomainfrom
add-mock-primitives
Mar 30, 2026
Merged

feat: add withMockedFetch and withMockedCommand to swamp-testing#968
stack72 merged 1 commit intomainfrom
add-mock-primitives

Conversation

@stack72
Copy link
Copy Markdown
Contributor

@stack72 stack72 commented Mar 30, 2026

Summary

Add two runtime mock primitives that test extension code on the exact same code path as production — no injection or alternative paths needed:

  • withMockedFetch(handler|responses[], fn) — replaces globalThis.fetch for the callback's duration. Covers any HTTP-based SDK (AWS, Azure, GCP, REST APIs).
  • withMockedCommand(handler|outputs[], fn) — replaces Deno.Command for the callback's duration. Covers any CLI-tool-wrapping extension (1password's op, etc.).

Both support two modes:

  • Sequential — pass an array of canned responses, consumed one per call
  • Handler — pass a function that routes dynamically based on the request/command

All calls are recorded for inspection after the test.

Example: testing an AWS vault extension

await withMockedFetch(async (req) => {
  const body = await req.json();
  if (body.SecretId) return Response.json({ SecretString: "sk-123" });
  return Response.json({ SecretList: [] });
}, async () => {
  // This is the EXACT production code path — real SecretsManagerClient,
  // real SDK, just intercepted at the fetch boundary
  const provider = vault.createProvider("test", { region: "us-east-1" });
  await assertVaultConformance(provider);
});

Also fixes review issues from #963

  • vault_conformance: use keys.includes() instead of assertStringIncludes on joined keys
  • vault_conformance: re-read key1 after writing key2 to detect single-variable clobbering
  • datastore_test_context: use per-lock Map instead of shared lockHeld boolean
  • datastore_conformance: require nonce in LockInfo, verify lock still held after wrong-nonce forceRelease
  • report_test_context: clone Uint8Array in getContent to prevent mutation
  • Driver compat test: verify execute method and outputs field
  • Datastore compat test: verify withLock method

115 tests pass (97 existing + 18 new mock tests).

Test Plan

  • deno check passes
  • deno lint passes
  • deno fmt --check passes
  • deno run test packages/testing/ — 115 tests pass
  • Compat tests pass with new assertions

🤖 Generated with Claude Code

Add two runtime mock primitives that intercept at the boundary between
extension code and the outside world:

- withMockedFetch(handler|responses[], fn) — replaces globalThis.fetch
  for the duration of the callback. Supports sequential responses (array)
  or dynamic routing (handler function). Records all calls for inspection.

- withMockedCommand(handler|outputs[], fn) — replaces Deno.Command for
  the duration of the callback. Same two modes. Records command/args.

These test the exact same code path as production — no _client injection
or alternative code paths needed. Extension authors mock at the network
or process boundary, not the SDK layer.

Also fixes review issues from #963:
- vault_conformance: use keys.includes() instead of assertStringIncludes
- vault_conformance: re-read key1 after writing key2 to detect clobbering
- datastore_test_context: use per-lock Map instead of shared lockHeld bool
- datastore_conformance: require nonce in LockInfo, verify lock held after
  wrong-nonce forceRelease
- report_test_context: clone Uint8Array in getContent
- driver compat test: verify execute method and outputs field
- datastore compat test: verify withLock method

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
Copy link
Copy Markdown

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

Clean, well-structured PR. The mock primitives are well-designed with proper cleanup semantics, good error messages, and thorough test coverage. The conformance suite fixes are solid improvements.

Blocking Issues

None.

Suggestions

  1. mock_fetch.ts:100Request constructor consumes init when input is already a Request: When input is a Request and init is also provided (e.g. fetch(existingRequest, { headers: ... })), the current code ignores init. This is a narrow edge case since the Request(input, init) constructor already merges them correctly, but worth noting that input instanceof Request ? input : new Request(input, init) skips the init merge for existing Request objects. Consider new Request(input, init) unconditionally, which the spec handles correctly for both cases.

  2. mock_command.ts:113options?.args as string[] cast: The as string[] cast on options args bypasses type checking. Since the CommandOptions type already declares args?: string[], this cast is safe in practice, but the [key: string]: unknown index signature on the options type is what forces it. Minor — no action needed.

  3. DDD perspective: The mock primitives sit cleanly in the testing infrastructure layer and don't leak into domain code. The with* pattern (resource acquisition + guaranteed cleanup) is the right choice for global state replacement. The conformance suite changes correctly tighten the behavioral contracts for locks and vaults, which strengthens the domain boundary enforcement for extension implementations.

Copy link
Copy Markdown

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adversarial Review

Critical / High

None found.

Medium

  1. packages/testing/mock_fetch.ts:100init silently dropped when input is a Request

    The mock does:

    const request = input instanceof Request ? input : new Request(input, init);

    The real fetch(request, init) API merges init overrides (headers, method, signal, etc.) into the request. The mock ignores init entirely when input is a Request, so tests using fetch(existingRequest, { headers: { "X-Extra": "val" } }) will silently lose the override. The captured CapturedFetchCall.headers will also be wrong.

    Breaking example: An extension does fetch(req, { signal }) and the test asserts on captured headers from the init — assertion passes in mock but the header was never on the request.

    Suggested fix:

    const request = input instanceof Request
      ? new Request(input, init)
      : new Request(input, init);

    (Or simply: const request = new Request(input, init); — the Request constructor already handles both overloads.)

  2. Both mocks are unsafe under concurrent test execution

    withMockedFetch and withMockedCommand replace globals (globalThis.fetch, Deno.Command). Deno runs tests concurrently by default. If two tests use withMockedFetch simultaneously, Test B captures Test A's mock as originalFetch, then restores it after Test A already cleaned up — leaving a stale mock permanently installed.

    This is a well-known limitation of global-replacement mocking and may be acceptable for a testing library, but it should be documented (e.g., "use Deno.test(..., { sanitizeOps: false }) or run tests with --jobs=1 when multiple test files use these mocks").

Low

  1. packages/testing/mock_command.ts:158outputSync() throws but callers may expect sync output

    The mock throws on outputSync(). If extension code under test uses outputSync() (reasonable for simple CLI wrappers), the mock will fail with a confusing error rather than returning mock data. This limits the mock's coverage but is clearly documented in the error message, so it's not a real bug — just a limitation to be aware of.

Verdict

PASS — The code is well-structured with proper cleanup in finally blocks, good error messages, and correct sequential/handler dispatch. The conformance test fixes (vault keys.includes(), datastore per-lock map, report Uint8Array clone, lock nonce assertions) are all genuine improvements. The fetch(Request, init) issue (Medium #1) is worth fixing but is not a blocker since it only affects an uncommon calling pattern.

@stack72 stack72 merged commit c5b3735 into main Mar 30, 2026
10 checks passed
@stack72 stack72 deleted the add-mock-primitives branch March 30, 2026 23:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant