@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 +33 -19
- package/dist/appium.adapter.js +23 -10
- package/dist/checker.js +0 -1
- package/dist/index.d.ts +214 -153
- package/dist/index.js +206 -665
- package/dist/intercept.js +37 -84
- package/dist/match.js +12 -1
- package/dist/oxlint.cjs +316 -56
- package/dist/oxlint.js +316 -56
- package/dist/queue.js +555 -0
- package/package.json +1 -1
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
|
|
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(
|
|
264
|
-
| `.intercept(
|
|
265
|
-
| `.intercept(
|
|
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
|
-
##
|
|
343
|
+
## Contracts
|
|
344
344
|
|
|
345
|
-
|
|
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.
|
|
348
|
+
// contracts/openai/classify-product.ts
|
|
349
349
|
import { defineContract, openai } from '@jterrazz/test';
|
|
350
350
|
|
|
351
351
|
export default defineContract({
|
|
352
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
```
|
package/dist/appium.adapter.js
CHANGED
|
@@ -179,8 +179,7 @@ function projectScreen(xml) {
|
|
|
179
179
|
}
|
|
180
180
|
//#endregion
|
|
181
181
|
//#region src/integrations/appium/appium.adapter.ts
|
|
182
|
-
|
|
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
|
|
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
|
|
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)
|
|
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,
|