@jterrazz/test 10.1.0 → 11.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.
package/README.md CHANGED
@@ -208,7 +208,7 @@ const result = await cli.seed('legacy-schema.sql').exec('up');
208
208
 
209
209
  ### `specification.website({ server?, url?, backend?, external?, root? })`
210
210
 
211
- Tests a rendered website: `.fetch(path)` for a raw HTTP exchange (redirects never followed), `.visit(path, scenario?)` for a page rendered in a real chromium. Exactly one of `server` (start the site locally — a free port injected as `PORT`, polled on `ready`) or `url` (target a running site) is required. `backend: { env, port? }` (server mode only) additionally starts a declared stub backend and injects its URL into the server child under `env`; each chain declares what it serves via `.intercept('<name>.http')`.
211
+ Tests a rendered website: `.fetch(path)` for a raw HTTP exchange (redirects never followed), `.visit(path, scenario?)` for a page rendered in a real chromium. Exactly one of `server` (start the site locally — a free port injected as `PORT`, polled on `ready`) or `url` (target a running site) is required. `backend: { env, port? }` (server mode only) additionally starts a declared stub backend and injects its URL into the server child under `env`; each chain declares what it serves with `.intercept(contracts)`, the same contracts form `api`/`jobs` use.
212
212
 
213
213
  ```typescript
214
214
  export const { website, cleanup } = await specification.website({
@@ -253,16 +253,16 @@ When `root` is absent, the framework walks up from the specification file to the
253
253
 
254
254
  ### Setup (chainable)
255
255
 
256
- | Method | Facets | Description |
257
- | --------------------------------------- | ------------ | ------------------------------------------------------------------------------------------------------ |
258
- | `.seed("file.sql", { database? })` | all | Load SQL from `seeds/` — `database` is the record key (mandatory with ≥ 2 databases, forbidden with 1) |
259
- | `.fixture("file")` | cli | Copy the feature-local `fixtures/file` into the working directory |
260
- | `.fixture("$FIXTURES/name/")` | cli | Spread the shared `specs/fixtures/name/` project into the cwd (trailing `/` = contents; layers) |
261
- | `.env({ KEY: "value" })` | cli | Set env vars on the child (`null` unsets, `$WORKDIR` expands, calls merge) |
262
- | `.headers({ "Accept-Language": "fr" })` | api, website | Set HTTP request headers (merge on top of `.http` file headers, or on the browser context) |
263
- | `.intercept(contract)` | api, jobs | Intercept an outgoing HTTP call with a declared contract |
264
- | `.intercept(trigger, response)` | api, jobs | Inline intercept for one-off cases |
265
- | `.intercept("two-events.http")` | all but cli | Declared exchanges from `intercepts/<name>.http` — MSW on api/jobs, the stub backend on website/mobile |
256
+ | Method | Facets | Description |
257
+ | --------------------------------------- | ------------ | ----------------------------------------------------------------------------------------------------------- |
258
+ | `.seed("file.sql", { database? })` | all | Load SQL from `seeds/` — `database` is the record key (mandatory with ≥ 2 databases, forbidden with 1) |
259
+ | `.fixture("file")` | cli | Copy the feature-local `fixtures/file` into the working directory |
260
+ | `.fixture("$FIXTURES/name/")` | cli | Spread the shared `specs/fixtures/name/` project into the cwd (trailing `/` = contents; layers) |
261
+ | `.env({ KEY: "value" })` | cli | Set env vars on the child (`null` unsets, `$WORKDIR` expands, calls merge) |
262
+ | `.headers({ "Accept-Language": "fr" })` | api, website | Set HTTP request headers (merge on top of `.http` file headers, or on the browser context) |
263
+ | `.intercept(contracts)` | all but cli | Declare the world: a `defineContracts(...)` composite MSW on api/jobs, the stub backend on website/mobile |
264
+ | `.intercept(contract)` / `([a, b])` | all but cli | A single contract, or an ordered list |
265
+ | `.intercept(request, response)` | all but cli | Inline pair, for one-off plumbing |
266
266
 
267
267
  ### Actions (terminal)
268
268
 
@@ -340,25 +340,40 @@ Location: /orders/{{uuid#order}}
340
340
 
341
341
  See [docs/06-tokens.md](docs/06-tokens.md) for the canonical accepted form of every token.
342
342
 
343
- ## Intercept contracts
343
+ ## Contracts
344
344
 
345
- External interactions (LLM providers, third-party APIs) are declared as **contracts**: one file per interaction under `contracts/`, flat, with a provider suffix `contracts/<name>.<provider>.ts`, `provider ∈ { openai, anthropic, http }`:
345
+ Everything the outside world replies is a **contract** a request to match and a response to serve, declared together. A feature owns a `contracts/` folder: a public `<name>.contracts.ts` facade (default export = the world, named exports = its scenarios) over internal `<provider>/<name>.ts` units, `provider ∈ { http, openai, anthropic }`.
346
346
 
347
347
  ```typescript
348
- // contracts/classify-product.openai.ts
348
+ // contracts/openai/classify-product.ts
349
349
  import { defineContract, openai } from '@jterrazz/test';
350
350
 
351
351
  export default defineContract({
352
- trigger: openai.responses({ user: /Product Classification/, tools: ['classify'] }),
352
+ request: openai.responses({ user: /Product Classification/, tools: ['classify'] }),
353
353
  response: openai.reply({ category: 'ELECTRONICS', confidence: 0.97 }),
354
354
  });
355
355
  ```
356
356
 
357
357
  ```typescript
358
- const result = await jobs.intercept(classifyProduct).trigger('nightly-report');
358
+ // contracts/pipeline.contracts.ts
359
+ import { defineContracts, http } from '@jterrazz/test';
360
+
361
+ import classifyProduct from './openai/classify-product.js';
362
+ import exchangeRates from './http/exchange-rates.js';
363
+
364
+ const pipeline = defineContracts(classifyProduct, exchangeRates);
365
+
366
+ export default pipeline;
367
+
368
+ export const withRatesDown = () =>
369
+ pipeline.with({ request: http.get('/rates'), response: http.error(503) });
370
+ ```
371
+
372
+ ```typescript
373
+ const result = await jobs.intercept(pipeline).trigger('nightly-report');
359
374
  ```
360
375
 
361
- Inline `.intercept(trigger, response)` and JSON fixtures (`intercepts/<provider>/<name>.json`) remain for one-off cases; `.intercept('<name>.http')` loads declared exchanges from a bi-block `intercepts/<name>.http` file the form website/mobile chains use, served by the declared `backend` stub. Failure simulation: `openai.error(429)`, `anthropic.timeout()`, `openai.malformed('not json')`. Intercepts queue FIFO per trigger. MSW ships as a direct dependency — no separate install.
376
+ Selection is first-match, one queue for every facet: `times` bounds how often a contract serves (omitted = unlimited, so retries and re-renders replay it), `required: true` fails the chain if it was never requested. Provider string filters are **exact** — the loose forms are explicit (`RegExp`, `match.includes('…')`). Failure simulation: `openai.error(429)`, `anthropic.timeout()`, `openai.malformed('not json')`. MSW ships as a direct dependency — no separate install. Full chapter: [docs/07-contracts.md](docs/07-contracts.md).
362
377
 
363
378
  ## Docker-aware CLIs
364
379
 
@@ -409,8 +424,7 @@ specs/<facet>/ # api | jobs | cli | integrations | lint
409
424
  ├── <aspect>.test.ts
410
425
  ├── seeds/ # *.sql ONLY — database state
411
426
  ├── requests/ # *.http — inputs: COMPLETE request (method, path, headers, body)
412
- ├── contracts/ # <name>.<provider>.ts declared external interactions
413
- ├── intercepts/ # <provider>/<name>.json — inline intercept fixtures; <name>.http — declared exchanges (flat)
427
+ ├── contracts/ # <name>.contracts.ts facade + <provider>/<name>.ts units + their .response.json / .request.ts data
414
428
  ├── fixtures/ # domain-local files/dirs copied into the cwd (cli) — shared pool lives at specs/fixtures/
415
429
  └── expected/ # ALL expected fixtures, FLAT (incl. response *.http) — a slash in the name creates a subfolder
416
430
  ```
@@ -179,8 +179,7 @@ function projectScreen(xml) {
179
179
  }
180
180
  //#endregion
181
181
  //#region src/integrations/appium/appium.adapter.ts
182
- /** How long every verb polls for at least one visible match. */
183
- const ACTION_TIMEOUT_MS = 15e3;
182
+ const ACTION_TIMEOUT_MS = 3e4;
184
183
  const POLL_INTERVAL_MS = 500;
185
184
  /** How many candidates the ambiguity error enumerates before truncating. */
186
185
  const MAX_REPORTED_MATCHES = 10;
@@ -196,14 +195,15 @@ function escapePredicate(value) {
196
195
  * have no XCUITest analog — the shared vocabulary is wider than a screen,
197
196
  * and the boundary is named rather than silently approximated.
198
197
  */
199
- function compilePredicate(element) {
198
+ function compilePredicate(element, options) {
200
199
  const name = escapePredicate(element.name ?? "");
201
200
  const contains = (attribute) => element.exact ? `${attribute} == '${name}'` : `${attribute} CONTAINS '${name}'`;
201
+ const visible = options?.anyVisibility ? "1 == 1" : "visible == 1";
202
202
  switch (element.kind) {
203
- case "button": return `type == 'XCUIElementTypeButton' AND ${contains("label")} AND visible == 1`;
204
- case "field": return `type IN {'XCUIElementTypeTextField','XCUIElementTypeSecureTextField'} AND (${contains("label")} OR ${contains("value")}) AND visible == 1`;
205
- case "testId": return `name == '${name}' AND visible == 1`;
206
- case "text": return `(${contains("label")} OR ${contains("value")}) AND visible == 1`;
203
+ case "button": return `type == 'XCUIElementTypeButton' AND ${contains("label")} AND ${visible}`;
204
+ case "field": return `type IN {'XCUIElementTypeTextField','XCUIElementTypeSecureTextField'} AND (${contains("label")} OR ${contains("value")}) AND ${visible}`;
205
+ case "testId": return `name == '${name}' AND ${visible}`;
206
+ case "text": return `(${contains("label")} OR ${contains("value")}) AND ${visible}`;
207
207
  default: throw new Error(`${formatElement(element)}: landmarks are a website concept — an iOS screen has no '${element.kind}' region. Scope with within(testId('…'), …) instead.`);
208
208
  }
209
209
  }
@@ -306,20 +306,33 @@ var AppiumAdapter = class {
306
306
  async resolveOne(driver, element, verb, cardinality) {
307
307
  const chain = scopeChain(element);
308
308
  const deadline = Date.now() + ACTION_TIMEOUT_MS;
309
+ let scrollAttempted = false;
309
310
  for (;;) {
310
- const resolved = await this.resolveChain(driver, chain, cardinality);
311
+ const resolved = await this.resolveChain(driver, chain, cardinality, { tryScroll: !scrollAttempted });
312
+ scrollAttempted = true;
311
313
  if (resolved) return resolved;
312
314
  if (Date.now() > deadline) throw await this.timeoutError(driver, element, verb);
313
315
  await delay(POLL_INTERVAL_MS);
314
316
  }
315
317
  }
316
318
  /** One resolution pass over the chain — `null` means "nothing yet, keep polling". */
317
- async resolveChain(driver, chain, cardinality) {
319
+ async resolveChain(driver, chain, cardinality, options) {
318
320
  let scope = driver;
319
321
  for (const [index, level] of chain.entries()) {
320
322
  const matches = await scope.$$(`-ios predicate string:${compilePredicate(level)}`);
321
323
  const count = await matches.length;
322
- if (count === 0) return null;
324
+ if (count === 0) {
325
+ if (options?.tryScroll) {
326
+ const offscreen = await scope.$$(`-ios predicate string:${compilePredicate(level, { anyVisibility: true })}`);
327
+ if (await offscreen.length > 0) try {
328
+ await driver.executeScript("mobile: scroll", [{
329
+ elementId: offscreen[0].elementId,
330
+ toVisible: true
331
+ }]);
332
+ } catch {}
333
+ }
334
+ return null;
335
+ }
323
336
  const isTarget = index === chain.length - 1;
324
337
  if (count > 1 && (cardinality === "one" || !isTarget)) throw new AmbiguousElementError(describeMobileAmbiguity({
325
338
  element: level,
package/dist/checker.js CHANGED
@@ -36,7 +36,6 @@ const PRUNED = /* @__PURE__ */ new Set([
36
36
  const CONVENTIONAL_SUBDIRS = /* @__PURE__ */ new Set([
37
37
  "expected",
38
38
  "fixtures",
39
- "intercepts",
40
39
  "requests",
41
40
  "seeds"
42
41
  ]);