@junejuly-lockstep/reconciler 0.1.0
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/LICENSE +21 -0
- package/README.md +182 -0
- package/dist/boot.d.ts +51 -0
- package/dist/boot.d.ts.map +1 -0
- package/dist/boot.js +162 -0
- package/dist/boot.js.map +1 -0
- package/dist/cli.d.ts +4 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +180 -0
- package/dist/cli.js.map +1 -0
- package/dist/config.d.ts +55 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +140 -0
- package/dist/config.js.map +1 -0
- package/dist/contract.d.ts +61 -0
- package/dist/contract.d.ts.map +1 -0
- package/dist/contract.js +154 -0
- package/dist/contract.js.map +1 -0
- package/dist/determinism.d.ts +23 -0
- package/dist/determinism.d.ts.map +1 -0
- package/dist/determinism.js +102 -0
- package/dist/determinism.js.map +1 -0
- package/dist/errors.d.ts +25 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +35 -0
- package/dist/errors.js.map +1 -0
- package/dist/exitCodes.d.ts +33 -0
- package/dist/exitCodes.d.ts.map +1 -0
- package/dist/exitCodes.js +49 -0
- package/dist/exitCodes.js.map +1 -0
- package/dist/extract.d.ts +30 -0
- package/dist/extract.d.ts.map +1 -0
- package/dist/extract.js +376 -0
- package/dist/extract.js.map +1 -0
- package/dist/index.d.ts +35 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +19 -0
- package/dist/index.js.map +1 -0
- package/dist/init.d.ts +17 -0
- package/dist/init.d.ts.map +1 -0
- package/dist/init.js +55 -0
- package/dist/init.js.map +1 -0
- package/dist/journeys.d.ts +34 -0
- package/dist/journeys.d.ts.map +1 -0
- package/dist/journeys.js +276 -0
- package/dist/journeys.js.map +1 -0
- package/dist/paths.d.ts +26 -0
- package/dist/paths.d.ts.map +1 -0
- package/dist/paths.js +80 -0
- package/dist/paths.js.map +1 -0
- package/dist/reconcile.d.ts +63 -0
- package/dist/reconcile.d.ts.map +1 -0
- package/dist/reconcile.js +316 -0
- package/dist/reconcile.js.map +1 -0
- package/dist/report.d.ts +33 -0
- package/dist/report.d.ts.map +1 -0
- package/dist/report.js +97 -0
- package/dist/report.js.map +1 -0
- package/dist/sync.d.ts +46 -0
- package/dist/sync.d.ts.map +1 -0
- package/dist/sync.js +110 -0
- package/dist/sync.js.map +1 -0
- package/dist/upload.d.ts +33 -0
- package/dist/upload.d.ts.map +1 -0
- package/dist/upload.js +86 -0
- package/dist/upload.js.map +1 -0
- package/dist/util.d.ts +24 -0
- package/dist/util.d.ts.map +1 -0
- package/dist/util.js +97 -0
- package/dist/util.js.map +1 -0
- package/dist/visual.d.ts +49 -0
- package/dist/visual.d.ts.map +1 -0
- package/dist/visual.js +145 -0
- package/dist/visual.js.map +1 -0
- package/dist/walk.d.ts +147 -0
- package/dist/walk.d.ts.map +1 -0
- package/dist/walk.js +433 -0
- package/dist/walk.js.map +1 -0
- package/package.json +78 -0
- package/templates/example.journey.ts +90 -0
- package/templates/lockstep-reconcile.yml +219 -0
- package/templates/lockstep.config.example.ts +46 -0
- package/templates/msw-handlers.example.ts +113 -0
- package/templates/scenarios.example.ts +32 -0
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
# Lockstep client contract, item 6. Commit this file as .github/workflows/lockstep-reconcile.yml
|
|
2
|
+
# and add the project API key to the repo secrets as LOCKSTEP_API_KEY.
|
|
3
|
+
#
|
|
4
|
+
# Runs:
|
|
5
|
+
# push (default branch) -> model sync: `lockstep sync-models` loads the journey files and
|
|
6
|
+
# scenarios and posts the parsed models to the platform. The platform
|
|
7
|
+
# never executes client code, so this job is how the canvas learns about
|
|
8
|
+
# a model change (its webhook only sees that a newer commit exists).
|
|
9
|
+
# pull_request -> report-only run (kind pr, env preview). Verdicts never fail the check.
|
|
10
|
+
# deployment_status -> deploy run on a successful deployment. Exit 3 (verdicts need judgment)
|
|
11
|
+
# fails the workflow so a red deploy is visible in GitHub and on the board.
|
|
12
|
+
# workflow_dispatch -> manual run of the checked-out commit (Actions -> Lockstep reconcile ->
|
|
13
|
+
# Run workflow): the deploy-run path for repos whose hosting never emits
|
|
14
|
+
# deployment_status, and a way to re-run a deploy run by hand. Inputs: kind
|
|
15
|
+
# (deploy|pr), env (staging|prod|preview), deploy_label (default d<run
|
|
16
|
+
# number>), pr (only for kind pr). Uses --fail-on-verdicts auto: a deploy
|
|
17
|
+
# run fails on verdicts, a pr run is report-only.
|
|
18
|
+
# repository_dispatch (lockstep-build) -> placeholder for the client's coding agent.
|
|
19
|
+
name: Lockstep reconcile
|
|
20
|
+
|
|
21
|
+
on:
|
|
22
|
+
push:
|
|
23
|
+
pull_request:
|
|
24
|
+
deployment_status:
|
|
25
|
+
workflow_dispatch:
|
|
26
|
+
inputs:
|
|
27
|
+
kind:
|
|
28
|
+
description: Run kind
|
|
29
|
+
type: choice
|
|
30
|
+
options: [deploy, pr]
|
|
31
|
+
default: deploy
|
|
32
|
+
env:
|
|
33
|
+
description: Environment the run is recorded against
|
|
34
|
+
type: choice
|
|
35
|
+
options: [staging, prod, preview]
|
|
36
|
+
default: staging
|
|
37
|
+
deploy_label:
|
|
38
|
+
description: Deploy label, e.g. d14 (empty = d<run number>)
|
|
39
|
+
type: string
|
|
40
|
+
default: ''
|
|
41
|
+
pr:
|
|
42
|
+
description: Pull request number (only for kind pr)
|
|
43
|
+
type: string
|
|
44
|
+
default: ''
|
|
45
|
+
repository_dispatch:
|
|
46
|
+
types: [lockstep-build]
|
|
47
|
+
|
|
48
|
+
concurrency:
|
|
49
|
+
group: lockstep-${{ github.workflow }}-${{ github.event.pull_request.number || github.event.deployment.sha || github.run_id }}
|
|
50
|
+
cancel-in-progress: false
|
|
51
|
+
|
|
52
|
+
permissions:
|
|
53
|
+
contents: read
|
|
54
|
+
pull-requests: read
|
|
55
|
+
|
|
56
|
+
jobs:
|
|
57
|
+
# Fast path, no browser and no build: a push to the default branch mirrors the journey models
|
|
58
|
+
# to the platform within a minute so the canvas updates on its next 10 s poll.
|
|
59
|
+
sync-models:
|
|
60
|
+
if: github.event_name == 'push' && github.ref == format('refs/heads/{0}', github.event.repository.default_branch)
|
|
61
|
+
runs-on: ubuntu-latest
|
|
62
|
+
timeout-minutes: 10
|
|
63
|
+
env:
|
|
64
|
+
LOCKSTEP_API_KEY: ${{ secrets.LOCKSTEP_API_KEY }}
|
|
65
|
+
# The reconciler never needs browsers for a model sync.
|
|
66
|
+
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: '1'
|
|
67
|
+
steps:
|
|
68
|
+
- uses: actions/checkout@v4
|
|
69
|
+
|
|
70
|
+
- uses: pnpm/action-setup@v4
|
|
71
|
+
|
|
72
|
+
- uses: actions/setup-node@v4
|
|
73
|
+
with:
|
|
74
|
+
node-version: 22
|
|
75
|
+
cache: pnpm
|
|
76
|
+
|
|
77
|
+
- run: pnpm install --frozen-lockfile
|
|
78
|
+
|
|
79
|
+
- name: Sync journey models to Lockstep
|
|
80
|
+
run: >-
|
|
81
|
+
npx lockstep sync-models
|
|
82
|
+
--ref "$GITHUB_SHA"
|
|
83
|
+
--branch "$GITHUB_REF_NAME"
|
|
84
|
+
--pushed-at "${{ github.event.head_commit.timestamp }}"
|
|
85
|
+
|
|
86
|
+
reconcile:
|
|
87
|
+
if: >-
|
|
88
|
+
github.event_name == 'pull_request' ||
|
|
89
|
+
github.event_name == 'workflow_dispatch' ||
|
|
90
|
+
(github.event_name == 'deployment_status' && github.event.deployment_status.state == 'success')
|
|
91
|
+
runs-on: ubuntu-latest
|
|
92
|
+
timeout-minutes: 30
|
|
93
|
+
env:
|
|
94
|
+
LOCKSTEP_API_KEY: ${{ secrets.LOCKSTEP_API_KEY }}
|
|
95
|
+
# deployment_status: the deployed commit; pull_request: the head commit; workflow_dispatch: the
|
|
96
|
+
# commit of the branch or tag the run was started from (GITHUB_SHA).
|
|
97
|
+
SHA: ${{ github.event_name == 'deployment_status' && github.event.deployment.sha || github.event.pull_request.head.sha || github.sha }}
|
|
98
|
+
TZ: UTC
|
|
99
|
+
steps:
|
|
100
|
+
- uses: actions/checkout@v4
|
|
101
|
+
with:
|
|
102
|
+
ref: ${{ env.SHA }}
|
|
103
|
+
# The contract diff compares the SDL against the previous commit.
|
|
104
|
+
fetch-depth: 2
|
|
105
|
+
|
|
106
|
+
- uses: pnpm/action-setup@v4
|
|
107
|
+
|
|
108
|
+
- uses: actions/setup-node@v4
|
|
109
|
+
with:
|
|
110
|
+
node-version: 22
|
|
111
|
+
cache: pnpm
|
|
112
|
+
|
|
113
|
+
- run: pnpm install --frozen-lockfile
|
|
114
|
+
|
|
115
|
+
- name: Install Chromium for Playwright
|
|
116
|
+
run: pnpm exec playwright install --with-deps chromium
|
|
117
|
+
|
|
118
|
+
- name: Build the app
|
|
119
|
+
run: pnpm build
|
|
120
|
+
|
|
121
|
+
- name: Validate the Lockstep contract
|
|
122
|
+
run: npx lockstep validate
|
|
123
|
+
|
|
124
|
+
- name: Reconcile (pull request, report-only)
|
|
125
|
+
if: github.event_name == 'pull_request'
|
|
126
|
+
run: >-
|
|
127
|
+
npx lockstep reconcile
|
|
128
|
+
--ref "$SHA"
|
|
129
|
+
--kind pr
|
|
130
|
+
--pr ${{ github.event.pull_request.number }}
|
|
131
|
+
--env preview
|
|
132
|
+
--fail-on-verdicts never
|
|
133
|
+
|
|
134
|
+
- name: Reconcile (deploy, fails on verdicts)
|
|
135
|
+
if: github.event_name == 'deployment_status'
|
|
136
|
+
run: >-
|
|
137
|
+
npx lockstep reconcile
|
|
138
|
+
--ref "$SHA"
|
|
139
|
+
--kind deploy
|
|
140
|
+
--env ${{ github.event.deployment.environment == 'production' && 'prod' || 'staging' }}
|
|
141
|
+
--deploy-label "d${{ github.run_number }}"
|
|
142
|
+
--fail-on-verdicts always
|
|
143
|
+
|
|
144
|
+
# Manual deploy-run path (no deployment_status needed). `auto` = fail on verdicts for kind
|
|
145
|
+
# deploy, report-only for kind pr; --pr and --deploy-label are only passed when they apply.
|
|
146
|
+
- name: Reconcile (manual run)
|
|
147
|
+
if: github.event_name == 'workflow_dispatch'
|
|
148
|
+
env:
|
|
149
|
+
KIND: ${{ inputs.kind }}
|
|
150
|
+
ENV_NAME: ${{ inputs.env }}
|
|
151
|
+
DEPLOY_LABEL: ${{ inputs.deploy_label != '' && inputs.deploy_label || format('d{0}', github.run_number) }}
|
|
152
|
+
PR: ${{ inputs.pr }}
|
|
153
|
+
run: |
|
|
154
|
+
set -- --ref "$GITHUB_SHA" --kind "$KIND" --env "$ENV_NAME" --fail-on-verdicts auto
|
|
155
|
+
if [ "$KIND" = "deploy" ]; then set -- "$@" --deploy-label "$DEPLOY_LABEL"; fi
|
|
156
|
+
if [ -n "$PR" ]; then set -- "$@" --pr "$PR"; fi
|
|
157
|
+
echo "npx lockstep reconcile $*"
|
|
158
|
+
npx lockstep reconcile "$@"
|
|
159
|
+
|
|
160
|
+
- name: Upload local reconcile output
|
|
161
|
+
if: always()
|
|
162
|
+
uses: actions/upload-artifact@v4
|
|
163
|
+
with:
|
|
164
|
+
name: lockstep-${{ github.run_id }}
|
|
165
|
+
path: .lockstep
|
|
166
|
+
retention-days: 14
|
|
167
|
+
if-no-files-found: ignore
|
|
168
|
+
|
|
169
|
+
# ------------------------------------------------------------------------------------------
|
|
170
|
+
# Agent build placeholder. The platform dispatches `lockstep-build` from POST /api/cards/:id/dispatch
|
|
171
|
+
# with client_payload { cardId, cardRef, branch, workOrder }. The agent runner itself is out of
|
|
172
|
+
# Lockstep's scope: wire your coding agent in the step marked below.
|
|
173
|
+
#
|
|
174
|
+
# The only contract back to the platform: the PR this job opens must contain the card ref
|
|
175
|
+
# (for example "FERN-12") in its body. The Lockstep GitHub App webhook links the PR to the card
|
|
176
|
+
# on open; merge plus a clean reconcile walks the card to In lockstep.
|
|
177
|
+
# ------------------------------------------------------------------------------------------
|
|
178
|
+
agent-build:
|
|
179
|
+
if: github.event_name == 'repository_dispatch' && github.event.action == 'lockstep-build'
|
|
180
|
+
runs-on: ubuntu-latest
|
|
181
|
+
timeout-minutes: 60
|
|
182
|
+
permissions:
|
|
183
|
+
contents: write
|
|
184
|
+
pull-requests: write
|
|
185
|
+
env:
|
|
186
|
+
CARD_ID: ${{ github.event.client_payload.cardId }}
|
|
187
|
+
CARD_REF: ${{ github.event.client_payload.cardRef }}
|
|
188
|
+
BRANCH: ${{ github.event.client_payload.branch }}
|
|
189
|
+
steps:
|
|
190
|
+
- uses: actions/checkout@v4
|
|
191
|
+
|
|
192
|
+
- name: Show the work order
|
|
193
|
+
run: |
|
|
194
|
+
echo "Card: $CARD_REF ($CARD_ID)"
|
|
195
|
+
echo "Branch: $BRANCH"
|
|
196
|
+
echo '${{ toJson(github.event.client_payload.workOrder) }}' > work-order.json
|
|
197
|
+
cat work-order.json
|
|
198
|
+
|
|
199
|
+
# ----------------------------------------------------------------------------------------
|
|
200
|
+
# WIRE YOUR CODING AGENT HERE.
|
|
201
|
+
# Input: work-order.json (brief, machine delta, scenario fixtures, acceptance paths, bindings).
|
|
202
|
+
# Output: commits on $BRANCH that implement the journey and keep the contract green.
|
|
203
|
+
# Example: run your agent CLI against work-order.json, then `git push origin HEAD:$BRANCH`.
|
|
204
|
+
# ----------------------------------------------------------------------------------------
|
|
205
|
+
- name: Run coding agent (placeholder)
|
|
206
|
+
run: |
|
|
207
|
+
echo "No agent configured. Replace this step with your agent runner."
|
|
208
|
+
exit 0
|
|
209
|
+
|
|
210
|
+
# The PR body MUST include the card ref so the platform can link it. Keep the first line.
|
|
211
|
+
- name: Open the pull request
|
|
212
|
+
if: false # set to true once the agent step above produces commits on $BRANCH
|
|
213
|
+
env:
|
|
214
|
+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
215
|
+
run: |
|
|
216
|
+
gh pr create \
|
|
217
|
+
--head "$BRANCH" \
|
|
218
|
+
--title "$CARD_REF: $(jq -r '.brief | split("\n")[0]' work-order.json)" \
|
|
219
|
+
--body "$(printf 'Lockstep card %s\n\n%s\n\nGenerated from work order %s.' "$CARD_REF" "$(jq -r .brief work-order.json)" "$CARD_ID")"
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { defineConfig } from '@junejuly-lockstep/reconciler';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Lockstep client contract, item 1. Lives at the repo root as `lockstep.config.ts`.
|
|
5
|
+
* Every relative path resolves from this file's directory.
|
|
6
|
+
*/
|
|
7
|
+
export default defineConfig({
|
|
8
|
+
/** Project key on the platform; cards render as FERN-12. Uppercase, 2-8 chars. */
|
|
9
|
+
projectKey: 'FERN',
|
|
10
|
+
|
|
11
|
+
/** Platform API origin the reconciler posts runs to. */
|
|
12
|
+
apiBaseUrl: 'https://api.lockstep.example.com',
|
|
13
|
+
|
|
14
|
+
/** One file per journey, each exporting `machine` (XState v5 createMachine) and `meta`. */
|
|
15
|
+
journeysGlob: 'src/journeys/*.journey.ts',
|
|
16
|
+
|
|
17
|
+
/** Named personas: seed plus mock overrides. */
|
|
18
|
+
scenariosPath: 'src/journeys/scenarios.ts',
|
|
19
|
+
|
|
20
|
+
/** Ordered seam strip, 6 to 8 names. Backend changes tag the seams they touch. */
|
|
21
|
+
seams: ['Client app', 'API layer', 'Scheduling', 'Patient records', 'Notifications', 'Identity'],
|
|
22
|
+
|
|
23
|
+
/** Fraction of differing pixels above which a screenshot is `changed`. */
|
|
24
|
+
visualDiffThreshold: 0.001,
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* How the reconciler boots the app under test. The command receives PORT, LOCKSTEP_SCENARIO
|
|
28
|
+
* and LOCKSTEP_SEED in its environment; the walker also appends `?lockstep_scenario=` to the
|
|
29
|
+
* first navigation so the MSW layer can seed itself in the browser.
|
|
30
|
+
*/
|
|
31
|
+
bootCommand: 'pnpm exec next start --port 3100',
|
|
32
|
+
port: 3100,
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Security rule: walks only ever hit mocks. `appApiBase` is the origin the app talks to and
|
|
36
|
+
* must match `mockApiPattern`, otherwise the reconciler refuses to run.
|
|
37
|
+
*/
|
|
38
|
+
appApiBase: 'msw://patient-api',
|
|
39
|
+
mockApiPattern: '^(mock|msw):|localhost|127\\.0\\.0\\.1',
|
|
40
|
+
|
|
41
|
+
/** GraphQL contract: the SDL and the graphql-codegen TypeScript output. */
|
|
42
|
+
schema: {
|
|
43
|
+
sdlPath: 'schema.graphql',
|
|
44
|
+
codegenOutput: 'src/gql/graphql.ts',
|
|
45
|
+
},
|
|
46
|
+
});
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { graphql, HttpResponse } from 'msw';
|
|
2
|
+
import { scenarios } from '../journeys/scenarios';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Lockstep client contract, item 4: MSW handlers that are deterministic under a fixed seed.
|
|
6
|
+
*
|
|
7
|
+
* Rules that keep screenshots byte-identical run to run:
|
|
8
|
+
* - No faker, no Math.random, no Date.now(). Every value derives from the seed below or from
|
|
9
|
+
* the frozen epoch the walker installs (FROZEN_EPOCH_MS in @junejuly-lockstep/reconciler).
|
|
10
|
+
* - The scenario comes from `?lockstep_scenario=` in the browser or LOCKSTEP_SCENARIO on the
|
|
11
|
+
* server; both are set by the reconciler. Default to the first scenario in dev.
|
|
12
|
+
* - Shapes follow the generated types from graphql-codegen so a schema change shows up here
|
|
13
|
+
* as a type error before it shows up as a contract verdict.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** mulberry32: tiny, fast, fully deterministic PRNG. */
|
|
17
|
+
export function seeded(seed: number): () => number {
|
|
18
|
+
let a = seed >>> 0;
|
|
19
|
+
return () => {
|
|
20
|
+
a = (a + 0x6d2b79f5) >>> 0;
|
|
21
|
+
let t = a;
|
|
22
|
+
t = Math.imul(t ^ (t >>> 15), t | 1);
|
|
23
|
+
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
|
24
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const CLINICIANS = ['Dr. Amara Okafor', 'Dr. Ben Liu', 'Dr. Carla Mendes', 'Dr. Dev Patel'];
|
|
29
|
+
const LOCATIONS = ['Downtown clinic', 'Riverside clinic', 'Telehealth'];
|
|
30
|
+
|
|
31
|
+
/** The walker freezes the page clock here; derive every date from it, never from Date.now(). */
|
|
32
|
+
export const FROZEN_EPOCH_MS = Date.UTC(2026, 0, 15, 9, 30, 0);
|
|
33
|
+
|
|
34
|
+
export function currentScenario(): (typeof scenarios)[number] {
|
|
35
|
+
let name: string | undefined;
|
|
36
|
+
if (typeof window !== 'undefined') {
|
|
37
|
+
name = new URLSearchParams(window.location.search).get('lockstep_scenario') ?? undefined;
|
|
38
|
+
if (name) window.sessionStorage.setItem('lockstep_scenario', name);
|
|
39
|
+
name ??= window.sessionStorage.getItem('lockstep_scenario') ?? undefined;
|
|
40
|
+
} else if (typeof process !== 'undefined') {
|
|
41
|
+
name = process.env['LOCKSTEP_SCENARIO'];
|
|
42
|
+
}
|
|
43
|
+
return scenarios.find((s) => s.name === name) ?? scenarios[0]!;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function pick<T>(rand: () => number, list: readonly T[]): T {
|
|
47
|
+
return list[Math.floor(rand() * list.length)]!;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function slotsFor(scenario: (typeof scenarios)[number]) {
|
|
51
|
+
const rand = seeded(scenario.seed);
|
|
52
|
+
const count =
|
|
53
|
+
typeof scenario.overrides['availableSlots'] === 'number'
|
|
54
|
+
? (scenario.overrides['availableSlots'] as number)
|
|
55
|
+
: 6;
|
|
56
|
+
const slots = [];
|
|
57
|
+
for (let i = 0; i < count; i++) {
|
|
58
|
+
const dayOffset = 1 + Math.floor(rand() * 7);
|
|
59
|
+
const hour = 9 + Math.floor(rand() * 8);
|
|
60
|
+
slots.push({
|
|
61
|
+
id: `slot-${scenario.seed}-${i}`,
|
|
62
|
+
startsAt: new Date(FROZEN_EPOCH_MS + dayOffset * 86_400_000 + hour * 3_600_000).toISOString(),
|
|
63
|
+
clinician: pick(rand, CLINICIANS),
|
|
64
|
+
location: pick(rand, LOCATIONS),
|
|
65
|
+
available: true,
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
return slots.sort((a, b) => a.startsAt.localeCompare(b.startsAt));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export const handlers = [
|
|
72
|
+
graphql.query('Viewer', () => {
|
|
73
|
+
const scenario = currentScenario();
|
|
74
|
+
const rand = seeded(scenario.seed);
|
|
75
|
+
return HttpResponse.json({
|
|
76
|
+
data: {
|
|
77
|
+
viewer: {
|
|
78
|
+
id: `patient-${scenario.seed}`,
|
|
79
|
+
firstName: scenario.name.split('-')[0]!.replace(/^./, (c) => c.toUpperCase()),
|
|
80
|
+
eligible: true,
|
|
81
|
+
upcomingVisits: Array.from(
|
|
82
|
+
{ length: Number(scenario.overrides['upcomingVisits'] ?? 0) },
|
|
83
|
+
(_, i) => ({
|
|
84
|
+
id: `visit-${scenario.seed}-${i}`,
|
|
85
|
+
startsAt: new Date(FROZEN_EPOCH_MS + (i + 2) * 86_400_000).toISOString(),
|
|
86
|
+
clinician: pick(rand, CLINICIANS),
|
|
87
|
+
}),
|
|
88
|
+
),
|
|
89
|
+
},
|
|
90
|
+
},
|
|
91
|
+
});
|
|
92
|
+
}),
|
|
93
|
+
|
|
94
|
+
graphql.query('Slots', () => {
|
|
95
|
+
return HttpResponse.json({ data: { slots: slotsFor(currentScenario()) } });
|
|
96
|
+
}),
|
|
97
|
+
|
|
98
|
+
graphql.mutation('BookVisit', ({ variables }) => {
|
|
99
|
+
const scenario = currentScenario();
|
|
100
|
+
const slot =
|
|
101
|
+
slotsFor(scenario).find((s) => s.id === (variables as { slotId?: string }).slotId) ??
|
|
102
|
+
slotsFor(scenario)[0];
|
|
103
|
+
return HttpResponse.json({
|
|
104
|
+
data: {
|
|
105
|
+
bookVisit: {
|
|
106
|
+
id: `booking-${scenario.seed}`,
|
|
107
|
+
reference: `FERN-${String(scenario.seed).padStart(4, '0')}`,
|
|
108
|
+
slot,
|
|
109
|
+
},
|
|
110
|
+
},
|
|
111
|
+
});
|
|
112
|
+
}),
|
|
113
|
+
];
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { Scenario } from '@junejuly-lockstep/reconciler';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Lockstep client contract, item 3: named personas. Each scenario is a seed plus overrides for
|
|
5
|
+
* the schema-generated mock handlers. The reconciler boots the app once per scenario with
|
|
6
|
+
* LOCKSTEP_SCENARIO=<name> and LOCKSTEP_SEED=<seed>, and opens the first route with
|
|
7
|
+
* `?lockstep_scenario=<name>&lockstep_seed=<seed>`.
|
|
8
|
+
*
|
|
9
|
+
* Keep seeds stable: changing a seed changes every screenshot and forces a baseline review.
|
|
10
|
+
*/
|
|
11
|
+
export const scenarios: Scenario[] = [
|
|
12
|
+
{
|
|
13
|
+
name: 'nora',
|
|
14
|
+
description: 'Nora, eligible, no visit history',
|
|
15
|
+
seed: 1,
|
|
16
|
+
overrides: {},
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
name: 'omar',
|
|
20
|
+
description: 'Omar, returning, one upcoming visit, two slots left this week',
|
|
21
|
+
seed: 2,
|
|
22
|
+
overrides: { upcomingVisits: 1, availableSlots: 2 },
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
name: 'priya-no-slots',
|
|
26
|
+
description: 'Priya, eligible, no slots available in the next seven days',
|
|
27
|
+
seed: 3,
|
|
28
|
+
overrides: { availableSlots: 0 },
|
|
29
|
+
},
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
export default scenarios;
|