@jterrazz/test 16.1.1 → 17.0.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.
Files changed (304) hide show
  1. package/README.md +45 -171
  2. package/dist/browser/index.js +2 -2980
  3. package/dist/browser/services.js +2 -0
  4. package/dist/browser/stubs.js +2 -0
  5. package/dist/checker.d.ts +6 -1
  6. package/dist/checker.js +4534 -2628
  7. package/dist/config.d.ts +40 -0
  8. package/dist/config.js +309 -0
  9. package/dist/in-place.global-setup.d.ts +11 -0
  10. package/dist/in-place.global-setup.js +21 -0
  11. package/dist/index.d.ts +1653 -3004
  12. package/dist/index.js +5226 -3845
  13. package/dist/mobile-session.global-setup.d.ts +14 -0
  14. package/dist/mobile-session.global-setup.js +31 -0
  15. package/dist/oxlint.cjs +6069 -5634
  16. package/dist/oxlint.d.cts +43 -7212
  17. package/dist/oxlint.d.ts +43 -7212
  18. package/dist/oxlint.js +6127 -5693
  19. package/dist/route-coverage.global-setup.d.ts +42 -0
  20. package/dist/route-coverage.global-setup.js +2 -0
  21. package/dist/services.d.ts +3 -0
  22. package/dist/services.global-setup.d.ts +43 -0
  23. package/dist/services.global-setup.js +2 -0
  24. package/dist/services.js +597 -0
  25. package/dist/shared-appium.adapter.js +428 -0
  26. package/dist/shared-artifacts.js +32 -0
  27. package/dist/shared-chapter.js +7 -0
  28. package/dist/shared-container.port.d.ts +31 -0
  29. package/dist/shared-derived-projects.js +58 -0
  30. package/dist/shared-device.port.d.ts +189 -0
  31. package/dist/shared-exchange.js +447 -0
  32. package/dist/shared-in-place.js +172 -0
  33. package/dist/shared-index.browser.js +3191 -0
  34. package/dist/shared-index.services.d.ts +346 -0
  35. package/dist/shared-index.stubs.d.ts +287 -0
  36. package/dist/shared-json-shape.js +17 -0
  37. package/dist/shared-landmarks.js +71 -0
  38. package/dist/shared-ledger.js +68 -0
  39. package/dist/shared-literate-plugin.js +183 -0
  40. package/dist/shared-manifest.js +1321 -0
  41. package/dist/shared-pause.js +11 -0
  42. package/dist/shared-peer.js +88 -0
  43. package/dist/shared-playwright.adapter.js +330 -0
  44. package/dist/shared-process.adapter.js +74 -0
  45. package/dist/shared-refusal.js +35 -0
  46. package/dist/shared-resolve.js +24 -0
  47. package/dist/shared-route-coverage.global-setup.js +187 -0
  48. package/dist/shared-scenario-failure.js +198 -0
  49. package/dist/shared-serve.js +365 -0
  50. package/dist/shared-service-images.js +10 -0
  51. package/dist/shared-services.global-setup.js +179 -0
  52. package/dist/shared-session-ledger.js +301 -0
  53. package/dist/shared-setup-entry.js +12 -0
  54. package/dist/shared-source-scan.js +133 -0
  55. package/dist/shared-spec-document.js +365 -0
  56. package/dist/shared-specifications.js +26 -0
  57. package/dist/shared-stream-body.js +130 -0
  58. package/dist/shared-stub-server.js +190 -0
  59. package/dist/shared-temp-root.global-setup.js +80 -0
  60. package/dist/shared-test-baseline.js +26 -0
  61. package/dist/shared-test-section.js +58 -0
  62. package/dist/shared-testcontainers.adapter.js +203 -0
  63. package/dist/shared-tokens.js +55 -0
  64. package/dist/shared-yaml-document.js +119 -0
  65. package/dist/stubs.d.ts +2 -0
  66. package/dist/stubs.js +657 -0
  67. package/dist/temp-root.global-setup.d.ts +7 -0
  68. package/dist/temp-root.global-setup.js +2 -0
  69. package/dist/vitest.d.ts +97 -163
  70. package/dist/vitest.js +373 -525
  71. package/docs/README.md +82 -36
  72. package/docs/concepts/architecture.md +118 -0
  73. package/docs/concepts/conventions.md +333 -0
  74. package/docs/guides/api-specs.md +374 -0
  75. package/docs/guides/cli-specs.md +564 -0
  76. package/docs/guides/component-specs.md +252 -0
  77. package/docs/guides/develop.md +94 -0
  78. package/docs/guides/integration-specs.md +181 -0
  79. package/docs/guides/job-specs.md +180 -0
  80. package/docs/guides/lint-specs.md +174 -0
  81. package/docs/guides/mobile-specs.md +319 -0
  82. package/docs/guides/module-tests.md +149 -0
  83. package/docs/guides/release.md +43 -0
  84. package/docs/guides/spec-documents.md +189 -0
  85. package/docs/guides/website-specs.md +413 -0
  86. package/docs/reference/assertions.md +345 -0
  87. package/docs/reference/glossary.md +20 -0
  88. package/docs/reference/linting.md +483 -0
  89. package/docs/reference/services.md +236 -0
  90. package/docs/reference/stubs.md +498 -0
  91. package/docs/reference/time-and-doubles.md +137 -0
  92. package/docs/reference/tokens.md +258 -0
  93. package/docs/reference/visitor.md +221 -0
  94. package/docs/start/getting-started.md +187 -0
  95. package/docs/start/set-up-a-project.md +214 -0
  96. package/package.json +50 -30
  97. package/schema/spec.schema.json +43 -17
  98. package/dist/appium.adapter.js +0 -489
  99. package/dist/artifacts.js +0 -32
  100. package/dist/fixtures.js +0 -110
  101. package/dist/literate.js +0 -1747
  102. package/dist/match.js +0 -804
  103. package/dist/playwright.adapter.js +0 -438
  104. package/dist/server.js +0 -160
  105. package/dist/stream.js +0 -321
  106. package/dist/substring-warning.js +0 -282
  107. package/docs/01-architecture.md +0 -95
  108. package/docs/02-developing.md +0 -351
  109. package/docs/03-testing.md +0 -495
  110. package/docs/04-operating.md +0 -101
  111. package/docs/05-module-tests.md +0 -152
  112. package/docs/06-integration.md +0 -180
  113. package/docs/07-component.md +0 -246
  114. package/docs/08-website.md +0 -404
  115. package/docs/09-mobile.md +0 -270
  116. package/docs/10-api.md +0 -264
  117. package/docs/11-jobs.md +0 -157
  118. package/docs/12-cli.md +0 -677
  119. package/docs/13-elements.md +0 -248
  120. package/docs/14-assertions.md +0 -314
  121. package/docs/15-tokens.md +0 -253
  122. package/docs/16-contracts.md +0 -438
  123. package/docs/17-services.md +0 -188
  124. package/docs/18-conventions.md +0 -332
  125. package/docs/19-linting.md +0 -512
  126. package/docs/decisions/001-v9-conventions-on-three-channels.md +0 -50
  127. package/docs/decisions/002-the-statique-channel-holds-only-what-upstream-does-not.md +0 -49
  128. package/docs/decisions/003-a-rendered-component-is-a-facet-on-vitest-browser-mode.md +0 -84
  129. package/docs/decisions/004-a-module-against-real-services-or-a-golden-is-the-integration-facet.md +0 -81
  130. package/docs/decisions/005-the-doubles-ladder-is-closed.md +0 -86
  131. package/docs/decisions/006-the-catalogue-reaches-a-file-by-its-role.md +0 -245
  132. package/docs/decisions/007-compose-mode-leaves-the-package.md +0 -53
  133. package/docs/decisions/008-vitest-5-node-24-and-the-native-seams-as-optional-peers.md +0 -75
  134. package/docs/decisions/_template.md +0 -22
  135. package/docs/reference/classes/Matcher.md +0 -120
  136. package/docs/reference/classes/ProcessHandle.md +0 -368
  137. package/docs/reference/functions/defineContract.md +0 -39
  138. package/docs/reference/functions/defineContracts.md +0 -36
  139. package/docs/reference/functions/disabled.md +0 -28
  140. package/docs/reference/functions/enabled.md +0 -20
  141. package/docs/reference/functions/focused.md +0 -26
  142. package/docs/reference/functions/mockOf.md +0 -79
  143. package/docs/reference/functions/postgres.md +0 -31
  144. package/docs/reference/functions/process.md +0 -33
  145. package/docs/reference/functions/redis.md +0 -28
  146. package/docs/reference/functions/required.md +0 -41
  147. package/docs/reference/functions/selected.md +0 -26
  148. package/docs/reference/functions/sqlite.md +0 -33
  149. package/docs/reference/functions/testId.md +0 -20
  150. package/docs/reference/functions/text.md +0 -40
  151. package/docs/reference/functions/valued.md +0 -28
  152. package/docs/reference/functions/waitUntil.md +0 -46
  153. package/docs/reference/functions/within.md +0 -29
  154. package/docs/reference/index.md +0 -196
  155. package/docs/reference/interfaces/BaseResult.md +0 -87
  156. package/docs/reference/interfaces/CallResult.md +0 -160
  157. package/docs/reference/interfaces/CaptureScope.md +0 -82
  158. package/docs/reference/interfaces/CliResult.md +0 -253
  159. package/docs/reference/interfaces/ContainerAccessor.md +0 -166
  160. package/docs/reference/interfaces/DirectoryAccessor.md +0 -33
  161. package/docs/reference/interfaces/FetchResult.md +0 -180
  162. package/docs/reference/interfaces/FilesystemAccessor.md +0 -46
  163. package/docs/reference/interfaces/HttpResult.md +0 -126
  164. package/docs/reference/interfaces/JsonAccessor.md +0 -46
  165. package/docs/reference/interfaces/PageResult.md +0 -356
  166. package/docs/reference/interfaces/RenderResult.md +0 -72
  167. package/docs/reference/interfaces/RenderedText.md +0 -110
  168. package/docs/reference/interfaces/ResponseAccessor.md +0 -47
  169. package/docs/reference/interfaces/ScreenResult.md +0 -128
  170. package/docs/reference/interfaces/TableAccessor.md +0 -26
  171. package/docs/reference/interfaces/TextAccessor.md +0 -96
  172. package/docs/reference/type-aliases/ApiHandle.md +0 -68
  173. package/docs/reference/type-aliases/ApiSpecification.md +0 -213
  174. package/docs/reference/type-aliases/ApiSpecificationOptions.md +0 -68
  175. package/docs/reference/type-aliases/BrowserConsoleMessage.md +0 -30
  176. package/docs/reference/type-aliases/BrowserLinkElement.md +0 -50
  177. package/docs/reference/type-aliases/BrowserMetaElement.md +0 -40
  178. package/docs/reference/type-aliases/BrowserOpenOptions.md +0 -87
  179. package/docs/reference/type-aliases/BrowserPage.md +0 -132
  180. package/docs/reference/type-aliases/BrowserPort.md +0 -51
  181. package/docs/reference/type-aliases/CliEnv.md +0 -11
  182. package/docs/reference/type-aliases/CliHandle.md +0 -80
  183. package/docs/reference/type-aliases/CliInput.md +0 -36
  184. package/docs/reference/type-aliases/CliOutput.md +0 -46
  185. package/docs/reference/type-aliases/CliPort.md +0 -58
  186. package/docs/reference/type-aliases/CliSpecification.md +0 -144
  187. package/docs/reference/type-aliases/CliSpecificationOptions.md +0 -134
  188. package/docs/reference/type-aliases/ComponentChain.md +0 -156
  189. package/docs/reference/type-aliases/ComponentScenario.md +0 -20
  190. package/docs/reference/type-aliases/ComponentUi.md +0 -47
  191. package/docs/reference/type-aliases/ComponentVisitor.md +0 -10
  192. package/docs/reference/type-aliases/ContainerPort.md +0 -113
  193. package/docs/reference/type-aliases/Contract.md +0 -68
  194. package/docs/reference/type-aliases/ContractInput.md +0 -13
  195. package/docs/reference/type-aliases/ContractRequest.md +0 -95
  196. package/docs/reference/type-aliases/ContractResponder.md +0 -23
  197. package/docs/reference/type-aliases/ContractResponse.md +0 -78
  198. package/docs/reference/type-aliases/ContractResponseValue.md +0 -13
  199. package/docs/reference/type-aliases/Contracts.md +0 -48
  200. package/docs/reference/type-aliases/DatabaseKeys.md +0 -16
  201. package/docs/reference/type-aliases/DatabasePort.md +0 -72
  202. package/docs/reference/type-aliases/DeviceOpenOptions.md +0 -46
  203. package/docs/reference/type-aliases/DevicePort.md +0 -51
  204. package/docs/reference/type-aliases/DeviceScreen.md +0 -32
  205. package/docs/reference/type-aliases/DeviceTimeouts.md +0 -39
  206. package/docs/reference/type-aliases/DockerSpecConfig.md +0 -43
  207. package/docs/reference/type-aliases/DomMount.md +0 -21
  208. package/docs/reference/type-aliases/ElementKind.md +0 -27
  209. package/docs/reference/type-aliases/ElementMatch.md +0 -74
  210. package/docs/reference/type-aliases/ElementOptions.md +0 -29
  211. package/docs/reference/type-aliases/ElementRef.md +0 -128
  212. package/docs/reference/type-aliases/ExecOptions.md +0 -37
  213. package/docs/reference/type-aliases/FileAccessor.md +0 -54
  214. package/docs/reference/type-aliases/HonoApp.md +0 -31
  215. package/docs/reference/type-aliases/HttpContractFilter.md +0 -49
  216. package/docs/reference/type-aliases/HttpResponseInit.md +0 -46
  217. package/docs/reference/type-aliases/HttpStreamInit.md +0 -58
  218. package/docs/reference/type-aliases/IntegrationHandle.md +0 -46
  219. package/docs/reference/type-aliases/IntegrationSpecification.md +0 -111
  220. package/docs/reference/type-aliases/IntegrationSpecificationOptions.md +0 -42
  221. package/docs/reference/type-aliases/Intercept.md +0 -19
  222. package/docs/reference/type-aliases/InterceptOptions.md +0 -28
  223. package/docs/reference/type-aliases/InterceptScope.md +0 -11
  224. package/docs/reference/type-aliases/IsolationStrategy.md +0 -75
  225. package/docs/reference/type-aliases/JobHandle.md +0 -34
  226. package/docs/reference/type-aliases/JobsHandle.md +0 -57
  227. package/docs/reference/type-aliases/JobsResult.md +0 -16
  228. package/docs/reference/type-aliases/JobsSpecification.md +0 -94
  229. package/docs/reference/type-aliases/JobsSpecificationOptions.md +0 -56
  230. package/docs/reference/type-aliases/LandmarkKind.md +0 -20
  231. package/docs/reference/type-aliases/LiterateRunFlags.md +0 -27
  232. package/docs/reference/type-aliases/LiterateServeRegistration.md +0 -70
  233. package/docs/reference/type-aliases/MatchFixtureOptions.md +0 -27
  234. package/docs/reference/type-aliases/MatchableRequest.md +0 -61
  235. package/docs/reference/type-aliases/MatcherKind.md +0 -34
  236. package/docs/reference/type-aliases/MobileBackendOptions.md +0 -28
  237. package/docs/reference/type-aliases/MobileElementKind.md +0 -14
  238. package/docs/reference/type-aliases/MobileElementMatch.md +0 -60
  239. package/docs/reference/type-aliases/MobileElementRef.md +0 -20
  240. package/docs/reference/type-aliases/MobileHandle.md +0 -65
  241. package/docs/reference/type-aliases/MobileScenario.md +0 -20
  242. package/docs/reference/type-aliases/MobileSpecification.md +0 -51
  243. package/docs/reference/type-aliases/MobileSpecificationOptions.md +0 -126
  244. package/docs/reference/type-aliases/MobileVisitor.md +0 -81
  245. package/docs/reference/type-aliases/MockOfOptions.md +0 -26
  246. package/docs/reference/type-aliases/MockPort.md +0 -10
  247. package/docs/reference/type-aliases/PinnedClock.md +0 -10
  248. package/docs/reference/type-aliases/PostgresOptions.md +0 -32
  249. package/docs/reference/type-aliases/ProcessOptions.md +0 -112
  250. package/docs/reference/type-aliases/RedisOptions.md +0 -20
  251. package/docs/reference/type-aliases/RenderSubject.md +0 -12
  252. package/docs/reference/type-aliases/ScreenNode.md +0 -67
  253. package/docs/reference/type-aliases/ServerPort.md +0 -36
  254. package/docs/reference/type-aliases/ServerResponse.md +0 -46
  255. package/docs/reference/type-aliases/ServerSpec.md +0 -21
  256. package/docs/reference/type-aliases/ServiceHandle.md +0 -216
  257. package/docs/reference/type-aliases/ServiceRecord.md +0 -12
  258. package/docs/reference/type-aliases/SpecDocument.md +0 -84
  259. package/docs/reference/type-aliases/SpecEnvToken.md +0 -21
  260. package/docs/reference/type-aliases/SpecFileAssertion.md +0 -27
  261. package/docs/reference/type-aliases/SpecFixture.md +0 -30
  262. package/docs/reference/type-aliases/SpecKind.md +0 -8
  263. package/docs/reference/type-aliases/SpecRun.md +0 -108
  264. package/docs/reference/type-aliases/SpecServeEntry.md +0 -40
  265. package/docs/reference/type-aliases/SpecStream.md +0 -32
  266. package/docs/reference/type-aliases/SpecificationConfig.md +0 -290
  267. package/docs/reference/type-aliases/SqliteOptions.md +0 -36
  268. package/docs/reference/type-aliases/SseEvent.md +0 -58
  269. package/docs/reference/type-aliases/StreamBody.md +0 -61
  270. package/docs/reference/type-aliases/TextFilter.md +0 -22
  271. package/docs/reference/type-aliases/VisitScenario.md +0 -20
  272. package/docs/reference/type-aliases/Visitor.md +0 -215
  273. package/docs/reference/type-aliases/WaitUntilOptions.md +0 -46
  274. package/docs/reference/type-aliases/WebsiteBackendOptions.md +0 -37
  275. package/docs/reference/type-aliases/WebsiteHandle.md +0 -51
  276. package/docs/reference/type-aliases/WebsiteSpecification.md +0 -118
  277. package/docs/reference/type-aliases/WebsiteSpecificationOptions.md +0 -40
  278. package/docs/reference/variables/anthropic.md +0 -19
  279. package/docs/reference/variables/banner.md +0 -10
  280. package/docs/reference/variables/button.md +0 -10
  281. package/docs/reference/variables/clock.md +0 -16
  282. package/docs/reference/variables/complementary.md +0 -10
  283. package/docs/reference/variables/component.md +0 -10
  284. package/docs/reference/variables/content.md +0 -10
  285. package/docs/reference/variables/contentinfo.md +0 -10
  286. package/docs/reference/variables/dialog.md +0 -10
  287. package/docs/reference/variables/field.md +0 -10
  288. package/docs/reference/variables/form.md +0 -10
  289. package/docs/reference/variables/heading.md +0 -10
  290. package/docs/reference/variables/http.md +0 -40
  291. package/docs/reference/variables/intercept.md +0 -11
  292. package/docs/reference/variables/link.md +0 -10
  293. package/docs/reference/variables/listitem.md +0 -10
  294. package/docs/reference/variables/main.md +0 -10
  295. package/docs/reference/variables/match.md +0 -8
  296. package/docs/reference/variables/navigation.md +0 -10
  297. package/docs/reference/variables/openai.md +0 -21
  298. package/docs/reference/variables/option.md +0 -17
  299. package/docs/reference/variables/region.md +0 -10
  300. package/docs/reference/variables/row.md +0 -10
  301. package/docs/reference/variables/search.md +0 -10
  302. package/docs/reference/variables/specification.md +0 -8
  303. package/docs/reference/variables/status.md +0 -10
  304. package/docs/reference/variables/table.md +0 -10
@@ -0,0 +1,319 @@
1
+ # Mobile specs (`specification.mobile`)
2
+
3
+ `specification.mobile()` tests a native app on the iOS simulator (its screens, the accessibility tree a user's assistive tech sees, and its flows: tap, fill, see) through a real XCUITest session driven by appium. It resolves and boots the simulator itself, and starts the appium server from the caller project, unless it is pointed at an Appium server someone else runs.
4
+
5
+ Use it when the subject under test is an installed native app. For a browser-rendered page use [website](website-specs.md); for a JSON/HTTP API surface use [api](api-specs.md).
6
+
7
+ ## What it specifies
8
+
9
+ A mobile spec answers one question: **given this backend and this visitor, what does the native app draw on a simulator, and what does the screen read like to someone who cannot see it?** The subject is the installed app met through a deep link, driven by the accessibility tree the platform publishes. A React Native component rendered off a device is jest's for now, not this specification's ([Component specs](component-specs.md) states the boundary and what would move it).
10
+
11
+ ## The constructor
12
+
13
+ ```typescript
14
+ // specs/mobile/mobile.specification.ts
15
+ import { specification } from '@jterrazz/test';
16
+ import { afterAll } from 'vitest';
17
+
18
+ export const { cleanup, mobile } = await specification.mobile({
19
+ app: { bundleId: 'com.jterrazz.fakenews' },
20
+ device: { name: 'iPhone 17', os: '26.5' },
21
+ });
22
+
23
+ afterAll(cleanup);
24
+ ```
25
+
26
+ ### Options
27
+
28
+ | Option | Description |
29
+ | ---------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
30
+ | `app` | `{ bundleId }`: the installed app under test. Every `.open()` terminates and relaunches it |
31
+ | `appium` | `{ url }`: an Appium server already running, connected to instead of spawned; see [A declared Appium server](#a-declared-appium-server) |
32
+ | `device` | `{ name, os?, udid? }`: the simulator to run on, resolved through `xcrun simctl` and booted when shut down or, under `appium`, asked of the server. Left out under a [`mobile({ device })` project](#one-session-per-run), which owns it; refused anywhere else |
33
+ | `backend` | `{ port? }`: start a declared stub backend; the handle gains `backendUrl` (see [Declared backend](#declared-backend)) |
34
+ | `root` | **Project-root override** (rule `no-redundant-root`): where the `appium` binary is resolved from (`node_modules/.bin`). Auto-discovered from the calling file when absent |
35
+ | `services` | A named record started with the runner and stopped with it: a `process()` for the bundler the app loads from, a database a dev build reads |
36
+ | `timeouts` | `{ action?, launch? }` in ms: how long this runner waits. Each field falls back to the framework's default; see [Declared timeouts](#declared-timeouts) |
37
+
38
+ `device` itself takes:
39
+
40
+ | `device` field | Description |
41
+ | -------------- | --------------------------------------------------------------------------------------------- |
42
+ | `name` | Simulator name, matched exactly (`'iPhone 17'`) |
43
+ | `os` | OS narrowing when the name exists on several runtimes: `'26.5'` or `'iOS 26.5'`, case-relaxed |
44
+ | `udid` | Explicit UDID; skips name/os resolution entirely |
45
+
46
+ Resolution refuses rather than guesses: zero matches and several matches both fail with the full `simctl` device listing, so the fix never needs an Xcode round-trip. A shut-down simulator is booted (`simctl boot` + `bootstatus`); an already-booted one is reused as-is.
47
+
48
+ **`services` is where Metro goes.** A dev build loads its JavaScript from a bundler. It is a `process()` like any other external process the framework owns, started before the app is ever launched and stopped with the specification ([Services](../reference/services.md#process-the-one-shape-an-external-process-takes)):
49
+
50
+ ```typescript
51
+ import { specification } from '@jterrazz/test';
52
+ import { process } from '@jterrazz/test/services';
53
+
54
+ export const { cleanup, mobile, udid } = await specification.mobile({
55
+ app: { bundleId: 'com.example.app' },
56
+ device: { name: 'iPhone 17' },
57
+ services: {
58
+ metro: process({ command: 'expo start', ready: /Metro waiting on .*:(\d+)/u }),
59
+ },
60
+ });
61
+ ```
62
+
63
+ Without a declared `appium`, and unless the run already serves one ([One session per run](#one-session-per-run)), the server is spawned from the caller project's `node_modules/.bin/appium` on a free port and polled on `/status` until ready. On teardown the runner's driver session ends and the server is ended the way every `process()` is ([Services § `process()`](../reference/services.md#process-the-one-shape-an-external-process-takes)), its WebDriverAgent with it ([below](#the-simulator-and-what-the-run-leaves-on-it)).
64
+
65
+ The handle destructures to `{ mobile, cleanup, udid }` (rule `no-runner-alias`). `udid` is the resolved simulator, handy for shelling out to `simctl` in a debugging session; under a declared `appium` server it is the `device.udid` you named, or absent. With the `backend` option it additionally carries `backendUrl`.
66
+
67
+ ### The simulator, and what the run leaves on it
68
+
69
+ A run leaves the machine as it found it, but for the booted simulator: no window, and no process of its own.
70
+
71
+ **The simulator runs headless.** Every session asks the XCUITest driver for it (`appium:isHeadless: true`), so no Simulator or DeviceHub window opens: not for a run, not for the hundredth run of an agent's loop. The driver holds the simulator to the mode a session asks for: a window already showing it is closed. To watch a run, set `TEST_MOBILE_HEADED=1` on that run alone. It is an environment variable and not an option, so no committed config can make every run open a window.
72
+
73
+ **WebDriverAgent ends with the server that started it.** The driver runs WebDriverAgent's `xcodebuild` in a process group of its own and keeps it past the session and past the server's own exit, beside the runner app it installs on the simulator. So the teardown of a server the framework spawned also ends that `xcodebuild` (the one naming WebDriverAgent and the simulator's UDID) and the runner app inside that simulator, and so does the watchdog of a run killed before its teardown ([Services § `process()`](../reference/services.md#process-the-one-shape-an-external-process-takes)). A server you declare with `appium` is yours, and so is its WebDriverAgent. Each spawned server starts WebDriverAgent again, its build already cached: a project that declares its device pays that once per run ([One session per run](#one-session-per-run)), a file that spawns its own server pays it once per file.
74
+
75
+ The booted simulator itself is the machine's: it is reused by the next run, and `xcrun simctl shutdown <udid>` ends it.
76
+
77
+ ### A declared Appium server
78
+
79
+ `appium: { url }` points the runner at a server someone else runs: a device farm, a Mac across the network, the protocol double this package specifies its own mobile specification against. The runner connects to it instead of spawning the project's own, and it hands the server the device by capabilities (`appium:deviceName`, `appium:platformVersion` from `os`, `appium:udid` when declared) instead of resolving and booting it through `simctl`: the machine the server runs on owns its simulators. `cleanup` stops nothing it did not start, and under `mobile()` the run's one session outlives it ([One session per run](#one-session-per-run)).
80
+
81
+ ```typescript
82
+ export const { cleanup, mobile } = await specification.mobile({
83
+ app: { bundleId: 'com.jterrazz.fakenews' },
84
+ appium: { url: 'http://mac-mini.local:4723' },
85
+ device: { name: 'iPhone 17', os: '26.5' },
86
+ });
87
+ ```
88
+
89
+ ### One session per run
90
+
91
+ Under a server that outlives the file (a declared `appium` server, or the run's own), a `mobile()` project opens **one driver session for the whole run**, not one per spec file. The project's global setup hands every file an empty ledger under `.artifacts/vitest/mobile-sessions/`. The first `.open()` of the run creates the session and records it there, and every later file asking the same server for the same capabilities attaches to it instead of creating its own. The session asks the server to hold it through five idle minutes (`appium:newCommandTimeout: 300`), and a later file probes it (`GET /session/:id/timeouts`, answered without WebDriverAgent) before attaching: a session the server no longer holds (expired, or lost with a restarted server) is replaced by a new one, recorded and ended the same way. A probe that fails without saying the session is gone (a timeout, a 5xx) still replaces it, after a best-effort `DELETE /session/:id`: a session the probe could not confirm is ended, never left holding the device. `cleanup` leaves that session alone; the setup ends it once the last file has run. Global setups tear down in the reverse of their order, so a setup of the project's own that SERVES the declared Appium server (a protocol double, a server started for the run) is listed before `mobile()`'s: the run's session is then ended while its server still answers.
92
+
93
+ **The run's own server** is the project's to declare: `mobile({ device, root })` names the simulator and where the `appium` binary is resolved from (`<root>/node_modules/.bin/appium`, `root` read against the config's root, `.` when absent). The project's global setup resolves and boots that simulator, spawns the server once for the run, and hands both to the workers. A runner with no `appium` of its own drives that server on the project's device and spawns nothing. The device is the project's to own, so a runner leaves `device` out and drives the project's; one that states it restates the project's, and the booted simulator's UDID stands in when it names none. A `device` that names another simulator, another runtime (`'iOS 26.5'` and `'26.5'` are one, as they are to resolution) or another UDID is refused, naming both devices: the setup booted the project's device alone. The setup stops the server, and its WebDriverAgent, once it has ended the run's sessions.
94
+
95
+ ```typescript
96
+ // a project of jterrazz.config.ts: one simulator, one Appium server, one session for the run
97
+ const mobile = {
98
+ device: { name: 'iPhone 17', os: '26.5' },
99
+ reason: 'the app and its Appium install live in the workspace member',
100
+ root: 'apps/mobile',
101
+ };
102
+ ```
103
+
104
+ The session is keyed on the server's URL and the capabilities asked for, so a second specification naming another app (or, under a declared `appium` server, another device) gets a session of its own, recorded and ended the same way. Files still run one at a time (`fileParallelism: false` is `mobile()`'s default): the app is terminated and relaunched by every `.open()`, and two files driving it at once would fight over it.
105
+
106
+ Two runs keep a session per runner instead, which `cleanup` ends:
107
+
108
+ - **A vitest project written by a tool of your own**, with no `mobile()`: nothing provides the ledger.
109
+ - **A server the runner spawns itself**, under a `mobile()` project that declares no `device`. It stops with the runner that started it, so its session cannot outlive the file.
110
+
111
+ ### Declared timeouts
112
+
113
+ Every verb auto-waits: there is no sleep and no conditional helper. `timeouts` states how long, for the two waits a project legitimately needs to move:
114
+
115
+ | `timeouts` field | The wait it budgets | Default |
116
+ | ---------------- | ------------------------------------------------------------------------------------------ | --------- |
117
+ | `action` | one visible match, on every verb, the whole `see`/`tap`/`fill` budget, polled every 500 ms | `30_000` |
118
+ | `launch` | WebDriverAgent to build and launch, on a run's first session | `240_000` |
119
+
120
+ The defaults suit a **release** bundle: 30 s absorbs a cold app boot, the same figure playwright chose for its actionability timeout. A project driving a **dev** build pays its bundler's cold boot on top (a Metro cold start to first content measures 31 to 32 s, over the default) and states that wait once, on the runner, rather than sleeping inside its scenarios:
121
+
122
+ ```typescript
123
+ // specs/mobile/mobile.specification.ts
124
+ export const { cleanup, mobile } = await specification.mobile({
125
+ app: { bundleId: 'com.jterrazz.fakenews' },
126
+ device: { name: 'iPhone 17', os: '26.5' },
127
+ // Dev-mode Metro cold boot measured at 31-32s, over the 30s default.
128
+ // 45s covers the cold path with margin without hiding a real hang.
129
+ timeouts: { action: 45_000 },
130
+ });
131
+
132
+ afterAll(cleanup);
133
+ ```
134
+
135
+ Raise it to the slowest **honest** path and no further: the timeout is what turns a hang into a legible refusal naming the element and what was on screen instead.
136
+
137
+ ## The chain
138
+
139
+ ### One terminal action: `.open(deepLink?, scenario?)`
140
+
141
+ `.open()` is terminal and deterministic: it **terminates and relaunches the app** (fresh state every spec), applies the deep link when given, runs the scenario, then captures the final screen: the projected accessibility tree plus the visible texts:
142
+
143
+ ```typescript
144
+ test('shows the events feed behind its deep link', async () => {
145
+ // Given - the events screen
146
+ const result = await mobile.open('news://events');
147
+
148
+ // Then - one golden covers the whole screen
149
+ expect(result.screen).toMatch(file('./__expected__/events.screen.json'));
150
+ });
151
+ ```
152
+
153
+ Without a deep link, the app opens on its launch screen. The driver session is opened lazily on the first `.open()` and reused; the expensive part is WebDriverAgent startup, not the app relaunch. How long it lives is [one session per run](#one-session-per-run)'s.
154
+
155
+ ### Open scenarios: the When
156
+
157
+ A scenario is the interaction that happens **before** the capture, the open's When. The capture always reflects the **final** screen state, after the scenario ran:
158
+
159
+ ```typescript
160
+ test('bookmarks an event from its detail screen', async () => {
161
+ // Given - a visitor on the events feed
162
+ const result = await mobile.open('news://events', async (visitor) => {
163
+ // When - they open the concert and bookmark it
164
+ await visitor.tap(button('Jazz night at the harbour'));
165
+ await visitor.see(content('12 rapports publiés'));
166
+ await visitor.tap(button('Bookmark'));
167
+ });
168
+
169
+ // Then - the capture reflects the screen after the interaction
170
+ expect(result.content.text).toContain('Bookmarked');
171
+ });
172
+ ```
173
+
174
+ **No `expect()` inside a scenario (rule `no-expect-in-scenario`).** A scenario is pure interaction; assertions live in the Then, on the returned result.
175
+
176
+ The visitor's vocabulary is [The visitor](../reference/visitor.md)'s, the same one the website and component specifications name things with; there is no CSS, no XPath and no raw predicate surface. What a device does with it is this page's, and it is three things.
177
+
178
+ **The verbs are three.** `tap(element)` is the device's word for a click, `fill(element, value)` fills a text field, and `see(element)` is the only synchronization primitive. There is no `gone()` here ([The visitor § Verbs](../reference/visitor.md#verbs) says why). Every verb polls until at least one visible match exists (default 30 s, sized for a cold app boot), then enforces the verb's cardinality. Actionability includes **scroll-into-view**: when a target matches nothing visible but an off-screen element exists, the adapter scrolls it into view once (directly by element id) and keeps polling. Elements buried in very heavy screens (a full-page WebView) may still be out of reach, so prefer a deep link then.
179
+
180
+ **Four targets reach a screen:** `button(name)` (accessible label, `XCUIElementTypeButton`), `field(label)` (accessible name or value, text and secure-text fields), `content(text)` (any element whose label or value IS the text, the whole-name default of [The visitor](../reference/visitor.md#a-name-designates-the-accessible-name-whole), compiled to `==`; `{ exact: false }` compiles to `CONTAINS`) and `testId(id)` (the accessibility identifier, rule `test-id-states-what-is-missing`'s escape hatch, with the line saying what the element lacks). The link, heading, dialog, table and option roles, the landmarks and the state modifiers are not part of a device's tree: passing one to a mobile verb refuses at runtime with a message naming the boundary.
181
+
182
+ **`target-matches-one-element` is relaxed for `see()` alone.** `tap` and `fill` refuse an ambiguous target and print the candidates with their accessibility identifiers; `see()` does not; [The visitor § `see()` and `gone()`](../reference/visitor.md#see-and-gone-are-the-synchronization) says why, once.
183
+
184
+ A mobile scope is any target, most often a `testId()` on the containing view, because iOS has no landmarks:
185
+
186
+ ```typescript
187
+ await visitor.tap(within(testId('event-list'), button('Bookmark')));
188
+ ```
189
+
190
+ `see()` resolving through render work is the point: a deep link into a Metro cold bundle, a network round-trip, an animation: the poll absorbs them all without a single sleep.
191
+
192
+ ## The result
193
+
194
+ | Member | Type | Description |
195
+ | ---------------- | -------------- | --------------------------------------------------------------------------- |
196
+ | `result.screen` | `JsonAccessor` | The projected accessibility tree, the one-golden-per-screen surface |
197
+ | `result.content` | `TextAccessor` | The visible texts, one per line in reading order, the scalpel for one probe |
198
+
199
+ ### The `screen` golden: one per screen
200
+
201
+ `result.screen` is the **stable, assertion-friendly projection** of the XCUITest page source. The raw source is deep and noisy (React Native wraps every view in layers of unlabeled `XCUIElementTypeOther` nodes), so the projection collapses it: wrapper nodes with no label, no value and no identifier are dropped (their children hoisted), an element repeated as its own child appears once, type names lose the `XCUIElementType` prefix, and a value or identifier merely echoing the label is omitted:
202
+
203
+ ```json
204
+ {
205
+ "type": "Application",
206
+ "label": "SigNews",
207
+ "children": [
208
+ { "type": "StaticText", "label": "Signals" },
209
+ { "type": "Button", "label": "Jazz night at the harbour" },
210
+ { "type": "TextField", "label": "Email", "value": "a@b.test", "identifier": "email-field" }
211
+ ]
212
+ }
213
+ ```
214
+
215
+ It is the one golden a screen needs:
216
+
217
+ ```typescript
218
+ test('renders the events feed', async () => {
219
+ // Given - the events screen
220
+ const result = await mobile.open('news://events');
221
+
222
+ // Then - the whole projected tree matches one golden
223
+ expect(result.screen).toMatch(file('./__expected__/events.screen.json'));
224
+ });
225
+ ```
226
+
227
+ The tree describes the **whole mounted hierarchy**, including rows below the fold; `result.content` carries only what is visible. Volatile parts (dates, counters) are covered by the usual `{{token}}` grammar ([Tokens](../reference/tokens.md)); generate with `TEST_UPDATE=1`.
228
+
229
+ ## Unique here
230
+
231
+ ### Declared backend
232
+
233
+ A native app usually talks to an API. The `backend` option starts a small **stub backend** (plain `node:http`, no extra dependency) with the runner; what it serves is declared per chain, as [stubs](../reference/stubs.md): the feature's `__setup__/stubs/` facade, exactly the form `api`/`job` use:
234
+
235
+ ```typescript
236
+ // specs/mobile/mobile.specification.ts
237
+ export const { backendUrl, cleanup, mobile } = await specification.mobile({
238
+ app: { bundleId: 'com.jterrazz.fakenews' },
239
+ backend: { port: 4820 },
240
+ device: { name: 'iPhone 17', os: '26.5' },
241
+ });
242
+
243
+ afterAll(cleanup);
244
+ ```
245
+
246
+ ```typescript
247
+ // specs/mobile/events/feed.spec.ts
248
+ import newsroom from './__setup__/stubs/newsroom.stubs.js';
249
+
250
+ describe('feed', () => {
251
+ test('renders the events feed from the declared backend', async () => {
252
+ // Given - the backend under stub for this chain
253
+ const result = await mobile.intercept(newsroom).open('news://events');
254
+
255
+ // Then - the screen rendered what the stub declared
256
+ expect(result.screen).toMatch(file('./__expected__/events.screen.json'));
257
+ });
258
+ });
259
+ ```
260
+
261
+ | `backend` field | Description |
262
+ | --------------- | -------------------------------------------------------------------------------- |
263
+ | `port` | Fixed port; pins a stable stub URL across runs. Default: a free OS-assigned port |
264
+
265
+ **The ownership boundary.** The framework owns the simulator and the appium server it started; it does NOT own the JS bundler: Metro belongs to the caller's repo, exactly like `next build` belongs to a website's. So nothing is injected anywhere; the handle exposes `backendUrl` and **the caller wires it into its own bundler env** (e.g. `EXPO_PUBLIC_API_URL=<backendUrl> npx expo start`). This is why `port` exists: Metro inlines `EXPO_PUBLIC_*` values at bundle-serve time, and a stable port lets a warm Metro survive between runs instead of re-bundling against a fresh URL.
266
+
267
+ The stub backend behaves exactly as on the website specification ([Website specs](website-specs.md#declared-backend)): it **resets between chains** (one chain = one terminal action); selection is the shared queue (first non-exhausted match wins, no `times` = unlimited); a request matching no declared stub is answered **501 and recorded**, and the `.open()` then **throws** an error enumerating every unmatched request (method, path, count). Screenshots and other failure evidence are captured first, as always. A chain with zero stubs leaves the stub unguarded.
268
+
269
+ ### Evidence on failure
270
+
271
+ When a scenario throws (an element never appears, a `see()` times out), the error carries a screenshot of the state the scenario died in, referenced by its path in the error message. It is written under `.artifacts/vitest/evidence/`, in the folder of the run that took it, so it outlives the run and its temp root, until the next run starts, which sweeps every run's evidence but a living one's. A timeout additionally includes a compact excerpt of the labels currently on screen, so the failure reads without relaunching anything. The original error is never masked.
272
+
273
+ ### Requirements: what the machine must have
274
+
275
+ Provisioning the simulator environment is not this package's job. The framework needs, in this order:
276
+
277
+ 1. **Xcode, its first launch done, + an iOS simulator**: `xcodebuild -checkFirstLaunchStatus` must exit 0 (else `sudo xcodebuild -runFirstLaunch`; until then simctl never answers and the run is refused), and `xcrun simctl list devices` must show the device named in `device`.
278
+ 2. **The app installed on that simulator**: `.open()` launches by bundle id; it does not build or install. Install with your build tooling (`xcrun simctl install <udid> <path>.app`, Expo/EAS, xcodebuild).
279
+ 3. **appium + webdriverio + the XCUITest driver** in the caller project; both are optional peer dependencies:
280
+
281
+ ```bash
282
+ npm install -D appium webdriverio && npx appium driver install xcuitest
283
+ ```
284
+
285
+ Calling `specification.mobile()` (or `.open()`) without them throws exactly that guidance; there is nothing else to search for.
286
+
287
+ ### Folder layout
288
+
289
+ ```text
290
+ specs/mobile/
291
+ ├── mobile.specification.ts # runner at the specification ROOT (rule `specs-at-declared-depth`)
292
+ └── <domain>/
293
+ ├── <aspect>.spec.ts
294
+ ├── __expected__/ # ALL expected fixtures, FLAT (*.screen.json, ...)
295
+ └── __setup__/stubs/ # the stubs declaring what the backend serves, with the `backend` option
296
+ ├── newsroom.stubs.ts
297
+ └── http/...
298
+ ```
299
+
300
+ No `__seeds__/` or `__requests__/`: a mobile chain has no `.seed()` and no request-file format; `.open()` calls are inline, and the golden is always `__expected__/<name>`.
301
+
302
+ ## Pitfalls
303
+
304
+ - **Using `expect()` inside a scenario callback.** Forbidden (rule `no-expect-in-scenario`): the scenario is the When; assertions belong on the result the `.open()` promise resolves to.
305
+ - **The first session builds WebDriverAgent (~40 s, once per simulator).** A later server starts it again without building it, and later sessions on the same server are seconds. Budget the first run; do not "fix" it with retries.
306
+ - **Ordering spec files by name.** Vitest schedules the files of a project in no name order (signews-mobile's `z-language.spec.ts` ran second, not last), so a `z-` prefix orders nothing. A file stands on what it opens and seeds itself, never on a file that ran before it; a journey that proves state across a relaunch is one test, its relaunch the second `.open()` of its chain.
307
+ - **Passing a landmark (`main()`, `navigation()`) to a mobile verb.** They are website concepts: iOS has no ARIA regions; the runtime refusal points at `within(testId('...'), ...)` as the scoping tool.
308
+ - **Reaching for `testId()` first.** It is the last rung, and `test-id-states-what-is-missing` refuses it without the line naming what the element lacks; see [The visitor § `testId()`](../reference/visitor.md#testid-the-one-escape-hatch).
309
+ - **Expecting `.open()` to preserve app state between specs.** It never does: every open terminates and relaunches the app; a flow that spans screens belongs in ONE scenario.
310
+ - **Asserting on off-screen content with `result.content`.** `content` carries only _visible_ texts; the mounted-but-offscreen rows live in `result.screen`. Use the golden for the whole list, `content` for what the user currently sees.
311
+ - **Calling `specification.mobile()` without appium installed.** The error names the exact fix (`npm install -D appium webdriverio && npx appium driver install xcuitest`); there is no silent fallback.
312
+ - **Pointing `device.name` at an ambiguous simulator.** When the same name exists on several OS runtimes the constructor refuses with the listing. Narrow with `os:` (or pin `udid:`), do not delete simulators to make it pass.
313
+ - **Calling `.intercept()` without the `backend` option.** It throws immediately, naming the fix: add `backend: { ... }` to the `specification.mobile()` options; a chain's `.http` exchanges need a stub to serve them.
314
+ - **Sleeping around a slow boot instead of declaring it.** A dev bundle that boots past the 30 s default makes every verb refuse; the answer is `timeouts: { action: ... }` on the runner, once. An arbitrary wait inside a scenario is untyped, per-spec, and hides the next regression.
315
+ - **Expecting the framework to point the app at the stub.** It never touches the bundler; that is the caller's territory. Wire `backendUrl` into your own bundler env (`EXPO_PUBLIC_API_URL`), and pin `port` so a warm Metro keeps its inlined URL.
316
+
317
+ ## Related
318
+
319
+ [Set up a project](../start/set-up-a-project.md) · [Assertions](../reference/assertions.md) · [Tokens](../reference/tokens.md) · [Website specs](website-specs.md)
@@ -0,0 +1,149 @@
1
+ # Module tests (`__tests__/*.test.ts`, in the module's folder)
2
+
3
+ A module test is plain vitest: one source file, called through its own exports, with nothing started. It needs no constructor, no runner and no folder under `specs/`: it sits in the `__tests__/` of the folder of the module it covers, as `__tests__/<file>.test.ts`.
4
+
5
+ It is also the exception. A behaviour a user relies on is tested end to end, through the surface the user meets; a module test is kept for a pure algorithm whose edge cases are cheaper at its own level, or for a failure no end-to-end path can provoke ([Conventions § Which test earns its place](../concepts/conventions.md#which-test-earns-its-place)).
6
+
7
+ ## What it specifies
8
+
9
+ A module test answers one question: **given these inputs and these ports, what does this module return, and what does it ask of its collaborators?** Its subject is a unit of code, so it sits with that code, in the conventional folder of its folder (`test-in-its-tests-folder`):
10
+
11
+ ```text
12
+ src/domain/posts/
13
+ ├── ranking.ts
14
+ └── __tests__/
15
+ ├── ranking.test.ts # the module's spec; the `.test.ts` suffix IS the kind
16
+ └── ranking.fixtures.ts # the payloads it feeds itself, if any
17
+ ```
18
+
19
+ A test in a `__tests__/` names a module of the folder above it, and imports it one folder up (`../ranking.js`); a module test anywhere else is refused, the fix naming the `__tests__/` it moves to. `__tests__/` is the one special folder a code root holds. The ground its tests stand on sits inside it (`__tests__/__expected__/`, `__tests__/__fixtures__/`) and is judged as a specification's: a short golden one test alone reads belongs in that test (`ground-shared-or-inline`), and an entry no test of the folder names is dead (`no-unused-ground`). The suffix decides which project collects the file and therefore which rules judge it. `unit()` takes `**/*.test.ts` outside `specs/` and excludes `**/*.test.tsx`; a `.test.ts` UNDER `specs/<specification>/` is rule `file-suffix-matches-place`'s error, with the fix "rename it `.spec.ts`", and a rendered thing in a `.test.ts` is rule `no-dom-in-module-test`'s, with the fix "rename it `.test.tsx`" ([Component specs](component-specs.md)).
20
+
21
+ Nothing in this kind is slow, so the `unit` project is the one a developer runs on every save.
22
+
23
+ ## The constructor: there is none
24
+
25
+ Every other kind starts something (a server, a database, a binary, a browser, a simulator) and keeps it in a `*.specification.ts`. A module starts nothing, so there is no specification file and no project to configure beyond `unit()`.
26
+
27
+ The moment a test needs a real service, a real binary or a real screen, it is no longer this kind; [the fork](../start/getting-started.md#which-kind-of-test) says which one it became.
28
+
29
+ ## The chain: there is none either
30
+
31
+ A module test is `test`, `expect` and vitest's own matchers. The framework adds four primitives (`mockOf()`, `intercept()`, `clock` and `text()`), and the two that touch shared state, `intercept()` and `clock`, are scopes that give back what they took.
32
+
33
+ ```typescript
34
+ import { clock } from '@jterrazz/test';
35
+ import { describe, expect, test } from 'vitest';
36
+
37
+ import { twoPosts } from './ranking.fixtures.js';
38
+ import { rank } from './ranking.js';
39
+
40
+ describe('rank', () => {
41
+ test('ranks a fresher post above an older one of equal score', () => {
42
+ // Given - two posts of equal score, one written this morning
43
+ using _ = clock.at('2026-03-04T09:30:00Z');
44
+
45
+ // Then - the fresher one leads
46
+ expect(rank(twoPosts).map((post) => post.id)).toStrictEqual(['fresh', 'stale']);
47
+ });
48
+ });
49
+ ```
50
+
51
+ The `describe` is titled `rank` because `rank` is what `ranking.ts` exports: a module test names its subject, the export it is about, where a spec names its aspect. When a module exports several values, which one answers is [`one-describe-per-file`'s row](../reference/linting.md#one-describe-per-file), and `typescript fix` writes the title it reads.
52
+
53
+ ## What it may and may not do
54
+
55
+ The table is the rule set, and every row names the rule that holds it, where one does ([Linting § The rule catalogue](../reference/linting.md#the-rule-catalogue)).
56
+
57
+ | May | May not | Rule |
58
+ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- |
59
+ | import its module and pure helpers; a `<file>.fixtures.ts` beside it | import a data asset (`.json` / `.txt` / `.sql` / `.yaml`); keep a `__mocks__/` directory | `no-module-mock` |
60
+ | `mockOf<Port>()` / `mockOf<Port>({ deep: false })` for an injected interface; `vi.fn<Fn>()`; `vi.spyOn` on an object passed in | `vi.mock` / `vi.doMock`, except a specifier on the consumer's `modules` allow-list, written `{ module, reason }` | `no-module-mock` |
61
+ | `await using _ = await intercept(http.get(url), http.json(...))` | `vi.stubGlobal('fetch')`, `msw/node`, `nock` | `no-vitest-time-or-global-stub`, `no-second-test-runtime` |
62
+ | `using _ = clock.at('2026-03-04T09:30:00Z')`; `using _ = clock.run()`; `await clock.advance(ms)` | `Date.now()`, zero-argument `new Date()`, `Math.random()`, `randomUUID()` reaching an oracle; `vi.useFakeTimers` / `vi.setSystemTime` | `no-sampled-value-in-expectation`, `no-sampled-value-in-test`, `no-vitest-time-or-global-stub` |
63
+ | `vi.stubEnv('X', 'y')` for a module whose stub IS an env read; it restores itself | `process.env.X = ...`, a raw assignment nothing gives back | `no-raw-env-write` |
64
+ | one top-level `describe` titled with the subject (the module's export, `describe('rank', ...)`), every test inside it; a `test.each` table (`given-and-then-markers-present` judges the table once, not once per row) | a nested `describe`; a second `describe`, or a test beside it; a `beforeEach` / `beforeAll` that builds the Given | `one-describe-per-file`, `one-describe-level`, `given-in-the-test` |
65
+ | `expect(text(value).text).toContain(...)`; `toStrictEqual` against a literal | a golden (`toMatch(file(...))`): a module with a golden is an integration spec ([Integration specs](integration-specs.md)), a convention no rule holds; any `toMatchSnapshot` matcher | `no-vitest-snapshot` |
66
+ | none | `document` / `window` / `navigator`; `@vitest-environment happy-dom`; `react-dom/server` | `no-dom-in-module-test`, `no-simulated-dom`, `no-second-test-runtime` |
67
+
68
+ A module that fetches a RELATIVE url is browser code and cannot run under node as it stands: either state where it resolves (`intercept(stubs, { origin })`, below) or specify it where it renders, in a component spec.
69
+
70
+ ### Doubles
71
+
72
+ `mockOf<Port>()` doubles an injected interface and `vi.fn<Fn>()` a function; a module test is the only kind that builds its own double. Both, and the ladder that says when a double is the right tool, are [Time and doubles](../reference/time-and-doubles.md#doubles).
73
+
74
+ ### `intercept()`: the network, declared, with no chain
75
+
76
+ A module that reaches the network states what it expects to find there, in the same stub vocabulary every specification uses: the same double, with no chain to hang it on.
77
+
78
+ ```typescript
79
+ test('reads the feed the gateway publishes', async () => {
80
+ // Given - the feed answers with two posts
81
+ await using _ = await intercept(
82
+ http.get('https://console.test/api/posts'),
83
+ http.json({ posts: [{ id: 'fresh' }, { id: 'stale' }] }),
84
+ );
85
+
86
+ // Then - the client hands back what the feed carried
87
+ await expect(fetchPosts()).resolves.toHaveLength(2);
88
+ });
89
+ ```
90
+
91
+ The two awaits, `{ origin }` for a subject that calls a RELATIVE url, the refusal of an empty stub list, and everything about the stubs themselves (selection, `times`, `required`, the facades, the streams) are [Stubs § `intercept()`](../reference/stubs.md#intercept-the-same-double-with-no-chain)'s.
92
+
93
+ ### Time
94
+
95
+ `using _ = clock.at(iso)` pins `Date` for the scope; `clock.run()` and `await clock.advance(ms)` take the timers too, for a subject whose behaviour is elapsed time ([Time and doubles § The clock](../reference/time-and-doubles.md#the-clock)).
96
+
97
+ ### Fixtures
98
+
99
+ A payload a module test feeds itself lives in a `<file>.fixtures.ts` beside it in the `__tests__/`: TypeScript, type-checked with the test, reviewed in the same diff. What the module tests of several folders share (a builder of scratch trees, a common payload) is no module's and sits in the `__tests__/` of the lowest folder they share, named for what it prepares, as a `__setup__/` does under `specs/`; a `*.fixtures.ts` outside every `__tests__/` is `test-in-its-tests-folder`'s error. A `.json` a module test imports is `no-module-mock`'s error: an asset the compiler never reads is a second source of truth, and the kind that legitimately owns assets is the one that has a folder for them.
100
+
101
+ ### Tables
102
+
103
+ `test.each` is the form for a rule with many cases, and `given-and-then-markers-present` judges the table ONCE: the Given/Then narration is written on the table, not repeated in every row.
104
+
105
+ ```typescript
106
+ import { describe, expect, test } from 'vitest';
107
+
108
+ import { equalAge, equalScore } from './ranking.fixtures.js';
109
+ import { rank } from './ranking.js';
110
+
111
+ describe('rank', () => {
112
+ test.each([
113
+ { expected: 'fresh', label: 'a fresher post of equal score', posts: equalScore },
114
+ { expected: 'high', label: 'a higher score of equal age', posts: equalAge },
115
+ ])(
116
+ // Given - $label
117
+ // Then - it leads the ranking
118
+ 'ranks $label first',
119
+ ({ expected, posts }) => {
120
+ expect(rank(posts)[0]?.id).toBe(expected);
121
+ },
122
+ );
123
+ });
124
+ ```
125
+
126
+ The whole file is that: one top-level `describe` titled by the subject, and inside the `test.each` the `// Given -` and `// Then -` markers written once, before the title string. The linter reads the markers there, and a table written without that shape fails `given-and-then-markers-present` and `one-describe-per-file` in turn.
127
+
128
+ ## The result is the return value
129
+
130
+ There is no result object here, because there is no chain to hand one back. The subject is the module's own return value, its thrown error, or a call recorded on a port double, and those are vitest's native matchers' to judge ([Assertions](../reference/assertions.md#scalars-native-expect)). `text(value)` is the one helper that borrows the framework's stream vocabulary for a plain string.
131
+
132
+ ## Unique here
133
+
134
+ - **It is the only kind with no folder.** Every other kind is collected by a path (`specs/<specification>/`); this one is collected by a suffix, in the `__tests__/` of what it specifies. Moving a module test away from its module is `specification-test-uses-its-runner`'s error in one direction and `file-suffix-matches-place`'s in the other.
135
+ - **It is the only kind that may build its own double.** In every other kind a double is DECLARED (a stub, a service, a seeded row) because the subject is an assembled product and the test does not hold its wiring. A module test is handed the wiring, so `mockOf` is in its hands.
136
+ - **It is the only kind with no golden.** A golden is a file, and a file a module test imports is an asset; a module whose answer deserves a golden is describing something assembled, and the integration specification is where that goes ([Integration specs](integration-specs.md#the-golden-half-a-pure-module-no-services-at-all)).
137
+
138
+ ## Pitfalls
139
+
140
+ - **Reaching for `vi.mock` to avoid injecting a port.** The mock outlives the test's intent: every importer of that specifier gets the double, and the design pressure the injection would have applied is gone. Double the port.
141
+ - **`vi.setSystemTime` instead of `clock`.** `no-vitest-time-or-global-stub` refuses it ([Time and doubles § The clock](../reference/time-and-doubles.md#the-clock)).
142
+ - **Importing a `.json` payload.** `no-module-mock` refuses it. Put the payload in `<file>.fixtures.ts`, where the compiler reads it.
143
+ - **Nesting `describe`.** The one level is THE describe, titled with the subject, holding every test ([Conventions § One file, one subject](../concepts/conventions.md#one-file-one-subject)); a second level describes a structure the test file does not have. `one-describe-per-file` refuses a file with no describe or two, `one-describe-level` the nesting.
144
+ - **Asserting on a DOM.** There is no document here, and there will not be one: `no-dom-in-module-test` refuses the globals, `no-simulated-dom` refuses the `@vitest-environment` comment that would conjure them, and the route is a `.test.tsx` in a real browser.
145
+ - **Letting the Given drift into `beforeEach`.** A Given nobody can read in the test is a Given nobody maintains. `given-in-the-test` says so.
146
+
147
+ ## Related
148
+
149
+ [Getting started](../start/getting-started.md) · [Integration specs](integration-specs.md) · [Component specs](component-specs.md) · [Stubs](../reference/stubs.md) · [Time and doubles](../reference/time-and-doubles.md) · [Assertions](../reference/assertions.md) · [Conventions](../concepts/conventions.md)
@@ -0,0 +1,43 @@
1
+ # Release
2
+
3
+ This repository ships one thing: the npm package `@jterrazz/test`. Nothing here deploys, nothing here runs as a service, and merging to `main` publishes nothing: a release is a deliberate, separate act. This page says what that act is, what leaves the tree when it happens, and the footprint the package leaves in a project. What a consumer installs beside it is [Set up a project § What to install](../start/set-up-a-project.md#what-to-install).
4
+
5
+ ## Releasing
6
+
7
+ A release is three steps, in this order.
8
+
9
+ 1. **Bump and land the version.** The version in `package.json` moves, alone, in a commit whose subject is `chore: <version> — <what changed>`. The commit log is the changelog: this repository carries no `CHANGELOG.md`, by decision, and the release notes plus that subject line are the record. A major also adds what it makes a project rewrite to [Set up a project § Upgrading](../start/set-up-a-project.md#upgrading), the one page a consumer reads before bumping.
10
+ 2. **Create the GitHub release**, tagged at that commit. This is the trigger and the only one: `.github/workflows/release.yaml` listens on `release: types: [created]`.
11
+ 3. **Watch the gate.** The workflow delegates to the shared `jterrazz/jterrazz-actions/.github/workflows/release-npm.yaml`, which runs the FULL validate job first (the same targets the push gate runs, chromium included: [Test § What CI runs](test.md#what-ci-runs)) and publishes only if it is green.
12
+
13
+ The publish step is `npm publish --access public --provenance`, on Node 24, with `id-token: write` granted so npm records a provenance attestation linking the tarball to the workflow run that built it. The registry is npm's, stated as `publishConfig` in the manifest.
14
+
15
+ Because validate runs inside the release workflow, a red suite stops a publication rather than a merge. That is the design: the tree on `main` is always publishable, and nothing decides for you when it is published.
16
+
17
+ ## What leaves the tree
18
+
19
+ The tarball is what `files` declares and nothing else: `dist/` less the source maps the build writes for coverage, plus `schema/` and `docs/` less the two pages that are this repository's own: `guides/test.md`, how it tests itself and the rules only it is held to, and `decisions/`, what it alone decided. `dist/` holds only the shipped entries; the repository's own programs are built into `.artifacts/dev/` and so never reach the tarball ([Develop § The build comes first](develop.md#the-build-comes-first)). This package's audience meta-test holds the line on the tarball as `npm pack` writes it: a byte of it that names a self rule, a published entry that registers one, a private entry that runs one, a packed chunk that only an unshipped bundle imports, or a packed bundle that imports a module the tarball lacks, fails the suite.
20
+
21
+ The shipped chapters keep their links to the two pages left home, so from inside an install such a link reaches nothing: both are read in the repository, at <https://github.com/jterrazz/package-test/tree/main/docs>.
22
+
23
+ **The manual ships with the code**, because the code points at it: every relative link of the shipped `README.md` and every `read: docs/reference/linting.md#...` a rule message ends on is read from inside an install, and a chapter that is not there makes both a dead end. `specs/cli/node/pages.spec.ts` holds it from the packed tarball: every chapter the installed README and modules cite is listed with whether the install holds it.
24
+
25
+ The entries that reach a consumer are the whole public contract, and they are listed once: [Architecture § What the tree publishes](../concepts/architecture.md#what-the-tree-publishes). What matters here is only that the ROOT entry resolves by CONDITION: a bundler serving a page takes `browser`, node takes `import`, and both are described by one `types` entry.
26
+
27
+ No binary ships with them. The conventions checker is the `#checker` private import, `dist/checker.js`, and the `test()` preset names it as its checks: `typescript check`, `fix` and `baseline` run it through the toolchain's own checker program, once over the repository (every specs tree, every member) and once per member declaring its own presets. Its findings are published under the `jterrazz-check/<name>` keys the ratchet counts ([Linting](../reference/linting.md#the-ratchet-the-test-section-of-jterrazzbaselinejson)). How the toolchain runs a preset's checks is `@jterrazz/typescript`'s: [Quality checks § A preset's checks](https://github.com/jterrazz/package-typescript/blob/main/docs/reference/quality-checks.md#a-presets-checks).
28
+
29
+ ## The footprint it leaves
30
+
31
+ Nothing persistent: inside a consuming project the framework writes only under `.artifacts/`, and keeps its scratch in one temp folder per run that it removes when the run ends; [Set up a project § Where a run writes](../start/set-up-a-project.md#where-a-run-writes) lists each path.
32
+
33
+ Containers are the one resource that outlives a process badly, which is why `afterAll(cleanup)` is a rule and not a suggestion: a suite that forgets it leaks its infrastructure into the next one.
34
+
35
+ ## Pitfalls
36
+
37
+ - **Pushing the bump and expecting a publish.** `main` is not a release trigger. Without a created GitHub release, the version on npm does not move.
38
+ - **Releasing from a red tree.** The workflow will refuse, and it refuses AFTER the tag exists, so the tag has to be re-cut once the fix lands.
39
+ - **Adding a file to the tarball by putting it in the repository.** Only what `files` names ships. A new published asset is a manifest change, and a deliberate one.
40
+
41
+ ## Related
42
+
43
+ [Architecture](../concepts/architecture.md) · [Develop](develop.md) · [Test](test.md)