Skip to content

[POC] #1034 Test A: client-side MSW mock playground - #1049

Draft
karinamzalez wants to merge 10 commits into
mainfrom
karina/playground-spike
Draft

[POC] #1034 Test A: client-side MSW mock playground#1049
karinamzalez wants to merge 10 commits into
mainfrom
karina/playground-spike

Conversation

@karinamzalez

@karinamzalez karinamzalez commented Jul 30, 2026

Copy link
Copy Markdown
Collaborator

Summary

Important

This PR's role has changed since it was opened. The review feedback here, plus the onboarding/quickstart discussion, shifted the spike ADR's recommendation away from client-side MSW (this PR) toward a small standalone Cloudflare Worker serving the mock: one real URL that answers the browser, curl, and the SDK identically, while the docs site stays pure static on GH Pages, untouched. This PR now serves two purposes:

  1. Evidence: it proves the client-side path works end-to-end (Test A), and documents why spec-generated responses aren't enough on their own.
  2. The portable data layer: the fixture and handlers built here are what the recommended option would reuse. Of handlers.ts's ~580 lines, MSW touches six (the import, two HttpResponse.json wrappers, three route registrations)-- the filter/sort/error logic is pure request->response code that ports into a Worker nearly as-is.

The browser-vs-CLI/SDK trade-off is deliberately not resolved in this PR -- that's the ADR's central question, so please put architecture-level thoughts there. Review here is scoped to: are the fidelity must-dos/should-dos met?

Expected end state: if the team approves the standalone-Worker direction, this PR closes unmerged with a pointer to the ADR and the successor PR; the fixture/handler modules carry forward.

Changes proposed

What was added, updated, or removed in this PR.

  • Add MSW + @mswjs/source and initialize the mockServiceWorker.js; add a throwaway /protocol/mock-playground route rendering the shared OpenApiDocs island with "Try it out" enabled, generating handlers from the rendered OpenAPI specs via fromOpenApi() and swapping the active set across v0.1.0/v0.2.0/v0.3.0 (the shipped /protocol/api-docs page still ships with Try-it-out disabled).
  • New opportunities/fixtures.ts: 11 semantic, version-aware opportunity records built from the TypeSpec @example decorators instead of faker output. One reproduces the spec's published example under the id Swagger UI pre-fills, so the default Execute returns that record rather than a 404, and it sorts first in the list response.
  • New opportunities/handlers.ts: hand-authored list/detail/search reading from that fixture: deterministic, mutually consistent, with real status/date/money-range filters and OppSortBy sorting. Bad filter bounds and out-of-range pagination are rejected rather than silently ignored.
  • New error affordances, listed on the playground page so they're discoverable: 404 for an unknown UUID; 400 for a malformed id, an unknown sortBy/operator, a malformed filter bound or JSON body, and page/pageSize below the spec's minimum: 1-- all in the protocol's { status, message, errors[] } envelope.
  • MockPlayground prepends the opportunity handlers ahead of the generated set (MSW resolves first-match-wins); spec-handlers.ts memoizes the rest, so repeat calls are identical across the whole surface for the session.
  • Tests across all three protocol versions: determinism, list -> detail consistency, filters, sorting, pagination, error shapes, and handler precedence.

Context for reviewers

Testing instructions, background context, more in-depth details of the implementation, and anything else you'd like to call out or ask reviewers. Explain how the changes were verified.

Root cause of the fidelity gap. fromOpenApi() falls back to seedSchema(), which calls @faker-js/faker with no faker.seed() -- fresh data every call and ignores OpenAPI's singular example, the keyword our @example decorators compile to. Structural, not configuration: schema-seeding can't resolve a path param, keep two endpoints consistent, apply filters, or emit errors. (This applies to any schema-sampling mock, including Prism's dynamic mode, which is why the fixture layer is needed under every option, not just MSW.)

Fix: a hybrid. Fixture-backed handlers for the three opportunity endpoints, layered ahead of the generated set that still covers every other path. Rejected: seeding faker (repeatable, but nothing else); adding response-level examples to the TypeSpec (edits the published protocol to serve a docs feature); hand-authoring everything (loses the breadth that makes the playground worth visiting).

How to verify -- open /protocol/mock-playground; the intro block lists the error inputs.

  • GET /opportunities -> Execute twice -> same body. First item is Small business grant program.
  • GET /opportunities/{oppId} -> Execute without touching the pre-filled id → 200, matching the "Example Value" pane above it. Any id from the list resolves to that same record.
  • Search with status in [open] -> only open records; flip sortOrder → exact reverse.
  • Version dropdown -> v0.1 omits competitions and acceptedApplicantTypes.
  • Any other endpoint -> still responds; Execute twice → identical.

Verified locally: 146 tests pass, pnpm --filter website run checks clean, plus the flow above against a dev server.

Trade-offs. The fixture is hand-maintained, so it can drift from the TypeSpec models -- tests catch gross shape drift, not full conformance. Two of the 400s aren't declared responses on their operations, so Swagger UI labels them "Undocumented"; either the mock is over-strict or the protocol under-specifies param validation. Long-tail memoization is session-scoped and resets on reload.

Additional information

Screenshots, GIF demos, code examples or output to help show the changes working as expected.

Add Mock Service Worker (msw ^2.15.0) and @mswjs/source ^0.5.0 to the
website package and commit the generated public/mockServiceWorker.js worker
asset (via `msw init`), so later tickets can intercept Swagger UI "Try it out"
requests entirely client-side. The existing js-yaml dependency already provides
the spec-parsing path, so no new YAML parser was added. The vendored worker
script is excluded from prettier and cspell. Verified by a green
`pnpm --filter website run build` (which produces dist/mockServiceWorker.js)
and a passing `pnpm --filter website run checks`.

Refs #1034

Files changed:
- website/package.json
- pnpm-lock.yaml
- website/.prettierignore
- website/.cspell.json
- website/public/mockServiceWorker.js
…nd-written handler

Adds a throwaway MSW playground that proves client-side interception of
Swagger UI "Try it out" requests. A new /protocol/mock-playground route
renders the shared OpenApiDocs island with "Try it out" enabled and starts a
Mock Service Worker that answers GET /common-grants/opportunities with a
schema-valid example body sampled from the rendered OpenAPI spec via
openapi-sampler. The shipping /protocol/api-docs page is unchanged
(OpenApiDocs gains a backwards-compatible enableTryItOut prop defaulting to
false).

Refs #1034

Files changed:
- website/src/lib/mock/opportunities-handler.ts
- website/__tests__/lib/mock/opportunities-handler.spec.ts
- website/src/components/MockPlayground.tsx
- website/src/pages/protocol/mock-playground.astro
- website/src/components/OpenApiDocs.tsx
…all three versions)

Replaces T2's single hand-written handler with handlers generated from the
rendered OpenAPI spec via @mswjs/source fromOpenApi(), and swaps the active
handler set whenever the version dropdown changes so Try-it-out reflects the
selected version (0.1.0 / 0.2.0 / 0.3.0). Resolves the spike's central
base-URL question: because the specs declare no `servers:` block,
@mswjs/source falls back to a `/` base and emits same-origin relative-path
handlers matching exactly what Swagger UI targets — no base-URL patching
needed. OpenApiDocs gains an optional onVersionChange callback; MockPlayground
regenerates handlers via worker.resetHandlers with a latest-wins guard and
fetch/parse error handling. The now-superseded T2 opportunities-handler module
is removed (its OpenApiSpec type moved into spec-handlers).

Refs #1034

Files changed:
- website/src/lib/mock/spec-handlers.ts (new)
- website/__tests__/lib/mock/spec-handlers.spec.ts (new)
- website/src/components/MockPlayground.tsx
- website/src/components/OpenApiDocs.tsx
- website/src/lib/mock/opportunities-handler.ts (removed)
- website/__tests__/lib/mock/opportunities-handler.spec.ts (removed)
@github-actions github-actions Bot added website Issues related to the website dependencies Pull requests that update a dependency file typescript Issue or PR related to TypeScript tooling labels Jul 30, 2026
@github-actions

Copy link
Copy Markdown
Contributor

🚀 Website Preview Deployed!

Preview your changes at: https://cg-pr-1049.billy-daly.workers.dev

This preview will be automatically deleted when the PR is closed.

@karinamzalez
karinamzalez force-pushed the karina/playground-spike branch from 948131f to 31da123 Compare July 30, 2026 02:12
@karinamzalez

Copy link
Copy Markdown
Collaborator Author

Verified on the Cloudflare preview (cg-pr-1049.billy-daly.workers.dev): the MSW service worker registers and Swagger UI Try it out → Execute returns 200 OK (from ServiceWorker) with schema-valid bodies for GET /common-grants/opportunities across all three versions (v0.1.0 / v0.2.0 / v0.3.0), the version dropdown swapping the active handler set.

Base-URL finding: specs have no servers: block → @mswjs/source emits relative same-origin handlers matching exactly what Swagger UI targets — no base-URL patching needed.

Known caveat (fix before productionizing): the root-scoped worker intercepts the page's own navigation requests; a navigation hitting Astro's trailing-slash 307 logs Failed to fetch in MSW's passthrough (can't re-fetch a navigate-mode request). Blast radius is narrow — MSW bypasses when no client has started the worker, so only the active playground page is affected and the canonical trailing-slash URL is clean. Fix: bypass navigate-mode requests and/or unregister the worker on leaving the playground.

CI's website validate is red only at the pre-existing repo-wide dependency audit (--level high); build/checks/tests pass and the preview deploys regardless.

@karinamzalez

karinamzalez commented Jul 30, 2026

Copy link
Copy Markdown
Collaborator Author
Screenshot 2026-07-29 at 11 04 14 PM Screenshot 2026-07-29 at 11 05 36 PM Screenshot 2026-07-29 at 11 06 05 PM

@widal001

widal001 commented Aug 3, 2026

Copy link
Copy Markdown
Collaborator

@karinamzalez Thanks for all of your work on this! It's very cool to see it live.

While this an excellent proof of concept, there are a few key things I'd love to iron out in this test before we can consider MSW a viable path forward:

Must do

  1. Deterministic responses: Repeated calls to the same API endpoint, with the same inputs return the same value. Right now multiple calls to the same API endpoint e.g. GET /common-grants/opportunities/30a12e5e-5940-4c08-921c-17a8960fcf4b return different response bodies each time, including an id value that doesn't match the path param.
  2. Consistent across API endpoints: If users call GET /common-grants/opportunities/ and then GET /common-grants/opportunities/{oppId} with the ID from the list response, they should see the same data across those two response calls.
  3. Semantically representative responses: In addition to being consistent across multiple calls to the same endpoint, the data returned should also be semantically representative of the data returned by an actual endpoint, so that visitors can build some intuition around the expected values per field in addition to their basic shape.

Should do

  1. Valid filters and sorting: If users include sorting and filtering in the request, they should get a difference response than without those inputs. And the responses returned shouldn't contradict the filter/sorting params included (e.g. a status that's not in the list of statuses passed to the request)
  2. Option to test errors: There should be a way to surface non-2xx responses, like missing opportunity IDs (404) and validation errors, ideally ones that would match the error structure that we outline.

Adds a hand-authored, frozen fixture of 10 funding opportunities spanning
all four statuses (forecasted/open/closed/custom), varied funding amounts,
and spread-out close dates, so filters and sorting will visibly change
results in the deterministic handlers built on top of it (#1034-T5/T6).
Values are assembled from the TypeSpec @example decorators; the first
three records keep their ids/titles from lib/ts-sdk/examples/mock-api-server.ts
so cross-repo examples stay recognizable. shapeOpportunityForVersion()
projects a full (v0.3, detail) record down to the version/variant a given
endpoint should emit (stripping acceptedApplicantTypes and competitions
for v0.1, and competitions from any list-variant projection).

Refs #1034
Adds the POST /common-grants/opportunities/search handler to
buildOpportunityHandlers: applies the status (in/notIn), closeDateRange,
totalFundingAvailableRange, minAwardAmountRange, and maxAwardAmountRange
filters (currency-aware, partial bounds supported) plus OppSortBy sorting
and body pagination over the shared opportunity fixture, and echoes
sortInfo/filterInfo per the protocol envelope. Malformed bodies, unknown
sort fields/operators, and malformed filter shapes return a 400 with the
protocol Error shape instead of throwing.

Note: this file (handlers.ts) and its test file also carry the pre-existing
list/detail handlers and tests from #1034-T5, which were already present in
the working tree uncommitted; they land in this commit since git can't split
a single file's hunks into two commits atomically here.

Refs #1034
…, 404/400)

Rounds out the list/detail opportunity handlers (already landed inside the
T6 commit, which couldn't be split from these pre-existing uncommitted
changes) with review follow-ups: adds pagination edge-case tests (page
past the end, pageSize clamping/defaults) that weren't previously locked
in, extracts a shared resolvePagination() helper used by both the list and
search handlers (unifying pageSize=0 to clamp to 1 instead of silently
defaulting to 100), extracts a successResponse() envelope helper alongside
the existing errorResponse() one, and corrects the UUID_PATTERN comment to
not overclaim RFC 4122 conformance.

Refs #1034
… long-tail

Prepends buildOpportunityHandlers ahead of the generated fromOpenApi set in
MockPlayground.handleVersionChange so opportunity endpoints override the
long tail (first-match-wins). spec-handlers.ts gains a memoization wrapper
so every other endpoint replays a byte-identical cached response, keyed on
method+path+sorted-query+body, instead of reseeding faker on each call.

Refs #1034
Parametrizes opportunity handler tests (determinism, list-detail
consistency, filter/sort correctness, 404/400 error shapes) across all
three protocol versions via it.each, closing the version-coverage gap
left after #1034-T5/T6. Test-only change; no production code touched.

Refs #1034
Follow-ups from the review of the mock playground implementation.

Swagger UI pre-fills the `oppId` box from the specs' `Types.uuid` example
(30a12e5e-...), so the first Execute a visitor ran -- field untouched --
answered 404, and the rendered "Example Value" pane described a record the
mock had never heard of. Add a fixture record reproducing that published
example verbatim, sorted first by lastModifiedAt so the list -> detail
round-trip is the obvious next click.

Validate filter bound *values*, not just operators: a bound that isn't a
date, or isn't a Money object, now returns 400 instead of being silently
dropped -- dropping it answered a filtered request with unfiltered results.
Report unapplied customFilters through filterInfo.errors, the spec's channel
for non-fatal filtering errors.

Reject page/pageSize below the spec's `minimum: 1` rather than clamping, and
drop the MAX_PAGE_SIZE cap -- 100 is the declared default, not a maximum.
Derive totalPages the same way on list and search, so an empty result set
reports zero pages on both.

Break sort ties on `id` so `desc` is the exact reverse of `asc`; without it,
records sharing a sort value kept their incoming order under a stable sort.

Surface the reserved error inputs on the playground page itself, built from
the exported id constants so the docs can't drift from the handlers.

Also: evict a failed resolver's cache key instead of replaying the rejection
forever; derive Version from SUPPORTED_VERSIONS and guard the dropdown value
so an unsupported version degrades visibly rather than being cast through;
move personal AI-tooling ignores out of the shared .gitignore.

Files changed:
- website/src/lib/mock/opportunities/fixtures.ts
- website/src/lib/mock/opportunities/handlers.ts
- website/src/lib/mock/spec-handlers.ts
- website/src/components/MockPlayground.tsx
- website/src/pages/protocol/mock-playground.astro
- website/__tests__/lib/mock/*.spec.ts
- .gitignore
Restore .gitignore to match main. The AI-tooling entries added earlier in
this branch are personal workflow artifacts and now live in a local
.git/info/exclude; the leftover trailing-whitespace tweak is unrelated to
this spike. Net effect: .gitignore no longer appears in this PR's diff.
@karinamzalez

karinamzalez commented Aug 4, 2026

Copy link
Copy Markdown
Collaborator Author

@widal001 Thanks for the review! All five items are addressed on this branch-- verifiable on the preview at /protocol/mock-playground (the intro block lists the error inputs):

  1. Deterministic ✅ Opportunity endpoints read from a frozen fixture, so repeat calls are byte-identical and detail echoes the requested id. Your example id (30a12e5e-…) now returns the spec's own published example record. Everything else is memoized per session.
  2. Consistent across endpoints ✅ List, detail, and search project from one fixture; any id from the list resolves through detail to the same record.
  3. Semantically representative ✅ 11 records built from the TypeSpec @example decorators (not faker), spanning all four OppStatus values, version-aware.
  4. Filters/sortingstatus, closeDateRange, and funding-range filters apply; sorting is stable and reversible; malformed bounds are a 400 rather than silently dropped.
  5. Errors ✅ Documented affordances return the protocol { status, message, errors[] } envelope: unknown UUID -> 404; malformed id/sort/operator/filter/body/pagination -> 400.

Why it took a fixture: fromOpenApi() generates from the schema via unseeded faker and ignores the example keyword our @example decorators compile to-- so no configuration could meet items 1–3. That's true of any schema-sampling engine (including Prism's dynamic mode), which means this data layer is needed under every option-- i.e. it's portable, not MSW-specific patching :)

Bigger implication: your must-dos plus the browser / curl / SDK continuity point shifted the ADR's recommendation to a small standalone Worker serving one real URL for all three consumers (docs site stays static on GH Pages), reusing this PR's fixture/handlers. Are you open to taking the architecture discussion to the ADR? I'm still making small updates but here is the link: https://drive.google.com/file/d/1EqvksgT5zesQelSGvv0XrujDagxDJA4U/view?usp=drive_link

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

dependencies Pull requests that update a dependency file typescript Issue or PR related to TypeScript tooling website Issues related to the website

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants