@fabrika/app 0.4.0 → 0.4.2

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.
@@ -111,6 +111,11 @@ export const BROWSER_CONFIG_FILE = '__fabrika-config.json'
111
111
  /** What every `c.spa` mount serves under its prefix to the person its access admitted: label, mailbox and actions. */
112
112
  export const PAGE_CALLER_FILE = '__fabrika-me.json'
113
113
 
114
+ /** The row of an SPA mount's bare prefix (`GET /admin`), which redirects to the mount (`/admin/`). */
115
+ export function spaPrefixRowId(mount: string): string {
116
+ return `${mount}.prefix`
117
+ }
118
+
114
119
  /**
115
120
  * The value a generated endpoint access case sends in an input field that names a member the handler looks up
116
121
  * (`members` of `endpoint-access.fixtures.ts`); the endpoint access kit replaces it with a real member of the directory.
@@ -38,14 +38,14 @@ import type { FileObjectStore } from '@fabrika/platform'
38
38
  import { compileContract } from '../contract/compile'
39
39
  import { exportDescription } from '../contract/connect'
40
40
  import { isProcedure, type ProcedureDeclaration, type ProcedureTreeDeclaration, SURFACE_SECTIONS } from '../contract/declaration'
41
- import { exportBase, FORM_CHALLENGE_FIELD, FORM_LIMITS } from '../contract/defaults'
41
+ import { exportBase, FORM_CHALLENGE_FIELD, FORM_LIMITS, spaPrefixRowId } from '../contract/defaults'
42
42
  import type { Schema } from '../contract/define'
43
43
  import { kitOriginOf } from '../contract/handled'
44
44
  import type { RequestExecutionContext } from '../fetch'
45
45
  import { FILE_SWEEP_JOB, FILE_SWEEP_SCHEDULE, FILE_SWEEP_TIMEOUT_MS, fileCollections } from '../files/collections'
46
46
  import { FILE_SCAN_JOB, FILE_SCAN_SCHEDULE, type FileScanner, SCAN_LIMITS } from '../files/scan'
47
47
  import { logReporter, type OperationsReporter, operationsReporter, operationsSettings, type ReportSource } from '../jobs/report'
48
- import { bindJobs, type ContractJobs } from '../jobs/runner'
48
+ import { bindJobs, type ContractJobs, type HandlerErrorSink } from '../jobs/runner'
49
49
  import { browserConfigs } from './browser-config'
50
50
  import { createCapabilityIssuer, requestIsReadOnly } from './capability'
51
51
  import { type ChallengeFetch, type ChallengeVerifier, turnstileVerifier } from './challenge'
@@ -116,6 +116,16 @@ export interface BootOptions {
116
116
  readonly operationsFetch?: (request: Request) => Promise<Response>
117
117
  /** What asks a form's challenge provider to verify a token; `fetch` by default. */
118
118
  readonly challengeFetch?: ChallengeFetch
119
+ /**
120
+ * The developer's own machine only (`fabrika app dev`): the token a form's challenge admits more than once, which
121
+ * Turnstile's always-passing test widget hands out for every submission. Every other token stays single use.
122
+ */
123
+ readonly repeatableChallengeToken?: string
124
+ /**
125
+ * The developer's own machine only (`fabrika app dev`): what a job's handler threw, which every log of a deployed
126
+ * application withholds because it may carry a credential.
127
+ */
128
+ readonly revealHandlerErrors?: HandlerErrorSink
119
129
  }
120
130
 
121
131
  export interface ContractApp {
@@ -527,6 +537,7 @@ function formAdmission(name: string, artifact: ContractArtifact, options: BootOp
527
537
  perForm: mount.rate?.perForm ?? FORM_LIMITS.perForm,
528
538
  fields: Object.freeze(submissionFieldsOf(mount)),
529
539
  challenge,
540
+ repeatableToken: challenge === null ? null : (options.repeatableChallengeToken ?? null),
530
541
  })
531
542
  }
532
543
 
@@ -640,6 +651,10 @@ export function boot(app: ContractImplementation, options: BootOptions): Contrac
640
651
  } else if (config !== undefined && row?.kind === 'public-spa') {
641
652
  endpoints.set(name, { kind: 'public-spa', row, prefix: mount.prefix, dir: mount.dir, config })
642
653
  }
654
+ const bare = rows.get(spaPrefixRowId(name))
655
+ if (bare !== undefined) {
656
+ endpoints.set(bare.id, { kind: 'spa-prefix', row: bare, prefix: mount.prefix })
657
+ }
643
658
  } else if (mount.kind === 'rpc' || mount.kind === 'link') {
644
659
  if (mount.kind === 'link') {
645
660
  const row = rows.get(name)
@@ -728,6 +743,7 @@ export function boot(app: ContractImplementation, options: BootOptions): Contrac
728
743
  membership,
729
744
  capabilities: issuer.authority,
730
745
  containers,
746
+ ...(options.revealHandlerErrors === undefined ? {} : { revealHandlerErrors: options.revealHandlerErrors }),
731
747
  }, problems)
732
748
  for (const id of jobs.declared) {
733
749
  declared.add(id)
@@ -20,7 +20,11 @@ export const CHALLENGE_LIMITS = {
20
20
  maxSkewMs: 60_000,
21
21
  } as const
22
22
 
23
- export type ChallengeFetch = (request: Request) => Promise<Response>
23
+ /**
24
+ * How the siteverify request reaches the provider. `check` is what a passing answer confirms (the host and the form),
25
+ * for a stand-in that answers as the provider would; the provider itself reads only the request.
26
+ */
27
+ export type ChallengeFetch = (request: Request, check: ChallengeCheck) => Promise<Response>
24
28
 
25
29
  /** `failed` is the visitor's (400); `unavailable` and `misconfigured` are the platform's or the operator's (503). */
26
30
  export type ChallengeOutcome = 'passed' | 'failed' | 'unavailable' | 'misconfigured'
@@ -73,6 +77,7 @@ export function turnstileVerifier(secret: string, fetch: ChallengeFetch, now: ()
73
77
  redirect: 'manual',
74
78
  signal: deadline.signal,
75
79
  }),
80
+ check,
76
81
  )
77
82
  const type = response.headers.get('Content-Type')?.split(';')[0]?.trim().toLowerCase()
78
83
  if (response.status !== 200 || type !== 'application/json') {
@@ -124,6 +124,8 @@ export type ServedEndpoint =
124
124
  /** What an export's consumer vendors, behind the export's own callers. */
125
125
  | { readonly kind: 'description'; readonly row: CredentialedEndpoint; readonly description: ExportDescription }
126
126
  | { readonly kind: 'spa'; readonly row: CredentialedEndpoint; readonly prefix: string; readonly dir: string; readonly config: BrowserConfig }
127
+ /** An SPA mount's bare prefix (`/admin`): the proxy admitted it under the mount's access; it redirects to `/admin/`. */
128
+ | { readonly kind: 'spa-prefix'; readonly row: EndpointRow; readonly prefix: string }
127
129
  /** An SPA anyone opens: anonymous, served without a token. */
128
130
  | { readonly kind: 'public-spa'; readonly row: EndpointRow; readonly prefix: string; readonly dir: string; readonly config: BrowserConfig }
129
131
  | { readonly kind: 'health'; readonly row: EndpointRow }
@@ -172,6 +174,8 @@ export interface FormAdmission {
172
174
  readonly fields: SubmissionFields
173
175
  /** The challenge's verifier, with the secret the application variable holds; `null` without a challenge. */
174
176
  readonly challenge: ChallengeVerifier | null
177
+ /** The one token the challenge does not record, so it passes again: `fabrika app dev`'s test widget's; `null` otherwise. */
178
+ readonly repeatableToken: string | null
175
179
  }
176
180
 
177
181
  export interface RuntimeParts {
@@ -290,6 +294,8 @@ export class ContractRuntime {
290
294
  }
291
295
  case 'public-spa':
292
296
  return await this.#spaFile(served, request, null)
297
+ case 'spa-prefix':
298
+ return new Response(null, { status: 308, headers: { Location: `${served.prefix}/${new URL(request.url).search}` } })
293
299
  case 'link-spa':
294
300
  if (rawRequestTarget(request.url).pathname === bootstrapPath(served.prefix)) {
295
301
  return bootstrapResponse()
@@ -720,7 +726,7 @@ export class ContractRuntime {
720
726
  return jsonResponse({ result: { received: true } }, 200)
721
727
  }
722
728
  if (admission.challenge !== null) {
723
- await this.#challenge(served.form, admission.challenge, submission.token, request)
729
+ await this.#challenge(served.form, admission.challenge, submission.token, request, admission.repeatableToken)
724
730
  }
725
731
  // One transaction: a row that fails to insert gives its place in the form's bucket back.
726
732
  if (!(await this.#framework().appendToInbox(served.form, submission.row, { bucket: ACCEPTED_BUCKET, limit: admission.perForm }))) {
@@ -734,8 +740,11 @@ export class ContractRuntime {
734
740
  return jsonResponse({ result: { received: true } }, 200)
735
741
  }
736
742
 
737
- /** The token verifies for this host and this form, and was not accepted before: a failure is 400, an unreachable provider 503. */
738
- async #challenge(form: string, verifier: ChallengeVerifier, token: string | null, request: Request): Promise<void> {
743
+ /**
744
+ * The token verifies for this host and this form, and was not accepted before unless it is the `repeatable` one: a
745
+ * failure is 400, an unreachable provider 503.
746
+ */
747
+ async #challenge(form: string, verifier: ChallengeVerifier, token: string | null, request: Request, repeatable: string | null): Promise<void> {
739
748
  const outcome = token === null ? 'failed' : await verifier.verify({ token, hostname: new URL(request.url).hostname, action: form })
740
749
  if (outcome === 'unavailable') {
741
750
  throw new ServerFault('challenge_unavailable', `the ${verifier.provider} challenge of '${form}' could not be verified`, 503)
@@ -744,7 +753,8 @@ export class ContractRuntime {
744
753
  throw new ServerFault('challenge_misconfigured', `the ${verifier.provider} challenge of '${form}' refused the application's secret`, 503)
745
754
  }
746
755
  const refused = token === null || outcome === 'failed'
747
- || !(await this.#framework().forms.recordChallenge(form, sha256Hex(token), CHALLENGE_LIMITS.maxAgeMs + CHALLENGE_LIMITS.maxSkewMs))
756
+ || (token !== repeatable
757
+ && !(await this.#framework().forms.recordChallenge(form, sha256Hex(token), CHALLENGE_LIMITS.maxAgeMs + CHALLENGE_LIMITS.maxSkewMs)))
748
758
  if (refused) {
749
759
  throw new HttpError(400, 'challenge_failed', 'The challenge did not verify; solve it again')
750
760
  }
@@ -158,8 +158,12 @@ interface RunnerContext {
158
158
  readonly membership: IamMembership | null
159
159
  readonly capabilities: CapabilityAuthority
160
160
  readonly containers: ReadonlyMap<string, readonly string[]>
161
+ readonly revealHandlerErrors: HandlerErrorSink | null
161
162
  }
162
163
 
164
+ /** Where `fabrika app dev` shows what a handler threw, on the developer's own machine; nothing else sets one. */
165
+ export type HandlerErrorSink = (where: string, error: unknown) => void
166
+
163
167
  export interface JobBindingInput {
164
168
  readonly artifact: ContractArtifact
165
169
  /** The handler tree `boot` binds; a job's handler is `handlers.jobs.<name>`. */
@@ -186,6 +190,8 @@ export interface JobBindingInput {
186
190
  readonly capabilities: CapabilityAuthority
187
191
  /** Each dimension's containers, nearest first: a link's chain. */
188
192
  readonly containers: ReadonlyMap<string, readonly string[]>
193
+ /** What a job's handler threw, for `fabrika app dev`; without it the error is only its fixed code. */
194
+ readonly revealHandlerErrors?: HandlerErrorSink
189
195
  }
190
196
 
191
197
  interface BoundFrameworkJob {
@@ -278,6 +284,7 @@ export function bindJobs(input: JobBindingInput, problems: string[]): BoundJobs
278
284
  membership: input.membership,
279
285
  capabilities: input.capabilities,
280
286
  containers: input.containers,
287
+ revealHandlerErrors: input.revealHandlerErrors ?? null,
281
288
  })
282
289
  const jobs: ContractJobs = Object.freeze({
283
290
  names,
@@ -598,7 +605,11 @@ async function attemptOnce(
598
605
  try {
599
606
  const ran = runInRequest(frame, async () => {
600
607
  await job.handler({ ctx })
601
- }).then(() => null, failureOf)
608
+ }).then(() => null, (error: unknown) => {
609
+ const failed = failureOf(error)
610
+ if (failed.code === 'handler_failed') context.revealHandlerErrors?.(`job '${job.name}' attempt ${attempt}`, error)
611
+ return failed
612
+ })
602
613
  const timeout = clock.wait(job.timeoutMs, timer.signal).then((): typeof TIMED_OUT => TIMED_OUT)
603
614
  const result = await Promise.race([ran, timeout])
604
615
  return result === TIMED_OUT ? failure('timeout') : result
@@ -1,9 +1,10 @@
1
- // The data hooks over the typed client: `useQuery`, `useInfiniteQuery`, `useMutation`, `useAction` and
1
+ // The data hooks over the typed client: `useQuery`, `useInfiniteQuery`, `useMutation`, `useAction`, `useDownload` and
2
2
  // `useInvalidate`. Each takes a client procedure (`api.deals.get`) as the typed name of the call; the answer comes back
3
3
  // through the procedure's own output schema, so its type is the contract's.
4
4
 
5
5
  import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react'
6
6
  import { z } from 'zod/v4'
7
+ import type { AttachmentProcedure, DownloadInput } from '../client/contract'
7
8
  import type { Schema } from '../contract/define'
8
9
  import { type FabrikaError, toFabrikaError } from './errors'
9
10
  import { parentOf, prefixOf, useFabrika } from './provider'
@@ -42,6 +43,14 @@ function useLoad(entry: QueryEntry | PagesEntry | null): void {
42
43
  }, [entry])
43
44
  }
44
45
 
46
+ export interface QueryOptions {
47
+ /**
48
+ * While a new input's answer loads, keep the previous input's answer as `success` data, with `fetching` true: an
49
+ * offset list (`{ items, total }`) whose `limit` or `offset` changes keeps its rows on screen.
50
+ */
51
+ readonly keepPrevious?: boolean
52
+ }
53
+
45
54
  /**
46
55
  * The answer of a query, cached by procedure and input: every view of one call shares one request, and a mutation
47
56
  * that invalidates it refetches it. Pass `skip` as the input to wait for a value the input needs.
@@ -50,8 +59,16 @@ export function useQuery<O extends Schema>(
50
59
  procedure: TypedProcedure<Record<never, never> | undefined, O>,
51
60
  input?: typeof skip,
52
61
  ): QueryState<z.output<O>>
53
- export function useQuery<I, O extends Schema>(procedure: TypedProcedure<I, O>, input: I | typeof skip): QueryState<z.output<O>>
54
- export function useQuery<I, O extends Schema>(procedure: TypedProcedure<I, O>, input?: I | typeof skip): QueryState<z.output<O>> {
62
+ export function useQuery<I, O extends Schema>(
63
+ procedure: TypedProcedure<I, O>,
64
+ input: I | typeof skip,
65
+ options?: QueryOptions,
66
+ ): QueryState<z.output<O>>
67
+ export function useQuery<I, O extends Schema>(
68
+ procedure: TypedProcedure<I, O>,
69
+ input?: I | typeof skip,
70
+ options: QueryOptions = {},
71
+ ): QueryState<z.output<O>> {
55
72
  const fabrika = useFabrika('useQuery')
56
73
  const target = useMemo(() => fabrika.target(procedure, 'useQuery'), [fabrika, procedure])
57
74
  if (target.node.procedure?.type === 'mutation') throw new Error(`useQuery: '${target.id}' is a mutation; use useMutation`)
@@ -63,7 +80,7 @@ export function useQuery<I, O extends Schema>(procedure: TypedProcedure<I, O>, i
63
80
  const snapshot = useSyncExternalStore((listener) => source.subscribe(listener), () => source.snapshot, () => source.snapshot)
64
81
  useLoad(entry)
65
82
  const refetch = useCallback(() => entry?.load() ?? Promise.resolve(), [entry])
66
- return useMemo((): QueryState<z.output<O>> => {
83
+ const state = useMemo((): QueryState<z.output<O>> => {
67
84
  if (snapshot === null) return IDLE
68
85
  const fetching = snapshot.fetching
69
86
  if (snapshot.status === 'loading') return { status: 'loading', data: undefined, error: null, fetching, refetch }
@@ -75,6 +92,15 @@ export function useQuery<I, O extends Schema>(procedure: TypedProcedure<I, O>, i
75
92
  ? { status: 'loading', data: undefined, error: null, fetching, refetch }
76
93
  : { status: 'success', data, error: null, fetching, refetch }
77
94
  }, [snapshot, procedure, refetch])
95
+ const previous = useRef<z.output<O> | undefined>(undefined)
96
+ useEffect(() => {
97
+ if (state.status === 'success') previous.current = state.data
98
+ }, [state])
99
+ const kept = previous.current
100
+ if (options.keepPrevious === true && state.status === 'loading' && kept !== undefined) {
101
+ return { status: 'success', data: kept, error: null, fetching: true, refetch }
102
+ }
103
+ return state
78
104
  }
79
105
 
80
106
  export interface InfiniteQueryOptions {
@@ -136,6 +162,22 @@ export function useInfiniteQuery<I extends { readonly after?: string } | undefin
136
162
  }, [snapshot, procedure, loadMore, refetch])
137
163
  }
138
164
 
165
+ /** What `useDownload`'s `mutate` takes: the attachment procedure's input, or none. */
166
+ export type DownloadArgument<I> = DownloadInput<I>[0]
167
+
168
+ /**
169
+ * The download of an attachment procedure (`api.reports.export`) through the provider's client of its mount, so a test
170
+ * that hands the provider a client over the kit downloads over the kit. `mutate(input)` saves the file in a browser and
171
+ * answers it as `data`; a refusal is the state's `error`, as for `useMutation`. It refetches nothing.
172
+ */
173
+ export function useDownload<I>(procedure: AttachmentProcedure<I>): MutationState<DownloadArgument<I>, File> {
174
+ const fabrika = useFabrika('useDownload')
175
+ const run = useMemo(() => fabrika.attachment(procedure, 'useDownload'), [fabrika, procedure])
176
+ return useRun(run, NOTHING_AFTER)
177
+ }
178
+
179
+ const NOTHING_AFTER = (): Promise<void> => Promise.resolve()
180
+
139
181
  export type MutationResult<T> = { readonly ok: true; readonly data: T } | { readonly ok: false; readonly error: FabrikaError }
140
182
 
141
183
  export interface MutationState<I, T> {
@@ -3,14 +3,17 @@
3
3
  export { type Caller, callerOf, type CallerScope, Can, type CanProps, useCaller } from './caller'
4
4
  export { type FabrikaError, type FabrikaErrorKind, fieldError, toFabrikaError } from './errors'
5
5
  export {
6
+ type DownloadArgument,
6
7
  type InfiniteQueryOptions,
7
8
  type InfiniteQueryState,
8
9
  type MutationOptions,
9
10
  type MutationResult,
10
11
  type MutationState,
12
+ type QueryOptions,
11
13
  type QueryState,
12
14
  skip,
13
15
  useAction,
16
+ useDownload,
14
17
  useInfiniteQuery,
15
18
  useInvalidate,
16
19
  useMutation,
@@ -5,7 +5,7 @@
5
5
  import { createContext, type ReactNode, useContext, useState } from 'react'
6
6
  import { z } from 'zod/v4'
7
7
  import { type PageCaller, parsePageCaller } from '../client/caller'
8
- import { type ClientNode, clientNodeOf } from '../client/contract'
8
+ import { attachmentAt, type ClientNode, clientNodeOf, downloadFile } from '../client/contract'
9
9
  import { RpcError, type RpcFetch } from '../client/transport'
10
10
  import { joinPath, PAGE_CALLER_FILE } from '../contract/defaults'
11
11
  import { type FabrikaError, toFabrikaError } from './errors'
@@ -59,6 +59,8 @@ export interface Fabrika {
59
59
  readonly caller: CallerSource
60
60
  /** The call of `procedure` on the provider's client of its mount. */
61
61
  target(procedure: unknown, hook: string): ResolvedProcedure
62
+ /** The download of attachment `procedure` on the provider's client of its mount. */
63
+ attachment(procedure: unknown, hook: string): (input: unknown) => Promise<File>
62
64
  }
63
65
 
64
66
  export interface ResolvedProcedure extends StoreTarget {
@@ -108,15 +110,27 @@ function fabrika(clients: readonly object[], spa: string, fetch: RpcFetch): Fabr
108
110
  if (byMount.has(node.mount)) throw new Error(`FabrikaProvider holds two clients of mount '${node.mount}'`)
109
111
  byMount.set(node.mount, client)
110
112
  }
113
+ const clientOf = (mount: string, hook: string): object => {
114
+ const client = byMount.get(mount)
115
+ if (client === undefined) throw new Error(`${hook}: the FabrikaProvider holds no client of mount '${mount}'`)
116
+ return client
117
+ }
111
118
  return {
112
119
  store: new QueryStore(toFabrikaError),
113
120
  caller: new CallerSource(joinPath(spa, PAGE_CALLER_FILE), fetch),
121
+ attachment(procedure, hook) {
122
+ const at = attachmentAt(procedure)
123
+ if (at === null) throw new Error(`${hook} takes an attachment procedure of a client createClient made`)
124
+ const own = procedureAt(clientOf(at.mount, hook), at.path)
125
+ if (attachmentAt(own) === null || typeof own !== 'object' || own === null) {
126
+ throw new Error(`${hook}: '${at.mount}.${at.path}' is not an attachment procedure of the provider's client`)
127
+ }
128
+ return (input) => downloadFile(own, input)
129
+ },
114
130
  target(procedure, hook) {
115
131
  const node = clientNodeOf(procedure)
116
132
  if (node?.procedure == null) throw new Error(`${hook} takes a procedure of a client createClient made`)
117
- const client = byMount.get(node.mount)
118
- if (client === undefined) throw new Error(`${hook}: the FabrikaProvider holds no client of mount '${node.mount}'`)
119
- const call = procedureAt(client, node.path)
133
+ const call = procedureAt(clientOf(node.mount, hook), node.path)
120
134
  if (typeof call !== 'function') throw new Error(`${hook}: '${idOf(node)}' is not a procedure of the provider's client`)
121
135
  return {
122
136
  id: idOf(node),
@@ -0,0 +1,144 @@
1
+ // @fabrika/app/react/testing — what a screen test of an SPA needs: happy-dom's document over Bun's own web platform,
2
+ // and typing that React reads. No test-runner dependency; happy-dom's registrator is an optional peer dependency.
3
+
4
+ import { GlobalRegistrator } from '@happy-dom/global-registrator'
5
+ import { act } from 'react'
6
+
7
+ /**
8
+ * The classes and functions of Bun's web platform a screen test keeps: the client, the test kit, a form's challenge and
9
+ * a download run on them, and happy-dom's own differ (a happy-dom `File` of a Bun `Blob` holds `[object Blob]`).
10
+ */
11
+ const BUN_GLOBALS = [
12
+ 'fetch',
13
+ 'Request',
14
+ 'Response',
15
+ 'Headers',
16
+ 'AbortController',
17
+ 'AbortSignal',
18
+ 'URL',
19
+ 'URLSearchParams',
20
+ 'Blob',
21
+ 'File',
22
+ 'FormData',
23
+ 'TextEncoder',
24
+ 'TextDecoder',
25
+ 'ReadableStream',
26
+ ] as const
27
+
28
+ export interface InstalledDom {
29
+ /** Closes the window and removes its globals; call it from the test file's `afterAll`. */
30
+ uninstall(): Promise<void>
31
+ }
32
+
33
+ export interface DomOptions {
34
+ /** The page's address; `http://app.test/` by default. A router needs a real origin. */
35
+ readonly url?: string
36
+ }
37
+
38
+ /**
39
+ * Installs happy-dom's window as the globals, keeping Bun's fetch, abort, URL, blob, file and form classes, with
40
+ * navigation off: a download's link click leaves the page where it is, so a later test of the file still has it. Call
41
+ * it in a module the test file imports first: React reads `window` when it loads.
42
+ */
43
+ export function installDom(options: DomOptions = {}): InstalledDom {
44
+ const kept = BUN_GLOBALS.map((name) => [name, Reflect.get(globalThis, name)] as const)
45
+ GlobalRegistrator.register({
46
+ url: options.url ?? 'http://app.test/',
47
+ settings: {
48
+ navigation: {
49
+ disableMainFrameNavigation: true,
50
+ disableChildFrameNavigation: true,
51
+ disableChildPageNavigation: true,
52
+ disableFallbackToSetURL: true,
53
+ },
54
+ },
55
+ })
56
+ for (const [name, value] of kept) Reflect.set(globalThis, name, value)
57
+ Reflect.set(globalThis, 'IS_REACT_ACT_ENVIRONMENT', true)
58
+ return {
59
+ async uninstall() {
60
+ // A request a screen started may still settle: let it, before its window goes.
61
+ await new Promise((resolve) => setTimeout(resolve, 50))
62
+ await GlobalRegistrator.unregister()
63
+ },
64
+ }
65
+ }
66
+
67
+ type Control = HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement
68
+
69
+ /**
70
+ * The text a label gives its control: what is hidden from assistive technology (a required marker) left out, and so
71
+ * is a control the label wraps.
72
+ */
73
+ function readText(element: Element): string {
74
+ const copy = element.cloneNode(true)
75
+ if (copy instanceof Element) {
76
+ for (const left of copy.querySelectorAll('[aria-hidden="true"], input, textarea, select')) left.remove()
77
+ }
78
+ return (copy.textContent ?? '').replace(/\s+/g, ' ').trim()
79
+ }
80
+
81
+ /** A control's accessible name: its `aria-label`, the elements `aria-labelledby` names, or its `<label>`. */
82
+ function nameOf(control: Control): string {
83
+ const aria = control.getAttribute('aria-label')
84
+ if (aria !== null) return aria.trim()
85
+ const labelledBy = control.getAttribute('aria-labelledby')
86
+ if (labelledBy !== null) {
87
+ return labelledBy.split(/\s+/).map((id) => {
88
+ const label = control.ownerDocument.getElementById(id)
89
+ return label === null ? '' : readText(label)
90
+ }).join(' ').trim()
91
+ }
92
+ const labels = control.labels === null ? [] : [...control.labels]
93
+ return labels.map(readText).join(' ').trim()
94
+ }
95
+
96
+ const isControl = (element: Element): element is Control =>
97
+ element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement || element instanceof HTMLSelectElement
98
+
99
+ /** The one input, textarea or select whose accessible name is `label`; throws when there is none or more than one. */
100
+ export function controlByLabel(label: string, within: ParentNode = document.body): Control {
101
+ const found = [...within.querySelectorAll('input, textarea, select')].filter(isControl).filter((control) => nameOf(control) === label)
102
+ const [control] = found
103
+ if (control === undefined || found.length > 1) {
104
+ throw new Error(`${found.length === 0 ? 'no' : found.length} control${found.length > 1 ? 's' : ''} labelled '${label}'`)
105
+ }
106
+ return control
107
+ }
108
+
109
+ /** The prototype whose `value` setter React's own value tracking does not see, so the next event reads as a change. */
110
+ function valueSetter(control: Control): ((value: string) => void) | undefined {
111
+ const prototype = control instanceof HTMLTextAreaElement
112
+ ? HTMLTextAreaElement.prototype
113
+ : control instanceof HTMLSelectElement
114
+ ? HTMLSelectElement.prototype
115
+ : HTMLInputElement.prototype
116
+ return Object.getOwnPropertyDescriptor(prototype, 'value')?.set
117
+ }
118
+
119
+ /**
120
+ * Types `value` into the control labelled `label`, as React reads a person typing: it focuses the control, sets the
121
+ * value past React's tracking, and sends `input`, `change` and `keyup`. A select takes the option's value.
122
+ */
123
+ export async function type(label: string, value: string, within?: ParentNode): Promise<void> {
124
+ const control = controlByLabel(label, within)
125
+ await act(async () => {
126
+ control.focus()
127
+ valueSetter(control)?.call(control, value)
128
+ control.dispatchEvent(new Event('input', { bubbles: true }))
129
+ control.dispatchEvent(new Event('change', { bubbles: true }))
130
+ control.dispatchEvent(new KeyboardEvent('keyup', { bubbles: true }))
131
+ })
132
+ }
133
+
134
+ /** Waits, letting React render, until the text of `within` holds `text`; throws after `timeoutMs` (2 s by default). */
135
+ export async function waitForText(text: string, options: { readonly within?: Element; readonly timeoutMs?: number } = {}): Promise<void> {
136
+ const within = options.within ?? document.body
137
+ const deadline = Date.now() + (options.timeoutMs ?? 2000)
138
+ while (!(within.textContent ?? '').includes(text)) {
139
+ if (Date.now() > deadline) throw new Error(`the page did not show '${text}' in time`)
140
+ await act(async () => {
141
+ await new Promise((resolve) => setTimeout(resolve, 10))
142
+ })
143
+ }
144
+ }
@@ -0,0 +1,10 @@
1
+ // A stand-in for a form challenge's provider, for a test kit and for `fabrika app dev`.
2
+
3
+ import type { ChallengeFetch } from '../implement/challenge'
4
+
5
+ /** The token Turnstile's always-passing test widget (site key `1x00000000000000000000AA`) hands out every time. */
6
+ export const TURNSTILE_TEST_TOKEN = 'XXXX.DUMMY.TOKEN.XXXX'
7
+
8
+ /** Turnstile's siteverify that passes every token: a fresh answer for the host and the form the check expects. */
9
+ export const passingChallenge: ChallengeFetch = (_request, check) =>
10
+ Promise.resolve(Response.json({ success: true, challenge_ts: new Date().toISOString(), hostname: check.hostname, action: check.action }))
@@ -3,6 +3,7 @@
3
3
  import type { AnyContract, DimensionOf } from '../contract/define'
4
4
  import type { ContractImplementation } from '../implement/implement'
5
5
  import type { PersonTestCaller, TestCaller } from './caller'
6
+ import { passingChallenge, TURNSTILE_TEST_TOKEN } from './challenge'
6
7
  import { bootTestKit, type TestKit, type TestKitOptions } from './kit'
7
8
  import type { SentInvitation, TestMembership } from './membership'
8
9
 
@@ -37,11 +38,30 @@ export interface DevSeedResult<C extends AnyContract> {
37
38
  readonly scopes?: { readonly [D in DimensionOf<C>]?: readonly DevScopeValue[] }
38
39
  }
39
40
 
41
+ /** The origin `fabrika app dev` captures every request to: an outside provider's stand-in, named by its path. */
42
+ export const DEV_OUTBOX_ORIGIN = 'https://outbox.invalid'
43
+
44
+ /** A request the application sent to `DEV_OUTBOX_ORIGIN` under `fabrika app dev`: its body, never its headers. */
45
+ export interface DevOutboxMessage {
46
+ readonly id: number
47
+ readonly method: string
48
+ /** The path it was sent to: `/mail`. */
49
+ readonly path: string
50
+ readonly contentType: string | null
51
+ readonly body: string
52
+ }
53
+
54
+ /** What `fabrika app dev` stands in for outside the application, for the seed to read. */
55
+ export interface DevOutside {
56
+ /** Every request sent to `DEV_OUTBOX_ORIGIN` so far, oldest first. */
57
+ outbox(): readonly DevOutboxMessage[]
58
+ }
59
+
40
60
  /**
41
61
  * The default export of `dev/seed.ts`: it receives the application's test kit, the API its tests use, writes through the
42
- * endpoints, and answers who and what the picker offers.
62
+ * endpoints, and answers who and what the picker offers. `outside` reads what the application sent to the dev outbox.
43
63
  */
44
- export type DevSeed<C extends AnyContract> = (dev: TestKit<C>) => Promise<DevSeedResult<C>> | DevSeedResult<C>
64
+ export type DevSeed<C extends AnyContract> = (dev: TestKit<C>, outside: DevOutside) => Promise<DevSeedResult<C>> | DevSeedResult<C>
45
65
 
46
66
  export interface DevKit<C extends AnyContract> {
47
67
  readonly kit: TestKit<C>
@@ -55,9 +75,15 @@ export interface DevKit<C extends AnyContract> {
55
75
 
56
76
  const isPerson = (caller: TestCaller): caller is PersonTestCaller => caller.kind === 'human' && caller.email !== null
57
77
 
58
- /** The test kit of `app` with its stand-in IAM, and what `fabrika app dev` shows of it. */
78
+ /**
79
+ * The test kit of `app` with its stand-in IAM, and what `fabrika app dev` shows of it. A form's challenge passes
80
+ * (`passingChallenge`) unless `options.challengeFetch` answers it, and admits the test widget's token
81
+ * (`TURNSTILE_TEST_TOKEN`) at every submission; any other token is single use, as deployed.
82
+ */
59
83
  export function devKit<C extends AnyContract>(app: ContractImplementation<C>, options: Omit<TestKitOptions, 'membership'> = {}): DevKit<C> {
60
- const { kit, standIn, callerOf } = bootTestKit(app, options)
84
+ const { kit, standIn, callerOf } = bootTestKit(app, { challengeFetch: passingChallenge, ...options }, {
85
+ repeatableChallengeToken: TURNSTILE_TEST_TOKEN,
86
+ })
61
87
  if (standIn === null) {
62
88
  throw new Error('a kit without its own membership transport stands in for IAM')
63
89
  }
@@ -227,16 +227,19 @@ const cleanScanner: FileScanner = {
227
227
  }
228
228
 
229
229
  /**
230
- * The variables `boot` requires of `app` that a generated case never uses: each form challenge's secret, since no case
231
- * submits a form. The kit's challenge stand-in refuses every token, so no verification leaves the process.
230
+ * The variables `boot` requires of `app` that a generated case never uses: each form challenge's secret and site key,
231
+ * since no case submits a form, and each SPA mount's `public` variables, since no case reads a page's config. The
232
+ * kit's challenge stand-in refuses every token, so no verification leaves the process.
232
233
  */
233
234
  function unusedVariables(app: ContractImplementation<AnyContract>): ApplicationEnvironment {
234
235
  const artifact = compileContract(app.contract, app.database?.tables ?? {})
235
- return Object.fromEntries(
236
- Object.values(artifact.mounts).flatMap((
237
- mount,
238
- ) => (mount.kind === 'form' && mount.challenge !== undefined ? [[mount.challenge.secret, 'unused']] : [])),
239
- )
236
+ const names = Object.values(artifact.mounts).flatMap((mount): readonly string[] => {
237
+ if (mount.kind === 'form' && mount.challenge !== undefined) {
238
+ return mount.challenge.siteKey === undefined ? [mount.challenge.secret] : [mount.challenge.secret, mount.challenge.siteKey]
239
+ }
240
+ return mount.kind === 'spa' ? (mount.public ?? []) : []
241
+ })
242
+ return Object.fromEntries(names.map((name) => [name, 'unused']))
240
243
  }
241
244
 
242
245
  /**
@@ -6,6 +6,7 @@ refuseInProduction('@fabrika/app/testing')
6
6
  export type { IamMembershipRpc } from '@fabrika/auth-core'
7
7
  export type { FileScanner } from '../files/scan'
8
8
  export type { ChallengeFetch } from '../implement/challenge'
9
+ export type { HandlerErrorSink } from '../jobs/runner'
9
10
  export type {
10
11
  LabelledTestCaller,
11
12
  LinkTestCaller,
@@ -17,7 +18,19 @@ export type {
17
18
  TestLink,
18
19
  TestScope,
19
20
  } from './caller'
20
- export { type DevKit, devKit, type DevLink, type DevPersona, type DevScopeValue, type DevSeed, type DevSeedResult } from './dev'
21
+ export { passingChallenge } from './challenge'
22
+ export {
23
+ DEV_OUTBOX_ORIGIN,
24
+ type DevKit,
25
+ devKit,
26
+ type DevLink,
27
+ type DevOutboxMessage,
28
+ type DevOutside,
29
+ type DevPersona,
30
+ type DevScopeValue,
31
+ type DevSeed,
32
+ type DevSeedResult,
33
+ } from './dev'
21
34
  export { type JobName, TEST_ENVIRONMENT, TEST_ISSUER, type TestConnection, type TestKit, testKit, type TestKitOptions } from './kit'
22
35
  export type { SentInvitation, TestMembership } from './membership'
23
36
  export { statusOf } from './status'