@burojs/test-helpers 0.4.0 → 0.5.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
@@ -33,7 +33,7 @@ Every locator above is derived from the resource name, so none of these test ids
33
33
 
34
34
  ## Main exports
35
35
 
36
- - **`createBuroPage` / `BuroPage`** — the entry point; wraps a Playwright `Page`. Exposes `resource(name)`, `contextPicker(slice)`, `notification(type)`, `actionConfirm(resource, scope, action)` and the raw `page`.
36
+ - **`createBuroPage` / `BuroPage`** — the entry point; wraps a Playwright `Page`. Exposes `resource(name)`, `contextPicker(slice)`, `notification(type)`, `actionConfirm(resource, scope, action)` (a flow's confirm dialog on a toolbar surface) and the raw `page`.
37
37
  - **`createResourceObject` / `ResourceObject`** — one resource's four surfaces: `list()`, `create()`, `edit(id)`, `show(id)`, plus `picker()`.
38
38
  - **`createFormView` / `FormView` / `FormFieldObject`** — form fields across create/edit/show: `field(name).input()`, `.error()`, `.help()`, and `section(key)`.
39
39
  - **`createShowView` / `ShowView`** — the detail surface (a `FormView` in `show` mode).
package/dist/index.cjs CHANGED
@@ -10,10 +10,10 @@ function createFormView(page, resource, view) {
10
10
  help: () => page.getByTestId(`form-field-help:${resource}:${view}:${fieldName}`)
11
11
  };
12
12
  },
13
- group: (groupKey) => page.getByTestId(`form-group:${resource}:${view}:${groupKey}`),
14
13
  submit: () => page.getByTestId(`form-submit:${resource}:${view}`),
15
14
  cancel: () => page.getByTestId(`form-cancel:${resource}:${view}`),
16
- action: (actionName) => page.getByTestId(`form-action:${resource}:${view}:${actionName}`)
15
+ action: (actionName) => page.getByTestId(`form-action:${resource}:${view}:${actionName}`),
16
+ node: (key, region = "record-body") => page.locator(`[data-buro-region="${region}"] [data-buro-key="${key}"]`)
17
17
  };
18
18
  }
19
19
  //#endregion
@@ -148,7 +148,7 @@ function createBuroPage(page) {
148
148
  failure: (subject) => page.getByTestId(`task-run-failure:${name}:${subject}`)
149
149
  }),
150
150
  notification: (type) => page.getByTestId(`notification:${type}`),
151
- actionConfirm: (resource, scope, actionName) => page.getByTestId(`action-confirm-dialog:${resource}:${scope}:${actionName}`),
151
+ actionConfirm: (resource, scope, actionName) => page.getByTestId(`form-action:${resource}:${scope}:${actionName}:confirm:dialog`),
152
152
  page
153
153
  };
154
154
  }
@@ -1 +1 @@
1
- {"version":3,"file":"index.cjs","names":[],"sources":["../src/form-view.ts","../src/list-view.ts","../src/picker-object.ts","../src/show-view.ts","../src/resource-object.ts","../src/buro-page.ts","../src/confirm-dialog.ts","../src/index.ts"],"sourcesContent":["import type { ClickableLocator, PlaywrightPage } from './types.js';\n\nexport interface FormFieldObject<L> {\n input: () => L;\n error: () => L;\n help: () => L;\n}\n\nexport interface FormView<L extends ClickableLocator> {\n field: (fieldName: string) => FormFieldObject<L>;\n /**\n * One declared field GROUP.\n *\n * ⚠️ Renamed from `section()` at SP9 task 5: it asked for `form-section:…`\n * and `<ResourceForm>` has always rendered `form-group:…`, so it had never\n * matched an element. See `src/picker-object.ts` for the wider sweep and for\n * why the cross-vocabulary test could not see any of it.\n */\n group: (groupKey: string) => L;\n submit: () => L;\n cancel: () => L;\n action: (actionName: string) => L;\n}\n\nexport function createFormView<L extends ClickableLocator>(\n page: PlaywrightPage<L>,\n resource: string,\n view: 'create' | 'edit' | 'show',\n): FormView<L> {\n return {\n field: (fieldName) => {\n page.getByTestId(`form-field:${resource}:${view}:${fieldName}`);\n return {\n input: () => page.getByTestId(`form-field-input:${resource}:${view}:${fieldName}`),\n error: () => page.getByTestId(`form-field-error:${resource}:${view}:${fieldName}`),\n help: () => page.getByTestId(`form-field-help:${resource}:${view}:${fieldName}`),\n };\n },\n group: (groupKey) => page.getByTestId(`form-group:${resource}:${view}:${groupKey}`),\n submit: () => page.getByTestId(`form-submit:${resource}:${view}`),\n cancel: () => page.getByTestId(`form-cancel:${resource}:${view}`),\n action: (actionName) => page.getByTestId(`form-action:${resource}:${view}:${actionName}`),\n };\n}\n","import type { ClickableLocator, PlaywrightPage } from './types.js';\n\n/**\n * A row's address inside the filter-builder draft: `[]` is the root group,\n * `[0]` is its first row, `[1, 0]` is the first row of the group nested at\n * top-level index 1. Mirrors `DraftPath` in\n * `packages/ui-shadcn/src/list-kit/filter-builder/draft.ts` — the testid\n * suffix is `path.join('-')` (an empty path renders as the empty string, so\n * the root group's own testid carries a trailing `:`, e.g.\n * `list-filter:<r>:group::add-row`).\n */\nexport type FilterRowPath = readonly number[];\n\nexport interface ListView<L extends ClickableLocator> {\n goto: () => Promise<void>;\n row: (recordId: string) => RowObject<L>;\n /**\n * EVERY rendered row of this resource, as one locator — the set, not a\n * named member of it. `row(recordId)` cannot express it: a row's testid\n * embeds its record id, so the set is only addressable by prefix.\n *\n * This is the accessor a list assertion is actually built on\n * (`await expect(list.rows()).toHaveCount(n)`), and it was missing for as\n * long as the only consumer of this package was a mock that never counted\n * anything.\n */\n rows: () => L;\n /**\n * One column across every rendered row, in row order — again a set, again\n * prefix-addressed. Reading a whole column through ONE locator matters for\n * more than convenience: a per-row loop awaits between reads and can meet a\n * detached node mid-re-render, which turns a polling wait into a thrown\n * error instead of another attempt.\n */\n column: (fieldName: string) => L;\n /** The column header's menu trigger (sort / pin / hide / move). */\n columnMenu: (fieldName: string) => L;\n /** The sort item inside an OPEN column header menu. */\n columnSort: (fieldName: string, direction: 'asc' | 'desc') => L;\n /**\n * The reorder item inside an OPEN column header menu — moves the column one\n * position left or right.\n *\n * ⚠️ What it moves is **column layout**, which is a different persistence\n * mechanism from everything else on this object: order, width and pinning\n * live in raw `localStorage` under `buro:table-layout:<resource>:{order,\n * sizing,pinning}` and are carried by neither the URL nor a saved view. See\n * {@link ListView.savedViews}.\n */\n columnMove: (fieldName: string, direction: 'left' | 'right') => L;\n /** The Columns menu trigger — show/hide columns, reset widths, restore defaults. */\n columnsToggle: () => L;\n /**\n * One column's row inside the OPEN Columns menu. A column the resource\n * declares in `views.list.columns` is REQUIRED unless\n * `views.list.requiredColumns` says otherwise, and renders `disabled` with\n * `data-required=\"true\"` — only `optionalColumns` are actually clickable.\n */\n columnsOption: (columnId: string) => L;\n /** The rows-per-page select trigger; its text is the active page size. */\n pageSize: () => L;\n search: () => L;\n // ── Filter builder (SP2) ─────────────────────────────────────────────\n // The chip toolbar this file used to model (`list-dyn-filter:*`,\n // `list-add-filter:*`) is gone — SP2 replaced it with a popover panel:\n // open the panel, add a row, pick a field/operator/value, Apply. Applied\n // rows then render as removable badges OUTSIDE the panel. See\n // `packages/ui-shadcn/src/list-kit/filter-builder/*` for the components\n // these testids come from.\n /** The filter region container — `list-filters:<resource>`. */\n filtersContainer: () => L;\n /** Opens/closes the filter-builder popover. */\n filterButton: () => L;\n /** The popover body once open. */\n filterPanel: () => L;\n /** \"+ Filter\" — appends a new predicate row to the group at `groupPath` (root: `[]`). */\n filterAddRow: (groupPath?: FilterRowPath) => L;\n /** Field-select trigger for the row at `path`. */\n filterRowField: (path: FilterRowPath) => L;\n /** One field option inside an open field-select. */\n filterRowFieldOption: (path: FilterRowPath, field: string) => L;\n /** Operator-select trigger for the row at `path`. */\n filterRowOp: (path: FilterRowPath) => L;\n /** One operator option inside an open operator-select. */\n filterRowOpOption: (path: FilterRowPath, op: string) => L;\n /** Value control trigger/input for the row at `path` — shape depends on the field kind (text box, enum/relation/boolean popover). */\n filterRowValue: (path: FilterRowPath) => L;\n /** Removes the row at `path` from the draft (inside the panel, before Apply). */\n filterRowRemove: (path: FilterRowPath) => L;\n /** Commits the draft — applies it and closes the panel. */\n filterApply: () => L;\n /** Clears the draft back to empty (still requires Apply to take effect). */\n filterReset: () => L;\n /** One applied-filter badge outside the panel, by its top-level index. */\n filterBadge: (index: number) => L;\n /** A badge's × — removes that row immediately, no Apply needed. */\n filterBadgeRemove: (index: number) => L;\n /** \"Clear all\" — empties both the pinned slot and the builder group. */\n filterClearAll: () => L;\n /** An always-visible pinned control (`views.list.filters.pinned`) for `field`, rendered outside the panel. */\n filterPinned: (field: string) => L;\n /**\n * The floating bulk bar, which appears as soon as ANY row is selected —\n * even for a resource that declares no bulk action, because it also carries\n * the built-in Export entry and the selection count.\n */\n bulkBar: () => L;\n /** One declared bulk action's button inside the bar (`actions.bulk`). */\n bulkAction: (actionName: string) => L;\n paginationPrev: () => L;\n paginationNext: () => L;\n /**\n * The load-more button a CURSOR-paginated list renders instead of the\n * numbered pager (`views.list.pagination: 'cursor'` → `<CursorLoadMore>`).\n *\n * ⚠️ NOT resource-scoped, because the control itself is not: `CursorLoadMore`\n * carries a bare `data-testid=\"cursor-load-more\"` with no resource segment,\n * unlike every other id above. A page rendering two cursor lists at once\n * would need `.first()`/`.nth()` to tell them apart. Recorded rather than\n * papered over — inventing a scoped id here would only make this object\n * disagree with the component.\n *\n * ⚠️ `<CursorLoadMore>` renders NOTHING when there is no next page, so the\n * end of a cursor list is \"this locator resolves to no element\", not a\n * disabled button. That is what makes it usable as an end-of-list assertion.\n *\n * Like `columnMenu`/`columnSort`, this id has no `useTestIds` factory behind\n * it, so `page-object-vocabulary` tests have nothing to check it against\n * (the pre-existing F6 gap — see the saved-views block below for what that\n * costs, measured).\n */\n loadMore: () => L;\n empty: () => L;\n toolbar: () => L;\n table: () => L;\n // ── Saved views ──────────────────────────────────────────────────────\n // `<ResourceListPage>` renders the Views dropdown UNCONDITIONALLY, and\n // `<BuroProvider>` mounts a localStorage-backed settings store when the\n // adopter supplies none — so \"Save current view…\" is on every list screen\n // of every app built on this framework, whether or not anyone declared\n // `views.list.savedViews`. Until these accessors existed, no page object\n // could address any of it: the only browser tests that ever tried\n // (the since-deleted `apps/playground`'s saved-views spec) spelled the ids by\n // hand, and marked six of their eleven tests `test.fixme` — including both\n // round-trips.\n //\n // ⚠️ **A view carries exactly six keys** — `filters`, `filterGroup`,\n // `sort`, `pageSize`, `search`, `visibleColumns`\n // (`SavedViewSettingValue`). NOT the page number (applying a view always\n // opens page one), NOT the selection, and NOT column layout: width, order\n // and pinning are a separate store (see {@link ListView.columnMove}), so a\n // view restores WHICH columns are shown and never HOW WIDE or in what\n // order.\n //\n // ⚠️ **NONE OF THE THIRTEEN ACCESSORS ADDED WITH THIS BLOCK HAS A\n // `useTestIds` FACTORY** (saved views, the Columns menu, the page-size\n // select and `columnMove`), so `page-object-vocabulary.test.tsx` has nothing\n // to compare them against — the F6 gap again, with thirteen more instances.\n // SP5 task 8 settled it as: NOT closed, and here is the measured cost, so\n // that the next person decides on numbers rather than on the word \"gap\".\n //\n // Both sides are independently pinned, just never against each other:\n // `tests/buro-page.test.ts` pins the string THIS file hands out, and on the\n // component side eight of the thirteen ids are spelled as literals in\n // `packages/ui-shadcn/tests/*` while ten of the thirteen accessors are\n // driven by `examples/marketplace-ops`' browser suite. So a rename in\n // `@burojs/ui-shadcn` reddens something in every case **but one**:\n //\n // `savedViewsMenu` (`list-saved-views-menu:<r>`) is spelled by no package\n // test and addressed by no browser spec, so renaming it in\n // `resource-list-page.tsx` is invisible everywhere and this accessor would\n // quietly point at nothing.\n //\n // Closing the family properly means ~13 new factories on the public\n // `TestIdBuilder` plus rewiring `resource-list-page.tsx`,\n // `columns-toggle.tsx` and `resource-list.tsx` to compose through them —\n // a public-surface decision, not a test fix, and it covers\n // `columnMenu`/`columnSort`/`columnMove` and `loadMore` at the same time.\n /** The Views dropdown trigger in the list toolbar. */\n savedViews: () => L;\n /** The dropdown body once open. */\n savedViewsMenu: () => L;\n /**\n * EVERY view entry in the open dropdown (adopter presets and user-saved\n * alike), as one locator — the set, not a named member of it, because a\n * user view's id is a uuid minted at save time and no test can know it in\n * advance. Prefix-addressed for the same reason {@link ListView.rows} is.\n */\n savedViewEntries: () => L;\n /** One view entry by id — for an adopter preset declared in `views.list.savedViews`, whose id IS known. */\n savedView: (viewId: string) => L;\n /** \"Save current view…\" — opens the save dialog. */\n savedViewSave: () => L;\n /** The label input inside the open save dialog. */\n savedViewSaveInput: () => L;\n /** The save dialog's confirm button; disabled while the label is empty. */\n savedViewSaveConfirm: () => L;\n /**\n * \"Manage views…\" — opens the manage dialog. Rendered only when at least\n * one entry exists, so `toHaveCount(0)` is a usable \"this browser has no\n * saved views\" assertion.\n */\n savedViewManage: () => L;\n /** One entry's Delete button inside the open manage dialog. */\n savedViewManageDelete: (viewId: string) => L;\n}\n\nexport interface RowObject<L extends ClickableLocator> {\n /**\n * The row element itself. Without it a caller cannot say \"this row is on\n * screen\" or \"this row is gone\" — the two assertions a list test needs\n * most, and the ones `cell()`/`action()` cannot stand in for (a row can be\n * present while a given cell or action is not).\n */\n root: () => L;\n cell: (fieldName: string) => L;\n action: (actionName: string) => L;\n checkbox: () => L;\n open: () => Promise<void>;\n}\n\nexport interface ListViewOptions {\n /**\n * What {@link ListView.goto} navigates to. Defaults to `/<resource>`, which\n * assumes the route is named after the resource — often false (a `order`\n * resource routed at `/orders`), and it carries no query string, so an app\n * whose test isolation travels on the URL has to pass its whole launch URL\n * here.\n */\n basePath?: string;\n}\n\n/** `path.join('-')` — see `FilterRowPath`'s doc for the empty-path edge case. */\nfunction pathStr(path: FilterRowPath): string {\n return path.join('-');\n}\n\nexport function createListView<L extends ClickableLocator>(\n page: PlaywrightPage<L>,\n resource: string,\n options: ListViewOptions = {},\n): ListView<L> {\n const path = options.basePath ?? `/${resource}`;\n const filterBase = `list-filter:${resource}`;\n const sortBase = (field: string) => `list-sort:${resource}:${field}`;\n const rowBase = (rowPath: FilterRowPath) => `${filterBase}:row:${pathStr(rowPath)}`;\n const groupBase = (groupPath: FilterRowPath) => `${filterBase}:group:${pathStr(groupPath)}`;\n return {\n goto: async () => {\n await page.goto(path);\n },\n row: (recordId) => createRowObject(page, resource, recordId),\n rows: () => page.locator(`[data-testid^=\"list-row:${resource}:\"]`),\n column: (fieldName) =>\n page.locator(`[data-testid^=\"list-cell:${resource}:\"][data-testid$=\":${fieldName}\"]`),\n columnMenu: (fieldName) => page.getByTestId(sortBase(fieldName)),\n columnSort: (fieldName, direction) =>\n page.getByTestId(`${sortBase(fieldName)}:sort-${direction}`),\n columnMove: (fieldName, direction) =>\n page.getByTestId(`${sortBase(fieldName)}:move-${direction}`),\n columnsToggle: () => page.getByTestId(`list-columns:${resource}`),\n columnsOption: (columnId) => page.getByTestId(`list-columns-option:${resource}:${columnId}`),\n pageSize: () => page.getByTestId(`list-page-size:${resource}`),\n search: () => page.getByTestId(`list-search:${resource}`),\n filtersContainer: () => page.getByTestId(`list-filters:${resource}`),\n filterButton: () => page.getByTestId(`${filterBase}:button`),\n filterPanel: () => page.getByTestId(`${filterBase}:panel`),\n filterAddRow: (groupPath = []) => page.getByTestId(`${groupBase(groupPath)}:add-row`),\n filterRowField: (rowPath) => page.getByTestId(`${rowBase(rowPath)}:field`),\n filterRowFieldOption: (rowPath, field) =>\n page.getByTestId(`${rowBase(rowPath)}:field:option:${field}`),\n filterRowOp: (rowPath) => page.getByTestId(`${rowBase(rowPath)}:op`),\n filterRowOpOption: (rowPath, op) => page.getByTestId(`${rowBase(rowPath)}:op:option:${op}`),\n filterRowValue: (rowPath) => page.getByTestId(`${rowBase(rowPath)}:value`),\n filterRowRemove: (rowPath) => page.getByTestId(`${rowBase(rowPath)}:remove`),\n filterApply: () => page.getByTestId(`${filterBase}:apply`),\n filterReset: () => page.getByTestId(`${filterBase}:reset`),\n filterBadge: (index) => page.getByTestId(`${filterBase}:badge:${index}`),\n filterBadgeRemove: (index) => page.getByTestId(`${filterBase}:badge:${index}:remove`),\n filterClearAll: () => page.getByTestId(`${filterBase}:clear-all`),\n filterPinned: (field) => page.getByTestId(`${filterBase}:pinned:${field}`),\n bulkBar: () => page.getByTestId(`list-bulk-bar:${resource}`),\n bulkAction: (actionName) => page.getByTestId(`bulk-action:${resource}:${actionName}`),\n paginationPrev: () => page.getByTestId(`list-pagination-prev:${resource}`),\n paginationNext: () => page.getByTestId(`list-pagination-next:${resource}`),\n // Deliberately unscoped — see the interface doc: the component's own id\n // carries no resource segment.\n loadMore: () => page.getByTestId('cursor-load-more'),\n empty: () => page.getByTestId(`list-empty:${resource}`),\n toolbar: () => page.getByTestId(`list-toolbar:${resource}`),\n table: () => page.getByTestId(`list-table:${resource}`),\n savedViews: () => page.getByTestId(`list-saved-views:${resource}`),\n savedViewsMenu: () => page.getByTestId(`list-saved-views-menu:${resource}`),\n // The trailing colon is load-bearing: it separates an ENTRY\n // (`list-saved-view:<r>:<uuid>`) from the trigger\n // (`list-saved-views:<r>`, plural) and from every sibling control\n // (`list-saved-view-save:<r>`, `list-saved-view-section:<r>:<id>`, …).\n savedViewEntries: () => page.locator(`[data-testid^=\"list-saved-view:${resource}:\"]`),\n savedView: (viewId) => page.getByTestId(`list-saved-view:${resource}:${viewId}`),\n savedViewSave: () => page.getByTestId(`list-saved-view-save:${resource}`),\n savedViewSaveInput: () => page.getByTestId(`list-saved-view-save-input:${resource}`),\n savedViewSaveConfirm: () => page.getByTestId(`list-saved-view-save-confirm:${resource}`),\n savedViewManage: () => page.getByTestId(`list-saved-view-manage:${resource}`),\n savedViewManageDelete: (viewId) =>\n page.getByTestId(`list-saved-view-manage-delete:${resource}:${viewId}`),\n };\n}\n\nfunction createRowObject<L extends ClickableLocator>(\n page: PlaywrightPage<L>,\n resource: string,\n recordId: string,\n): RowObject<L> {\n const rowId = `list-row:${resource}:${recordId}`;\n return {\n root: () => page.getByTestId(rowId),\n cell: (fieldName) => page.getByTestId(`list-cell:${resource}:${recordId}:${fieldName}`),\n action: (actionName) =>\n page.getByTestId(`list-row-action:${resource}:${recordId}:${actionName}`),\n checkbox: () => page.getByTestId(`list-row-checkbox:${resource}:${recordId}`),\n open: async () => {\n await page.getByTestId(rowId).click();\n },\n };\n}\n","import type { PlaywrightPage } from './types.js';\n\n/**\n * The relation picker's addresses, as a test drives them.\n *\n * ⚠️ **Every id here was wrong until SP9 task 5, and nothing could see it.**\n * This object used to ask for `picker-dialog:` / `picker-popover:` /\n * `picker-search:` / `picker-option:` / `picker-clear:` /\n * `picker-quickcreate:`, and `@burojs/react`'s `useTestIds()` composed exactly\n * the same six — while `@burojs/ui-shadcn`'s `<RelationPicker>` has always\n * emitted `relation-picker:<target>:…`. Neither vocabulary had a single\n * producer. The cross-check that exists to prevent this\n * (`examples/marketplace-ops/tests/page-object-vocabulary.test.tsx`) was green\n * throughout, because it compared these two copies with each other and neither\n * of them with the component. The producer side is pinned now, in\n * `packages/ui-shadcn/tests/relation-picker-testids.test.tsx`.\n *\n * `quickCreate` is not re-spelled but REMOVED: quick-create inside the picker\n * is deferred to the picker's v2 and there is no element to address. `popover`\n * is likewise gone — the popover content carries no id; {@link PickerObject.list}\n * is the element inside it that does.\n *\n * **`trigger()` is the standalone spelling.** Inside a `<ResourceForm>` the\n * form overrides the trigger's testid with its own\n * `form-field-input:<resource>:<view>:<field>`, so a form field's picker is\n * opened through `resource(r).create().field(f).input()` and only its popover\n * children are addressed through this object.\n */\nexport interface PickerObject<L> {\n /** The standalone trigger button. In a form, use the form field's `input()`. */\n trigger: () => L;\n /** The popover's search box. */\n search: () => L;\n /** The popover's result list. */\n list: () => L;\n /** One result row, by the target record's id. */\n option: (value: string) => L;\n /** The \"No results.\" row. */\n empty: () => L;\n /** The Clear link — present only when the picker is clearable and set. */\n clear: () => L;\n /** The \"Advanced search\" link that escalates to {@link PickerObject.dialog}. */\n advanced: () => L;\n /** The \"Open list\" link to the target's own list page. */\n viewAll: () => L;\n /** The escalation modal. */\n dialog: () => L;\n /** The escalation modal's own search box. */\n dialogSearch: () => L;\n /** The disabled trigger shown when the target resource is unregistered. */\n unregistered: () => L;\n}\n\nexport function createPickerObject<L>(\n page: PlaywrightPage<L>,\n targetResource: string,\n): PickerObject<L> {\n const prefix = `relation-picker:${targetResource}`;\n return {\n trigger: () => page.getByTestId(prefix),\n search: () => page.getByTestId(`${prefix}:search`),\n list: () => page.getByTestId(`${prefix}:list`),\n option: (value) => page.getByTestId(`${prefix}:option:${value}`),\n empty: () => page.getByTestId(`${prefix}:empty`),\n clear: () => page.getByTestId(`${prefix}:clear`),\n advanced: () => page.getByTestId(`${prefix}:advanced`),\n viewAll: () => page.getByTestId(`${prefix}:view-all`),\n dialog: () => page.getByTestId(`relation-picker-dialog:${targetResource}`),\n dialogSearch: () => page.getByTestId(`relation-picker-dialog:${targetResource}:search`),\n unregistered: () => page.getByTestId(`${prefix}:unregistered`),\n };\n}\n","import { type FormView, createFormView } from './form-view.js';\nimport type { ClickableLocator, PlaywrightPage } from './types.js';\n\nexport interface ShowView<L extends ClickableLocator> extends FormView<L> {\n /**\n * The show page root — `useTestIds`' `pageShow(resource, recordId)`.\n *\n * The ONE show accessor that uses the record id. Every other locator on this\n * view comes from `createFormView`, whose testids carry no record id at all\n * (see {@link import('./resource-object.js').ResourceObject.show}), so this\n * is what lets a test assert it is looking at the show page OF A GIVEN\n * RECORD rather than at whatever detail page happens to be mounted.\n */\n root: () => L;\n}\n\nexport function createShowView<L extends ClickableLocator>(\n page: PlaywrightPage<L>,\n resource: string,\n recordId: string,\n): ShowView<L> {\n return {\n ...createFormView(page, resource, 'show'),\n root: () => page.getByTestId(`page-show:${resource}:${recordId}`),\n };\n}\n","import { type FormView, createFormView } from './form-view.js';\nimport { type ListView, type ListViewOptions, createListView } from './list-view.js';\nimport { type PickerObject, createPickerObject } from './picker-object.js';\nimport { type ShowView, createShowView } from './show-view.js';\nimport type { ClickableLocator, PlaywrightPage } from './types.js';\n\nexport interface ResourceObject<L extends ClickableLocator> {\n name: string;\n list: (options?: ListViewOptions) => ListView<L>;\n /**\n * ⚠️ `recordId` is accepted and IGNORED: the form testids\n * (`form-field:<resource>:edit:<field>`) carry no record id, so an edit view\n * addressed for record A and one addressed for record B are the same\n * locators. Only one edit form is ever mounted at a time, so this is\n * harmless in practice — but the parameter reads like a scope and is not\n * one.\n */\n edit: (recordId: string) => FormView<L>;\n /**\n * ⚠️ `recordId` reaches {@link ShowView.root} and NOTHING ELSE. The show\n * view's field/section/action locators all come from `createFormView`, whose\n * testids carry no record id, so they are the same locators for record A and\n * record B. `root()` is the one accessor that is genuinely scoped to the\n * record — assert on it first if a test needs to know WHICH record's page is\n * mounted.\n */\n show: (recordId: string) => ShowView<L>;\n create: () => FormView<L>;\n picker: () => PickerObject<L>;\n}\n\nexport function createResourceObject<L extends ClickableLocator>(\n page: PlaywrightPage<L>,\n name: string,\n): ResourceObject<L> {\n return {\n name,\n list: (options) => createListView(page, name, options),\n edit: (_recordId) => createFormView(page, name, 'edit'),\n show: (recordId) => createShowView(page, name, recordId),\n create: () => createFormView(page, name, 'create'),\n picker: () => createPickerObject(page, name),\n };\n}\n","import { type ResourceObject, createResourceObject } from './resource-object.js';\nimport type { ClickableLocator, PlaywrightPage } from './types.js';\n\nexport interface BuroPage<L extends ClickableLocator> {\n resource: (name: string) => ResourceObject<L>;\n /**\n * The sidebar entry for a registered resource — `<NavMain>`'s\n * `tid.navMenuItem(resource)`. The whole navigation half of a suite used to\n * spell this string by hand even though the factory existed (SP3 stage C\n * final review, Important 2).\n */\n navMenuItem: (resource: string) => L;\n /** The sidebar entry for an adopter-declared custom page, keyed by page key\n * rather than resource name (`tid.sidebarCustomPage`). */\n sidebarCustomPage: (pageKey: string) => L;\n contextPicker: (sliceName: string) => L;\n /** One option inside an open context picker — the half that actually\n * performs a switch. Without it a caller can open the picker and not choose. */\n contextPickerOption: (sliceName: string, value: string) => L;\n /**\n * The header language switcher (`<LanguageSwitch>`) and one locale button\n * inside it. The trigger carries `data-language-state=\"<code>\"`, which is\n * how a test reads the ACTIVE locale without opening anything.\n */\n languageSwitch: () => L;\n languageSwitchOption: (localeCode: string) => L;\n /** `<LoginForm>`'s three controls, in the order a sign-in uses them. */\n login: () => LoginFormObject<L>;\n /**\n * `<TaskRunPanel>`'s three addressable parts, keyed by the RUN's name — a\n * task run has no resource to key on (a long server-side operation has no\n * record, a bulk run spans a selection).\n *\n * `root()` carries `data-state`, which is how a spec reads the outcome\n * without matching translated text.\n */\n taskRun: (name: string) => TaskRunObject<L>;\n notification: (type: 'success' | 'error' | 'progress' | 'info') => L;\n /**\n * ⚠️ Addresses the RECORD-LESS spelling only. `useTestIds`'\n * `actionConfirmDialog(resource, scope, action, recordId?)` appends the\n * record id when one is supplied, so a row-scoped confirmation renders a\n * testid this locator does not match.\n */\n actionConfirm: (resource: string, scope: string, actionName: string) => L;\n page: PlaywrightPage<L>;\n}\n\nexport interface TaskRunObject<L> {\n root: () => L;\n /** The bar; its `aria-valuenow`/`aria-valuemax` are the counts. */\n progress: () => L;\n /** One named unit that did not succeed — a refused row, or the step a run stopped at. */\n failure: (subject: string) => L;\n}\n\nexport interface LoginFormObject<L> {\n email: () => L;\n password: () => L;\n submit: () => L;\n}\n\nexport function createBuroPage<L extends ClickableLocator>(page: PlaywrightPage<L>): BuroPage<L> {\n return {\n resource: (name) => createResourceObject(page, name),\n navMenuItem: (resource) => page.getByTestId(`nav-menu-item:${resource}`),\n sidebarCustomPage: (pageKey) => page.getByTestId(`sidebar-custompage:${pageKey}`),\n contextPicker: (sliceName) => page.getByTestId(`context-picker:${sliceName}`),\n contextPickerOption: (sliceName, value) =>\n page.getByTestId(`context-picker-option:${sliceName}:${value}`),\n languageSwitch: () => page.getByTestId('language-switch'),\n languageSwitchOption: (localeCode) => page.getByTestId(`language-switch:${localeCode}`),\n login: () => ({\n email: () => page.getByTestId('login-email'),\n password: () => page.getByTestId('login-password'),\n submit: () => page.getByTestId('login-submit'),\n }),\n taskRun: (name) => ({\n root: () => page.getByTestId(`task-run:${name}`),\n progress: () => page.getByTestId(`task-run-progress:${name}`),\n failure: (subject) => page.getByTestId(`task-run-failure:${name}:${subject}`),\n }),\n notification: (type) => page.getByTestId(`notification:${type}`),\n actionConfirm: (resource, scope, actionName) =>\n page.getByTestId(`action-confirm-dialog:${resource}:${scope}:${actionName}`),\n page,\n };\n}\n","import type { PlaywrightPage } from './types.js';\n\nexport interface ConfirmDialogObject<L> {\n dialog: () => L;\n yes: () => L;\n no: () => L;\n}\n\nexport function createConfirmDialog<L>(\n page: PlaywrightPage<L>,\n resource: string,\n scope: string,\n actionName: string,\n): ConfirmDialogObject<L> {\n return {\n dialog: () => page.getByTestId(`action-confirm-dialog:${resource}:${scope}:${actionName}`),\n yes: () => page.getByTestId(`action-confirm-yes:${resource}:${scope}:${actionName}`),\n no: () => page.getByTestId(`action-confirm-no:${resource}:${scope}:${actionName}`),\n };\n}\n","/**\n * `@burojs/test-helpers` — the Playwright entry point.\n *\n * Everything exported here is loadable inside a `@playwright/test` process:\n * page objects over the testid convention `useTestIds()` (`@burojs/react`)\n * emits, and nothing else. The vitest-based provider contract moved to\n * `@burojs/test-helpers/provider-contract` — see that module for the measured\n * reason it can no longer share an entry point with these.\n */\n\n// Constants\nexport const PACKAGE_NAME = '@burojs/test-helpers' as const;\nexport const PACKAGE_VERSION = '0.0.0' as const;\n\n// Types\nexport type { PlaywrightPage, PlaywrightLocator, ClickableLocator } from './types.js';\n\n// Page object factories\nexport {\n createBuroPage,\n type BuroPage,\n type LoginFormObject,\n type TaskRunObject,\n} from './buro-page.js';\nexport { createResourceObject, type ResourceObject } from './resource-object.js';\nexport {\n createListView,\n type ListView,\n type RowObject,\n type ListViewOptions,\n type FilterRowPath,\n} from './list-view.js';\nexport { createFormView, type FormView, type FormFieldObject } from './form-view.js';\nexport { createShowView, type ShowView } from './show-view.js';\nexport { createPickerObject, type PickerObject } from './picker-object.js';\nexport { createConfirmDialog, type ConfirmDialogObject } from './confirm-dialog.js';\n"],"mappings":"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"}
1
+ {"version":3,"file":"index.cjs","names":[],"sources":["../src/form-view.ts","../src/list-view.ts","../src/picker-object.ts","../src/show-view.ts","../src/resource-object.ts","../src/buro-page.ts","../src/confirm-dialog.ts","../src/index.ts"],"sourcesContent":["import type { ClickableLocator, PlaywrightPage } from './types.js';\n\nexport interface FormFieldObject<L> {\n input: () => L;\n error: () => L;\n help: () => L;\n}\n\nexport interface FormView<L extends ClickableLocator> {\n field: (fieldName: string) => FormFieldObject<L>;\n submit: () => L;\n cancel: () => L;\n action: (actionName: string) => L;\n /**\n * A keyed node of the record screen (a panel, tabs, a step) by its key,\n * inside `[data-buro-region=\"<region>\"]` (default the body). Nodes carry no\n * test id of their own: the key and the region locate them.\n */\n node: (key: string, region?: 'record-header' | 'record-body') => L;\n}\n\nexport function createFormView<L extends ClickableLocator>(\n page: PlaywrightPage<L>,\n resource: string,\n view: 'create' | 'edit' | 'show',\n): FormView<L> {\n return {\n field: (fieldName) => {\n page.getByTestId(`form-field:${resource}:${view}:${fieldName}`);\n return {\n input: () => page.getByTestId(`form-field-input:${resource}:${view}:${fieldName}`),\n error: () => page.getByTestId(`form-field-error:${resource}:${view}:${fieldName}`),\n help: () => page.getByTestId(`form-field-help:${resource}:${view}:${fieldName}`),\n };\n },\n submit: () => page.getByTestId(`form-submit:${resource}:${view}`),\n cancel: () => page.getByTestId(`form-cancel:${resource}:${view}`),\n action: (actionName) => page.getByTestId(`form-action:${resource}:${view}:${actionName}`),\n node: (key, region = 'record-body') =>\n page.locator(`[data-buro-region=\"${region}\"] [data-buro-key=\"${key}\"]`),\n };\n}\n","import type { ClickableLocator, PlaywrightPage } from './types.js';\n\n/**\n * A row's address inside the filter-builder draft: `[]` is the root group,\n * `[0]` is its first row, `[1, 0]` is the first row of the group nested at\n * top-level index 1. Mirrors `DraftPath` in\n * `packages/ui-shadcn/src/list-kit/filter-builder/draft.ts` — the testid\n * suffix is `path.join('-')` (an empty path renders as the empty string, so\n * the root group's own testid carries a trailing `:`, e.g.\n * `list-filter:<r>:group::add-row`).\n */\nexport type FilterRowPath = readonly number[];\n\nexport interface ListView<L extends ClickableLocator> {\n goto: () => Promise<void>;\n row: (recordId: string) => RowObject<L>;\n /**\n * EVERY rendered row of this resource, as one locator — the set, not a\n * named member of it. `row(recordId)` cannot express it: a row's testid\n * embeds its record id, so the set is only addressable by prefix.\n *\n * This is the accessor a list assertion is actually built on\n * (`await expect(list.rows()).toHaveCount(n)`), and it was missing for as\n * long as the only consumer of this package was a mock that never counted\n * anything.\n */\n rows: () => L;\n /**\n * One column across every rendered row, in row order — again a set, again\n * prefix-addressed. Reading a whole column through ONE locator matters for\n * more than convenience: a per-row loop awaits between reads and can meet a\n * detached node mid-re-render, which turns a polling wait into a thrown\n * error instead of another attempt.\n */\n column: (fieldName: string) => L;\n /** The column header's menu trigger (sort / pin / hide / move). */\n columnMenu: (fieldName: string) => L;\n /** The sort item inside an OPEN column header menu. */\n columnSort: (fieldName: string, direction: 'asc' | 'desc') => L;\n /**\n * The reorder item inside an OPEN column header menu — moves the column one\n * position left or right.\n *\n * ⚠️ What it moves is **column layout**, which is a different persistence\n * mechanism from everything else on this object: order, width and pinning\n * live in raw `localStorage` under `buro:table-layout:<resource>:{order,\n * sizing,pinning}` and are carried by neither the URL nor a saved view. See\n * {@link ListView.savedViews}.\n */\n columnMove: (fieldName: string, direction: 'left' | 'right') => L;\n /** The Columns menu trigger — show/hide columns, reset widths, restore defaults. */\n columnsToggle: () => L;\n /**\n * One column's row inside the OPEN Columns menu. A column the resource\n * declares in `views.list.columns` is REQUIRED unless\n * `views.list.requiredColumns` says otherwise, and renders `disabled` with\n * `data-required=\"true\"` — only `optionalColumns` are actually clickable.\n */\n columnsOption: (columnId: string) => L;\n /** The rows-per-page select trigger; its text is the active page size. */\n pageSize: () => L;\n search: () => L;\n // ── Filter builder (SP2) ─────────────────────────────────────────────\n // The chip toolbar this file used to model (`list-dyn-filter:*`,\n // `list-add-filter:*`) is gone — SP2 replaced it with a popover panel:\n // open the panel, add a row, pick a field/operator/value, Apply. Applied\n // rows then render as removable badges OUTSIDE the panel. See\n // `packages/ui-shadcn/src/list-kit/filter-builder/*` for the components\n // these testids come from.\n /** The filter region container — `list-filters:<resource>`. */\n filtersContainer: () => L;\n /** Opens/closes the filter-builder popover. */\n filterButton: () => L;\n /** The popover body once open. */\n filterPanel: () => L;\n /** \"+ Filter\" — appends a new predicate row to the group at `groupPath` (root: `[]`). */\n filterAddRow: (groupPath?: FilterRowPath) => L;\n /** Field-select trigger for the row at `path`. */\n filterRowField: (path: FilterRowPath) => L;\n /** One field option inside an open field-select. */\n filterRowFieldOption: (path: FilterRowPath, field: string) => L;\n /** Operator-select trigger for the row at `path`. */\n filterRowOp: (path: FilterRowPath) => L;\n /** One operator option inside an open operator-select. */\n filterRowOpOption: (path: FilterRowPath, op: string) => L;\n /** Value control trigger/input for the row at `path` — shape depends on the field kind (text box, enum/relation/boolean popover). */\n filterRowValue: (path: FilterRowPath) => L;\n /** Removes the row at `path` from the draft (inside the panel, before Apply). */\n filterRowRemove: (path: FilterRowPath) => L;\n /** Commits the draft — applies it and closes the panel. */\n filterApply: () => L;\n /** Clears the draft back to empty (still requires Apply to take effect). */\n filterReset: () => L;\n /** One applied-filter badge outside the panel, by its top-level index. */\n filterBadge: (index: number) => L;\n /** A badge's × — removes that row immediately, no Apply needed. */\n filterBadgeRemove: (index: number) => L;\n /** \"Clear all\" — empties both the pinned slot and the builder group. */\n filterClearAll: () => L;\n /** An always-visible pinned control (`views.list.filters.pinned`) for `field`, rendered outside the panel. */\n filterPinned: (field: string) => L;\n /**\n * The floating bulk bar, which appears as soon as ANY row is selected —\n * even for a resource that declares no bulk action, because it also carries\n * the built-in Export entry and the selection count.\n */\n bulkBar: () => L;\n /** One declared bulk action's button inside the bar (`actions.bulk`). */\n bulkAction: (actionName: string) => L;\n paginationPrev: () => L;\n paginationNext: () => L;\n /**\n * The load-more button a CURSOR-paginated list renders instead of the\n * numbered pager (`views.list.pagination: 'cursor'` → `<CursorLoadMore>`).\n *\n * ⚠️ NOT resource-scoped, because the control itself is not: `CursorLoadMore`\n * carries a bare `data-testid=\"cursor-load-more\"` with no resource segment,\n * unlike every other id above. A page rendering two cursor lists at once\n * would need `.first()`/`.nth()` to tell them apart. Recorded rather than\n * papered over — inventing a scoped id here would only make this object\n * disagree with the component.\n *\n * ⚠️ `<CursorLoadMore>` renders NOTHING when there is no next page, so the\n * end of a cursor list is \"this locator resolves to no element\", not a\n * disabled button. That is what makes it usable as an end-of-list assertion.\n *\n * Like `columnMenu`/`columnSort`, this id has no `useTestIds` factory behind\n * it, so `page-object-vocabulary` tests have nothing to check it against\n * (the pre-existing F6 gap — see the saved-views block below for what that\n * costs, measured).\n */\n loadMore: () => L;\n empty: () => L;\n toolbar: () => L;\n table: () => L;\n // ── Saved views ──────────────────────────────────────────────────────\n // `<ResourceListPage>` renders the Views dropdown UNCONDITIONALLY, and\n // `<BuroProvider>` mounts a localStorage-backed settings store when the\n // adopter supplies none — so \"Save current view…\" is on every list screen\n // of every app built on this framework, whether or not anyone declared\n // `views.list.savedViews`. Until these accessors existed, no page object\n // could address any of it: the only browser tests that ever tried\n // (the since-deleted `apps/playground`'s saved-views spec) spelled the ids by\n // hand, and marked six of their eleven tests `test.fixme` — including both\n // round-trips.\n //\n // ⚠️ **A view carries exactly six keys** — `filters`, `filterGroup`,\n // `sort`, `pageSize`, `search`, `visibleColumns`\n // (`SavedViewSettingValue`). NOT the page number (applying a view always\n // opens page one), NOT the selection, and NOT column layout: width, order\n // and pinning are a separate store (see {@link ListView.columnMove}), so a\n // view restores WHICH columns are shown and never HOW WIDE or in what\n // order.\n //\n // ⚠️ **NONE OF THE THIRTEEN ACCESSORS ADDED WITH THIS BLOCK HAS A\n // `useTestIds` FACTORY** (saved views, the Columns menu, the page-size\n // select and `columnMove`), so `page-object-vocabulary.test.tsx` has nothing\n // to compare them against — the F6 gap again, with thirteen more instances.\n // SP5 task 8 settled it as: NOT closed, and here is the measured cost, so\n // that the next person decides on numbers rather than on the word \"gap\".\n //\n // Both sides are independently pinned, just never against each other:\n // `tests/buro-page.test.ts` pins the string THIS file hands out, and on the\n // component side eight of the thirteen ids are spelled as literals in\n // `packages/ui-shadcn/tests/*` while ten of the thirteen accessors are\n // driven by `examples/marketplace-ops`' browser suite. So a rename in\n // `@burojs/ui-shadcn` reddens something in every case **but one**:\n //\n // `savedViewsMenu` (`list-saved-views-menu:<r>`) is spelled by no package\n // test and addressed by no browser spec, so renaming it in\n // `resource-list-page.tsx` is invisible everywhere and this accessor would\n // quietly point at nothing.\n //\n // Closing the family properly means ~13 new factories on the public\n // `TestIdBuilder` plus rewiring `resource-list-page.tsx`,\n // `columns-toggle.tsx` and `resource-list.tsx` to compose through them —\n // a public-surface decision, not a test fix, and it covers\n // `columnMenu`/`columnSort`/`columnMove` and `loadMore` at the same time.\n /** The Views dropdown trigger in the list toolbar. */\n savedViews: () => L;\n /** The dropdown body once open. */\n savedViewsMenu: () => L;\n /**\n * EVERY view entry in the open dropdown (adopter presets and user-saved\n * alike), as one locator — the set, not a named member of it, because a\n * user view's id is a uuid minted at save time and no test can know it in\n * advance. Prefix-addressed for the same reason {@link ListView.rows} is.\n */\n savedViewEntries: () => L;\n /** One view entry by id — for an adopter preset declared in `views.list.savedViews`, whose id IS known. */\n savedView: (viewId: string) => L;\n /** \"Save current view…\" — opens the save dialog. */\n savedViewSave: () => L;\n /** The label input inside the open save dialog. */\n savedViewSaveInput: () => L;\n /** The save dialog's confirm button; disabled while the label is empty. */\n savedViewSaveConfirm: () => L;\n /**\n * \"Manage views…\" — opens the manage dialog. Rendered only when at least\n * one entry exists, so `toHaveCount(0)` is a usable \"this browser has no\n * saved views\" assertion.\n */\n savedViewManage: () => L;\n /** One entry's Delete button inside the open manage dialog. */\n savedViewManageDelete: (viewId: string) => L;\n}\n\nexport interface RowObject<L extends ClickableLocator> {\n /**\n * The row element itself. Without it a caller cannot say \"this row is on\n * screen\" or \"this row is gone\" — the two assertions a list test needs\n * most, and the ones `cell()`/`action()` cannot stand in for (a row can be\n * present while a given cell or action is not).\n */\n root: () => L;\n cell: (fieldName: string) => L;\n action: (actionName: string) => L;\n checkbox: () => L;\n open: () => Promise<void>;\n}\n\nexport interface ListViewOptions {\n /**\n * What {@link ListView.goto} navigates to. Defaults to `/<resource>`, which\n * assumes the route is named after the resource — often false (a `order`\n * resource routed at `/orders`), and it carries no query string, so an app\n * whose test isolation travels on the URL has to pass its whole launch URL\n * here.\n */\n basePath?: string;\n}\n\n/** `path.join('-')` — see `FilterRowPath`'s doc for the empty-path edge case. */\nfunction pathStr(path: FilterRowPath): string {\n return path.join('-');\n}\n\nexport function createListView<L extends ClickableLocator>(\n page: PlaywrightPage<L>,\n resource: string,\n options: ListViewOptions = {},\n): ListView<L> {\n const path = options.basePath ?? `/${resource}`;\n const filterBase = `list-filter:${resource}`;\n const sortBase = (field: string) => `list-sort:${resource}:${field}`;\n const rowBase = (rowPath: FilterRowPath) => `${filterBase}:row:${pathStr(rowPath)}`;\n const groupBase = (groupPath: FilterRowPath) => `${filterBase}:group:${pathStr(groupPath)}`;\n return {\n goto: async () => {\n await page.goto(path);\n },\n row: (recordId) => createRowObject(page, resource, recordId),\n rows: () => page.locator(`[data-testid^=\"list-row:${resource}:\"]`),\n column: (fieldName) =>\n page.locator(`[data-testid^=\"list-cell:${resource}:\"][data-testid$=\":${fieldName}\"]`),\n columnMenu: (fieldName) => page.getByTestId(sortBase(fieldName)),\n columnSort: (fieldName, direction) =>\n page.getByTestId(`${sortBase(fieldName)}:sort-${direction}`),\n columnMove: (fieldName, direction) =>\n page.getByTestId(`${sortBase(fieldName)}:move-${direction}`),\n columnsToggle: () => page.getByTestId(`list-columns:${resource}`),\n columnsOption: (columnId) => page.getByTestId(`list-columns-option:${resource}:${columnId}`),\n pageSize: () => page.getByTestId(`list-page-size:${resource}`),\n search: () => page.getByTestId(`list-search:${resource}`),\n filtersContainer: () => page.getByTestId(`list-filters:${resource}`),\n filterButton: () => page.getByTestId(`${filterBase}:button`),\n filterPanel: () => page.getByTestId(`${filterBase}:panel`),\n filterAddRow: (groupPath = []) => page.getByTestId(`${groupBase(groupPath)}:add-row`),\n filterRowField: (rowPath) => page.getByTestId(`${rowBase(rowPath)}:field`),\n filterRowFieldOption: (rowPath, field) =>\n page.getByTestId(`${rowBase(rowPath)}:field:option:${field}`),\n filterRowOp: (rowPath) => page.getByTestId(`${rowBase(rowPath)}:op`),\n filterRowOpOption: (rowPath, op) => page.getByTestId(`${rowBase(rowPath)}:op:option:${op}`),\n filterRowValue: (rowPath) => page.getByTestId(`${rowBase(rowPath)}:value`),\n filterRowRemove: (rowPath) => page.getByTestId(`${rowBase(rowPath)}:remove`),\n filterApply: () => page.getByTestId(`${filterBase}:apply`),\n filterReset: () => page.getByTestId(`${filterBase}:reset`),\n filterBadge: (index) => page.getByTestId(`${filterBase}:badge:${index}`),\n filterBadgeRemove: (index) => page.getByTestId(`${filterBase}:badge:${index}:remove`),\n filterClearAll: () => page.getByTestId(`${filterBase}:clear-all`),\n filterPinned: (field) => page.getByTestId(`${filterBase}:pinned:${field}`),\n bulkBar: () => page.getByTestId(`list-bulk-bar:${resource}`),\n bulkAction: (actionName) => page.getByTestId(`bulk-action:${resource}:${actionName}`),\n paginationPrev: () => page.getByTestId(`list-pagination-prev:${resource}`),\n paginationNext: () => page.getByTestId(`list-pagination-next:${resource}`),\n // Deliberately unscoped — see the interface doc: the component's own id\n // carries no resource segment.\n loadMore: () => page.getByTestId('cursor-load-more'),\n empty: () => page.getByTestId(`list-empty:${resource}`),\n toolbar: () => page.getByTestId(`list-toolbar:${resource}`),\n table: () => page.getByTestId(`list-table:${resource}`),\n savedViews: () => page.getByTestId(`list-saved-views:${resource}`),\n savedViewsMenu: () => page.getByTestId(`list-saved-views-menu:${resource}`),\n // The trailing colon is load-bearing: it separates an ENTRY\n // (`list-saved-view:<r>:<uuid>`) from the trigger\n // (`list-saved-views:<r>`, plural) and from every sibling control\n // (`list-saved-view-save:<r>`, `list-saved-view-section:<r>:<id>`, …).\n savedViewEntries: () => page.locator(`[data-testid^=\"list-saved-view:${resource}:\"]`),\n savedView: (viewId) => page.getByTestId(`list-saved-view:${resource}:${viewId}`),\n savedViewSave: () => page.getByTestId(`list-saved-view-save:${resource}`),\n savedViewSaveInput: () => page.getByTestId(`list-saved-view-save-input:${resource}`),\n savedViewSaveConfirm: () => page.getByTestId(`list-saved-view-save-confirm:${resource}`),\n savedViewManage: () => page.getByTestId(`list-saved-view-manage:${resource}`),\n savedViewManageDelete: (viewId) =>\n page.getByTestId(`list-saved-view-manage-delete:${resource}:${viewId}`),\n };\n}\n\nfunction createRowObject<L extends ClickableLocator>(\n page: PlaywrightPage<L>,\n resource: string,\n recordId: string,\n): RowObject<L> {\n const rowId = `list-row:${resource}:${recordId}`;\n return {\n root: () => page.getByTestId(rowId),\n cell: (fieldName) => page.getByTestId(`list-cell:${resource}:${recordId}:${fieldName}`),\n action: (actionName) =>\n page.getByTestId(`list-row-action:${resource}:${recordId}:${actionName}`),\n checkbox: () => page.getByTestId(`list-row-checkbox:${resource}:${recordId}`),\n open: async () => {\n await page.getByTestId(rowId).click();\n },\n };\n}\n","import type { PlaywrightPage } from './types.js';\n\n/**\n * The relation picker's addresses, as a test drives them.\n *\n * ⚠️ **Every id here was wrong until SP9 task 5, and nothing could see it.**\n * This object used to ask for `picker-dialog:` / `picker-popover:` /\n * `picker-search:` / `picker-option:` / `picker-clear:` /\n * `picker-quickcreate:`, and `@burojs/react`'s `useTestIds()` composed exactly\n * the same six — while `@burojs/ui-shadcn`'s `<RelationPicker>` has always\n * emitted `relation-picker:<target>:…`. Neither vocabulary had a single\n * producer. The cross-check that exists to prevent this\n * (`examples/marketplace-ops/tests/page-object-vocabulary.test.tsx`) was green\n * throughout, because it compared these two copies with each other and neither\n * of them with the component. The producer side is pinned now, in\n * `packages/ui-shadcn/tests/relation-picker-testids.test.tsx`.\n *\n * `quickCreate` is not re-spelled but REMOVED: quick-create inside the picker\n * is deferred to the picker's v2 and there is no element to address. `popover`\n * is likewise gone — the popover content carries no id; {@link PickerObject.list}\n * is the element inside it that does.\n *\n * **`trigger()` is the standalone spelling.** Inside a record form the\n * form overrides the trigger's testid with its own\n * `form-field-input:<resource>:<view>:<field>`, so a form field's picker is\n * opened through `resource(r).create().field(f).input()` and only its popover\n * children are addressed through this object.\n */\nexport interface PickerObject<L> {\n /** The standalone trigger button. In a form, use the form field's `input()`. */\n trigger: () => L;\n /** The popover's search box. */\n search: () => L;\n /** The popover's result list. */\n list: () => L;\n /** One result row, by the target record's id. */\n option: (value: string) => L;\n /** The \"No results.\" row. */\n empty: () => L;\n /** The Clear link — present only when the picker is clearable and set. */\n clear: () => L;\n /** The \"Advanced search\" link that escalates to {@link PickerObject.dialog}. */\n advanced: () => L;\n /** The \"Open list\" link to the target's own list page. */\n viewAll: () => L;\n /** The escalation modal. */\n dialog: () => L;\n /** The escalation modal's own search box. */\n dialogSearch: () => L;\n /** The disabled trigger shown when the target resource is unregistered. */\n unregistered: () => L;\n}\n\nexport function createPickerObject<L>(\n page: PlaywrightPage<L>,\n targetResource: string,\n): PickerObject<L> {\n const prefix = `relation-picker:${targetResource}`;\n return {\n trigger: () => page.getByTestId(prefix),\n search: () => page.getByTestId(`${prefix}:search`),\n list: () => page.getByTestId(`${prefix}:list`),\n option: (value) => page.getByTestId(`${prefix}:option:${value}`),\n empty: () => page.getByTestId(`${prefix}:empty`),\n clear: () => page.getByTestId(`${prefix}:clear`),\n advanced: () => page.getByTestId(`${prefix}:advanced`),\n viewAll: () => page.getByTestId(`${prefix}:view-all`),\n dialog: () => page.getByTestId(`relation-picker-dialog:${targetResource}`),\n dialogSearch: () => page.getByTestId(`relation-picker-dialog:${targetResource}:search`),\n unregistered: () => page.getByTestId(`${prefix}:unregistered`),\n };\n}\n","import { type FormView, createFormView } from './form-view.js';\nimport type { ClickableLocator, PlaywrightPage } from './types.js';\n\nexport interface ShowView<L extends ClickableLocator> extends FormView<L> {\n /**\n * The show page root — `useTestIds`' `pageShow(resource, recordId)`.\n *\n * The ONE show accessor that uses the record id. Every other locator on this\n * view comes from `createFormView`, whose testids carry no record id at all\n * (see {@link import('./resource-object.js').ResourceObject.show}), so this\n * is what lets a test assert it is looking at the show page OF A GIVEN\n * RECORD rather than at whatever detail page happens to be mounted.\n */\n root: () => L;\n}\n\nexport function createShowView<L extends ClickableLocator>(\n page: PlaywrightPage<L>,\n resource: string,\n recordId: string,\n): ShowView<L> {\n return {\n ...createFormView(page, resource, 'show'),\n root: () => page.getByTestId(`page-show:${resource}:${recordId}`),\n };\n}\n","import { type FormView, createFormView } from './form-view.js';\nimport { type ListView, type ListViewOptions, createListView } from './list-view.js';\nimport { type PickerObject, createPickerObject } from './picker-object.js';\nimport { type ShowView, createShowView } from './show-view.js';\nimport type { ClickableLocator, PlaywrightPage } from './types.js';\n\nexport interface ResourceObject<L extends ClickableLocator> {\n name: string;\n list: (options?: ListViewOptions) => ListView<L>;\n /**\n * ⚠️ `recordId` is accepted and IGNORED: the form testids\n * (`form-field:<resource>:edit:<field>`) carry no record id, so an edit view\n * addressed for record A and one addressed for record B are the same\n * locators. Only one edit form is ever mounted at a time, so this is\n * harmless in practice — but the parameter reads like a scope and is not\n * one.\n */\n edit: (recordId: string) => FormView<L>;\n /**\n * ⚠️ `recordId` reaches {@link ShowView.root} and NOTHING ELSE. The show\n * view's field/section/action locators all come from `createFormView`, whose\n * testids carry no record id, so they are the same locators for record A and\n * record B. `root()` is the one accessor that is genuinely scoped to the\n * record — assert on it first if a test needs to know WHICH record's page is\n * mounted.\n */\n show: (recordId: string) => ShowView<L>;\n create: () => FormView<L>;\n picker: () => PickerObject<L>;\n}\n\nexport function createResourceObject<L extends ClickableLocator>(\n page: PlaywrightPage<L>,\n name: string,\n): ResourceObject<L> {\n return {\n name,\n list: (options) => createListView(page, name, options),\n edit: (_recordId) => createFormView(page, name, 'edit'),\n show: (recordId) => createShowView(page, name, recordId),\n create: () => createFormView(page, name, 'create'),\n picker: () => createPickerObject(page, name),\n };\n}\n","import { type ResourceObject, createResourceObject } from './resource-object.js';\nimport type { ClickableLocator, PlaywrightPage } from './types.js';\n\nexport interface BuroPage<L extends ClickableLocator> {\n resource: (name: string) => ResourceObject<L>;\n /**\n * The sidebar entry for a registered resource — `<NavMain>`'s\n * `tid.navMenuItem(resource)`. The whole navigation half of a suite used to\n * spell this string by hand even though the factory existed (SP3 stage C\n * final review, Important 2).\n */\n navMenuItem: (resource: string) => L;\n /** The sidebar entry for an adopter-declared custom page, keyed by page key\n * rather than resource name (`tid.sidebarCustomPage`). */\n sidebarCustomPage: (pageKey: string) => L;\n contextPicker: (sliceName: string) => L;\n /** One option inside an open context picker — the half that actually\n * performs a switch. Without it a caller can open the picker and not choose. */\n contextPickerOption: (sliceName: string, value: string) => L;\n /**\n * The header language switcher (`<LanguageSwitch>`) and one locale button\n * inside it. The trigger carries `data-language-state=\"<code>\"`, which is\n * how a test reads the ACTIVE locale without opening anything.\n */\n languageSwitch: () => L;\n languageSwitchOption: (localeCode: string) => L;\n /** `<LoginForm>`'s three controls, in the order a sign-in uses them. */\n login: () => LoginFormObject<L>;\n /**\n * `<TaskRunPanel>`'s three addressable parts, keyed by the RUN's name — a\n * task run has no resource to key on (a long server-side operation has no\n * record, a bulk run spans a selection).\n *\n * `root()` carries `data-state`, which is how a spec reads the outcome\n * without matching translated text.\n */\n taskRun: (name: string) => TaskRunObject<L>;\n notification: (type: 'success' | 'error' | 'progress' | 'info') => L;\n /**\n * A flow's confirm dialog on a toolbar surface (`form-action:<resource>:<scope>:<action>`): the\n * dialog a flow with `confirm` (or a fired warning) asks through before it writes (P4). Its\n * buttons are the same id with `:proceed` / `:cancel` in place of `:dialog`. A row's flow asks\n * through `list-row-action:<resource>:<id>:<action>:confirm:dialog`.\n */\n actionConfirm: (resource: string, scope: string, actionName: string) => L;\n page: PlaywrightPage<L>;\n}\n\nexport interface TaskRunObject<L> {\n root: () => L;\n /** The bar; its `aria-valuenow`/`aria-valuemax` are the counts. */\n progress: () => L;\n /** One named unit that did not succeed — a refused row, or the step a run stopped at. */\n failure: (subject: string) => L;\n}\n\nexport interface LoginFormObject<L> {\n email: () => L;\n password: () => L;\n submit: () => L;\n}\n\nexport function createBuroPage<L extends ClickableLocator>(page: PlaywrightPage<L>): BuroPage<L> {\n return {\n resource: (name) => createResourceObject(page, name),\n navMenuItem: (resource) => page.getByTestId(`nav-menu-item:${resource}`),\n sidebarCustomPage: (pageKey) => page.getByTestId(`sidebar-custompage:${pageKey}`),\n contextPicker: (sliceName) => page.getByTestId(`context-picker:${sliceName}`),\n contextPickerOption: (sliceName, value) =>\n page.getByTestId(`context-picker-option:${sliceName}:${value}`),\n languageSwitch: () => page.getByTestId('language-switch'),\n languageSwitchOption: (localeCode) => page.getByTestId(`language-switch:${localeCode}`),\n login: () => ({\n email: () => page.getByTestId('login-email'),\n password: () => page.getByTestId('login-password'),\n submit: () => page.getByTestId('login-submit'),\n }),\n taskRun: (name) => ({\n root: () => page.getByTestId(`task-run:${name}`),\n progress: () => page.getByTestId(`task-run-progress:${name}`),\n failure: (subject) => page.getByTestId(`task-run-failure:${name}:${subject}`),\n }),\n notification: (type) => page.getByTestId(`notification:${type}`),\n actionConfirm: (resource, scope, actionName) =>\n page.getByTestId(`form-action:${resource}:${scope}:${actionName}:confirm:dialog`),\n page,\n };\n}\n","import type { PlaywrightPage } from './types.js';\n\nexport interface ConfirmDialogObject<L> {\n dialog: () => L;\n yes: () => L;\n no: () => L;\n}\n\nexport function createConfirmDialog<L>(\n page: PlaywrightPage<L>,\n resource: string,\n scope: string,\n actionName: string,\n): ConfirmDialogObject<L> {\n return {\n dialog: () => page.getByTestId(`action-confirm-dialog:${resource}:${scope}:${actionName}`),\n yes: () => page.getByTestId(`action-confirm-yes:${resource}:${scope}:${actionName}`),\n no: () => page.getByTestId(`action-confirm-no:${resource}:${scope}:${actionName}`),\n };\n}\n","/**\n * `@burojs/test-helpers` — the Playwright entry point.\n *\n * Everything exported here is loadable inside a `@playwright/test` process:\n * page objects over the testid convention `useTestIds()` (`@burojs/react`)\n * emits, and nothing else. The vitest-based provider contract moved to\n * `@burojs/test-helpers/provider-contract` — see that module for the measured\n * reason it can no longer share an entry point with these.\n */\n\n// Constants\nexport const PACKAGE_NAME = '@burojs/test-helpers' as const;\nexport const PACKAGE_VERSION = '0.0.0' as const;\n\n// Types\nexport type { PlaywrightPage, PlaywrightLocator, ClickableLocator } from './types.js';\n\n// Page object factories\nexport {\n createBuroPage,\n type BuroPage,\n type LoginFormObject,\n type TaskRunObject,\n} from './buro-page.js';\nexport { createResourceObject, type ResourceObject } from './resource-object.js';\nexport {\n createListView,\n type ListView,\n type RowObject,\n type ListViewOptions,\n type FilterRowPath,\n} from './list-view.js';\nexport { createFormView, type FormView, type FormFieldObject } from './form-view.js';\nexport { createShowView, type ShowView } from './show-view.js';\nexport { createPickerObject, type PickerObject } from './picker-object.js';\nexport { createConfirmDialog, type ConfirmDialogObject } from './confirm-dialog.js';\n"],"mappings":"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"}
package/dist/index.d.cts CHANGED
@@ -71,18 +71,15 @@ interface FormFieldObject<L> {
71
71
  }
72
72
  interface FormView<L extends ClickableLocator> {
73
73
  field: (fieldName: string) => FormFieldObject<L>;
74
- /**
75
- * One declared field GROUP.
76
- *
77
- * ⚠️ Renamed from `section()` at SP9 task 5: it asked for `form-section:…`
78
- * and `<ResourceForm>` has always rendered `form-group:…`, so it had never
79
- * matched an element. See `src/picker-object.ts` for the wider sweep and for
80
- * why the cross-vocabulary test could not see any of it.
81
- */
82
- group: (groupKey: string) => L;
83
74
  submit: () => L;
84
75
  cancel: () => L;
85
76
  action: (actionName: string) => L;
77
+ /**
78
+ * A keyed node of the record screen (a panel, tabs, a step) by its key,
79
+ * inside `[data-buro-region="<region>"]` (default the body). Nodes carry no
80
+ * test id of their own: the key and the region locate them.
81
+ */
82
+ node: (key: string, region?: 'record-header' | 'record-body') => L;
86
83
  }
87
84
  declare function createFormView<L extends ClickableLocator>(page: PlaywrightPage<L>, resource: string, view: 'create' | 'edit' | 'show'): FormView<L>;
88
85
  //#endregion
@@ -286,7 +283,7 @@ declare function createListView<L extends ClickableLocator>(page: PlaywrightPage
286
283
  * is likewise gone — the popover content carries no id; {@link PickerObject.list}
287
284
  * is the element inside it that does.
288
285
  *
289
- * **`trigger()` is the standalone spelling.** Inside a `<ResourceForm>` the
286
+ * **`trigger()` is the standalone spelling.** Inside a record form the
290
287
  * form overrides the trigger's testid with its own
291
288
  * `form-field-input:<resource>:<view>:<field>`, so a form field's picker is
292
289
  * opened through `resource(r).create().field(f).input()` and only its popover
@@ -397,10 +394,10 @@ interface BuroPage<L extends ClickableLocator> {
397
394
  taskRun: (name: string) => TaskRunObject<L>;
398
395
  notification: (type: 'success' | 'error' | 'progress' | 'info') => L;
399
396
  /**
400
- * ⚠️ Addresses the RECORD-LESS spelling only. `useTestIds`'
401
- * `actionConfirmDialog(resource, scope, action, recordId?)` appends the
402
- * record id when one is supplied, so a row-scoped confirmation renders a
403
- * testid this locator does not match.
397
+ * A flow's confirm dialog on a toolbar surface (`form-action:<resource>:<scope>:<action>`): the
398
+ * dialog a flow with `confirm` (or a fired warning) asks through before it writes (P4). Its
399
+ * buttons are the same id with `:proceed` / `:cancel` in place of `:dialog`. A row's flow asks
400
+ * through `list-row-action:<resource>:<id>:<action>:confirm:dialog`.
404
401
  */
405
402
  actionConfirm: (resource: string, scope: string, actionName: string) => L;
406
403
  page: PlaywrightPage<L>;
package/dist/index.d.ts CHANGED
@@ -71,18 +71,15 @@ interface FormFieldObject<L> {
71
71
  }
72
72
  interface FormView<L extends ClickableLocator> {
73
73
  field: (fieldName: string) => FormFieldObject<L>;
74
- /**
75
- * One declared field GROUP.
76
- *
77
- * ⚠️ Renamed from `section()` at SP9 task 5: it asked for `form-section:…`
78
- * and `<ResourceForm>` has always rendered `form-group:…`, so it had never
79
- * matched an element. See `src/picker-object.ts` for the wider sweep and for
80
- * why the cross-vocabulary test could not see any of it.
81
- */
82
- group: (groupKey: string) => L;
83
74
  submit: () => L;
84
75
  cancel: () => L;
85
76
  action: (actionName: string) => L;
77
+ /**
78
+ * A keyed node of the record screen (a panel, tabs, a step) by its key,
79
+ * inside `[data-buro-region="<region>"]` (default the body). Nodes carry no
80
+ * test id of their own: the key and the region locate them.
81
+ */
82
+ node: (key: string, region?: 'record-header' | 'record-body') => L;
86
83
  }
87
84
  declare function createFormView<L extends ClickableLocator>(page: PlaywrightPage<L>, resource: string, view: 'create' | 'edit' | 'show'): FormView<L>;
88
85
  //#endregion
@@ -286,7 +283,7 @@ declare function createListView<L extends ClickableLocator>(page: PlaywrightPage
286
283
  * is likewise gone — the popover content carries no id; {@link PickerObject.list}
287
284
  * is the element inside it that does.
288
285
  *
289
- * **`trigger()` is the standalone spelling.** Inside a `<ResourceForm>` the
286
+ * **`trigger()` is the standalone spelling.** Inside a record form the
290
287
  * form overrides the trigger's testid with its own
291
288
  * `form-field-input:<resource>:<view>:<field>`, so a form field's picker is
292
289
  * opened through `resource(r).create().field(f).input()` and only its popover
@@ -397,10 +394,10 @@ interface BuroPage<L extends ClickableLocator> {
397
394
  taskRun: (name: string) => TaskRunObject<L>;
398
395
  notification: (type: 'success' | 'error' | 'progress' | 'info') => L;
399
396
  /**
400
- * ⚠️ Addresses the RECORD-LESS spelling only. `useTestIds`'
401
- * `actionConfirmDialog(resource, scope, action, recordId?)` appends the
402
- * record id when one is supplied, so a row-scoped confirmation renders a
403
- * testid this locator does not match.
397
+ * A flow's confirm dialog on a toolbar surface (`form-action:<resource>:<scope>:<action>`): the
398
+ * dialog a flow with `confirm` (or a fired warning) asks through before it writes (P4). Its
399
+ * buttons are the same id with `:proceed` / `:cancel` in place of `:dialog`. A row's flow asks
400
+ * through `list-row-action:<resource>:<id>:<action>:confirm:dialog`.
404
401
  */
405
402
  actionConfirm: (resource: string, scope: string, actionName: string) => L;
406
403
  page: PlaywrightPage<L>;
package/dist/index.js CHANGED
@@ -9,10 +9,10 @@ function createFormView(page, resource, view) {
9
9
  help: () => page.getByTestId(`form-field-help:${resource}:${view}:${fieldName}`)
10
10
  };
11
11
  },
12
- group: (groupKey) => page.getByTestId(`form-group:${resource}:${view}:${groupKey}`),
13
12
  submit: () => page.getByTestId(`form-submit:${resource}:${view}`),
14
13
  cancel: () => page.getByTestId(`form-cancel:${resource}:${view}`),
15
- action: (actionName) => page.getByTestId(`form-action:${resource}:${view}:${actionName}`)
14
+ action: (actionName) => page.getByTestId(`form-action:${resource}:${view}:${actionName}`),
15
+ node: (key, region = "record-body") => page.locator(`[data-buro-region="${region}"] [data-buro-key="${key}"]`)
16
16
  };
17
17
  }
18
18
  //#endregion
@@ -147,7 +147,7 @@ function createBuroPage(page) {
147
147
  failure: (subject) => page.getByTestId(`task-run-failure:${name}:${subject}`)
148
148
  }),
149
149
  notification: (type) => page.getByTestId(`notification:${type}`),
150
- actionConfirm: (resource, scope, actionName) => page.getByTestId(`action-confirm-dialog:${resource}:${scope}:${actionName}`),
150
+ actionConfirm: (resource, scope, actionName) => page.getByTestId(`form-action:${resource}:${scope}:${actionName}:confirm:dialog`),
151
151
  page
152
152
  };
153
153
  }
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","names":[],"sources":["../src/form-view.ts","../src/list-view.ts","../src/picker-object.ts","../src/show-view.ts","../src/resource-object.ts","../src/buro-page.ts","../src/confirm-dialog.ts","../src/index.ts"],"sourcesContent":["import type { ClickableLocator, PlaywrightPage } from './types.js';\n\nexport interface FormFieldObject<L> {\n input: () => L;\n error: () => L;\n help: () => L;\n}\n\nexport interface FormView<L extends ClickableLocator> {\n field: (fieldName: string) => FormFieldObject<L>;\n /**\n * One declared field GROUP.\n *\n * ⚠️ Renamed from `section()` at SP9 task 5: it asked for `form-section:…`\n * and `<ResourceForm>` has always rendered `form-group:…`, so it had never\n * matched an element. See `src/picker-object.ts` for the wider sweep and for\n * why the cross-vocabulary test could not see any of it.\n */\n group: (groupKey: string) => L;\n submit: () => L;\n cancel: () => L;\n action: (actionName: string) => L;\n}\n\nexport function createFormView<L extends ClickableLocator>(\n page: PlaywrightPage<L>,\n resource: string,\n view: 'create' | 'edit' | 'show',\n): FormView<L> {\n return {\n field: (fieldName) => {\n page.getByTestId(`form-field:${resource}:${view}:${fieldName}`);\n return {\n input: () => page.getByTestId(`form-field-input:${resource}:${view}:${fieldName}`),\n error: () => page.getByTestId(`form-field-error:${resource}:${view}:${fieldName}`),\n help: () => page.getByTestId(`form-field-help:${resource}:${view}:${fieldName}`),\n };\n },\n group: (groupKey) => page.getByTestId(`form-group:${resource}:${view}:${groupKey}`),\n submit: () => page.getByTestId(`form-submit:${resource}:${view}`),\n cancel: () => page.getByTestId(`form-cancel:${resource}:${view}`),\n action: (actionName) => page.getByTestId(`form-action:${resource}:${view}:${actionName}`),\n };\n}\n","import type { ClickableLocator, PlaywrightPage } from './types.js';\n\n/**\n * A row's address inside the filter-builder draft: `[]` is the root group,\n * `[0]` is its first row, `[1, 0]` is the first row of the group nested at\n * top-level index 1. Mirrors `DraftPath` in\n * `packages/ui-shadcn/src/list-kit/filter-builder/draft.ts` — the testid\n * suffix is `path.join('-')` (an empty path renders as the empty string, so\n * the root group's own testid carries a trailing `:`, e.g.\n * `list-filter:<r>:group::add-row`).\n */\nexport type FilterRowPath = readonly number[];\n\nexport interface ListView<L extends ClickableLocator> {\n goto: () => Promise<void>;\n row: (recordId: string) => RowObject<L>;\n /**\n * EVERY rendered row of this resource, as one locator — the set, not a\n * named member of it. `row(recordId)` cannot express it: a row's testid\n * embeds its record id, so the set is only addressable by prefix.\n *\n * This is the accessor a list assertion is actually built on\n * (`await expect(list.rows()).toHaveCount(n)`), and it was missing for as\n * long as the only consumer of this package was a mock that never counted\n * anything.\n */\n rows: () => L;\n /**\n * One column across every rendered row, in row order — again a set, again\n * prefix-addressed. Reading a whole column through ONE locator matters for\n * more than convenience: a per-row loop awaits between reads and can meet a\n * detached node mid-re-render, which turns a polling wait into a thrown\n * error instead of another attempt.\n */\n column: (fieldName: string) => L;\n /** The column header's menu trigger (sort / pin / hide / move). */\n columnMenu: (fieldName: string) => L;\n /** The sort item inside an OPEN column header menu. */\n columnSort: (fieldName: string, direction: 'asc' | 'desc') => L;\n /**\n * The reorder item inside an OPEN column header menu — moves the column one\n * position left or right.\n *\n * ⚠️ What it moves is **column layout**, which is a different persistence\n * mechanism from everything else on this object: order, width and pinning\n * live in raw `localStorage` under `buro:table-layout:<resource>:{order,\n * sizing,pinning}` and are carried by neither the URL nor a saved view. See\n * {@link ListView.savedViews}.\n */\n columnMove: (fieldName: string, direction: 'left' | 'right') => L;\n /** The Columns menu trigger — show/hide columns, reset widths, restore defaults. */\n columnsToggle: () => L;\n /**\n * One column's row inside the OPEN Columns menu. A column the resource\n * declares in `views.list.columns` is REQUIRED unless\n * `views.list.requiredColumns` says otherwise, and renders `disabled` with\n * `data-required=\"true\"` — only `optionalColumns` are actually clickable.\n */\n columnsOption: (columnId: string) => L;\n /** The rows-per-page select trigger; its text is the active page size. */\n pageSize: () => L;\n search: () => L;\n // ── Filter builder (SP2) ─────────────────────────────────────────────\n // The chip toolbar this file used to model (`list-dyn-filter:*`,\n // `list-add-filter:*`) is gone — SP2 replaced it with a popover panel:\n // open the panel, add a row, pick a field/operator/value, Apply. Applied\n // rows then render as removable badges OUTSIDE the panel. See\n // `packages/ui-shadcn/src/list-kit/filter-builder/*` for the components\n // these testids come from.\n /** The filter region container — `list-filters:<resource>`. */\n filtersContainer: () => L;\n /** Opens/closes the filter-builder popover. */\n filterButton: () => L;\n /** The popover body once open. */\n filterPanel: () => L;\n /** \"+ Filter\" — appends a new predicate row to the group at `groupPath` (root: `[]`). */\n filterAddRow: (groupPath?: FilterRowPath) => L;\n /** Field-select trigger for the row at `path`. */\n filterRowField: (path: FilterRowPath) => L;\n /** One field option inside an open field-select. */\n filterRowFieldOption: (path: FilterRowPath, field: string) => L;\n /** Operator-select trigger for the row at `path`. */\n filterRowOp: (path: FilterRowPath) => L;\n /** One operator option inside an open operator-select. */\n filterRowOpOption: (path: FilterRowPath, op: string) => L;\n /** Value control trigger/input for the row at `path` — shape depends on the field kind (text box, enum/relation/boolean popover). */\n filterRowValue: (path: FilterRowPath) => L;\n /** Removes the row at `path` from the draft (inside the panel, before Apply). */\n filterRowRemove: (path: FilterRowPath) => L;\n /** Commits the draft — applies it and closes the panel. */\n filterApply: () => L;\n /** Clears the draft back to empty (still requires Apply to take effect). */\n filterReset: () => L;\n /** One applied-filter badge outside the panel, by its top-level index. */\n filterBadge: (index: number) => L;\n /** A badge's × — removes that row immediately, no Apply needed. */\n filterBadgeRemove: (index: number) => L;\n /** \"Clear all\" — empties both the pinned slot and the builder group. */\n filterClearAll: () => L;\n /** An always-visible pinned control (`views.list.filters.pinned`) for `field`, rendered outside the panel. */\n filterPinned: (field: string) => L;\n /**\n * The floating bulk bar, which appears as soon as ANY row is selected —\n * even for a resource that declares no bulk action, because it also carries\n * the built-in Export entry and the selection count.\n */\n bulkBar: () => L;\n /** One declared bulk action's button inside the bar (`actions.bulk`). */\n bulkAction: (actionName: string) => L;\n paginationPrev: () => L;\n paginationNext: () => L;\n /**\n * The load-more button a CURSOR-paginated list renders instead of the\n * numbered pager (`views.list.pagination: 'cursor'` → `<CursorLoadMore>`).\n *\n * ⚠️ NOT resource-scoped, because the control itself is not: `CursorLoadMore`\n * carries a bare `data-testid=\"cursor-load-more\"` with no resource segment,\n * unlike every other id above. A page rendering two cursor lists at once\n * would need `.first()`/`.nth()` to tell them apart. Recorded rather than\n * papered over — inventing a scoped id here would only make this object\n * disagree with the component.\n *\n * ⚠️ `<CursorLoadMore>` renders NOTHING when there is no next page, so the\n * end of a cursor list is \"this locator resolves to no element\", not a\n * disabled button. That is what makes it usable as an end-of-list assertion.\n *\n * Like `columnMenu`/`columnSort`, this id has no `useTestIds` factory behind\n * it, so `page-object-vocabulary` tests have nothing to check it against\n * (the pre-existing F6 gap — see the saved-views block below for what that\n * costs, measured).\n */\n loadMore: () => L;\n empty: () => L;\n toolbar: () => L;\n table: () => L;\n // ── Saved views ──────────────────────────────────────────────────────\n // `<ResourceListPage>` renders the Views dropdown UNCONDITIONALLY, and\n // `<BuroProvider>` mounts a localStorage-backed settings store when the\n // adopter supplies none — so \"Save current view…\" is on every list screen\n // of every app built on this framework, whether or not anyone declared\n // `views.list.savedViews`. Until these accessors existed, no page object\n // could address any of it: the only browser tests that ever tried\n // (the since-deleted `apps/playground`'s saved-views spec) spelled the ids by\n // hand, and marked six of their eleven tests `test.fixme` — including both\n // round-trips.\n //\n // ⚠️ **A view carries exactly six keys** — `filters`, `filterGroup`,\n // `sort`, `pageSize`, `search`, `visibleColumns`\n // (`SavedViewSettingValue`). NOT the page number (applying a view always\n // opens page one), NOT the selection, and NOT column layout: width, order\n // and pinning are a separate store (see {@link ListView.columnMove}), so a\n // view restores WHICH columns are shown and never HOW WIDE or in what\n // order.\n //\n // ⚠️ **NONE OF THE THIRTEEN ACCESSORS ADDED WITH THIS BLOCK HAS A\n // `useTestIds` FACTORY** (saved views, the Columns menu, the page-size\n // select and `columnMove`), so `page-object-vocabulary.test.tsx` has nothing\n // to compare them against — the F6 gap again, with thirteen more instances.\n // SP5 task 8 settled it as: NOT closed, and here is the measured cost, so\n // that the next person decides on numbers rather than on the word \"gap\".\n //\n // Both sides are independently pinned, just never against each other:\n // `tests/buro-page.test.ts` pins the string THIS file hands out, and on the\n // component side eight of the thirteen ids are spelled as literals in\n // `packages/ui-shadcn/tests/*` while ten of the thirteen accessors are\n // driven by `examples/marketplace-ops`' browser suite. So a rename in\n // `@burojs/ui-shadcn` reddens something in every case **but one**:\n //\n // `savedViewsMenu` (`list-saved-views-menu:<r>`) is spelled by no package\n // test and addressed by no browser spec, so renaming it in\n // `resource-list-page.tsx` is invisible everywhere and this accessor would\n // quietly point at nothing.\n //\n // Closing the family properly means ~13 new factories on the public\n // `TestIdBuilder` plus rewiring `resource-list-page.tsx`,\n // `columns-toggle.tsx` and `resource-list.tsx` to compose through them —\n // a public-surface decision, not a test fix, and it covers\n // `columnMenu`/`columnSort`/`columnMove` and `loadMore` at the same time.\n /** The Views dropdown trigger in the list toolbar. */\n savedViews: () => L;\n /** The dropdown body once open. */\n savedViewsMenu: () => L;\n /**\n * EVERY view entry in the open dropdown (adopter presets and user-saved\n * alike), as one locator — the set, not a named member of it, because a\n * user view's id is a uuid minted at save time and no test can know it in\n * advance. Prefix-addressed for the same reason {@link ListView.rows} is.\n */\n savedViewEntries: () => L;\n /** One view entry by id — for an adopter preset declared in `views.list.savedViews`, whose id IS known. */\n savedView: (viewId: string) => L;\n /** \"Save current view…\" — opens the save dialog. */\n savedViewSave: () => L;\n /** The label input inside the open save dialog. */\n savedViewSaveInput: () => L;\n /** The save dialog's confirm button; disabled while the label is empty. */\n savedViewSaveConfirm: () => L;\n /**\n * \"Manage views…\" — opens the manage dialog. Rendered only when at least\n * one entry exists, so `toHaveCount(0)` is a usable \"this browser has no\n * saved views\" assertion.\n */\n savedViewManage: () => L;\n /** One entry's Delete button inside the open manage dialog. */\n savedViewManageDelete: (viewId: string) => L;\n}\n\nexport interface RowObject<L extends ClickableLocator> {\n /**\n * The row element itself. Without it a caller cannot say \"this row is on\n * screen\" or \"this row is gone\" — the two assertions a list test needs\n * most, and the ones `cell()`/`action()` cannot stand in for (a row can be\n * present while a given cell or action is not).\n */\n root: () => L;\n cell: (fieldName: string) => L;\n action: (actionName: string) => L;\n checkbox: () => L;\n open: () => Promise<void>;\n}\n\nexport interface ListViewOptions {\n /**\n * What {@link ListView.goto} navigates to. Defaults to `/<resource>`, which\n * assumes the route is named after the resource — often false (a `order`\n * resource routed at `/orders`), and it carries no query string, so an app\n * whose test isolation travels on the URL has to pass its whole launch URL\n * here.\n */\n basePath?: string;\n}\n\n/** `path.join('-')` — see `FilterRowPath`'s doc for the empty-path edge case. */\nfunction pathStr(path: FilterRowPath): string {\n return path.join('-');\n}\n\nexport function createListView<L extends ClickableLocator>(\n page: PlaywrightPage<L>,\n resource: string,\n options: ListViewOptions = {},\n): ListView<L> {\n const path = options.basePath ?? `/${resource}`;\n const filterBase = `list-filter:${resource}`;\n const sortBase = (field: string) => `list-sort:${resource}:${field}`;\n const rowBase = (rowPath: FilterRowPath) => `${filterBase}:row:${pathStr(rowPath)}`;\n const groupBase = (groupPath: FilterRowPath) => `${filterBase}:group:${pathStr(groupPath)}`;\n return {\n goto: async () => {\n await page.goto(path);\n },\n row: (recordId) => createRowObject(page, resource, recordId),\n rows: () => page.locator(`[data-testid^=\"list-row:${resource}:\"]`),\n column: (fieldName) =>\n page.locator(`[data-testid^=\"list-cell:${resource}:\"][data-testid$=\":${fieldName}\"]`),\n columnMenu: (fieldName) => page.getByTestId(sortBase(fieldName)),\n columnSort: (fieldName, direction) =>\n page.getByTestId(`${sortBase(fieldName)}:sort-${direction}`),\n columnMove: (fieldName, direction) =>\n page.getByTestId(`${sortBase(fieldName)}:move-${direction}`),\n columnsToggle: () => page.getByTestId(`list-columns:${resource}`),\n columnsOption: (columnId) => page.getByTestId(`list-columns-option:${resource}:${columnId}`),\n pageSize: () => page.getByTestId(`list-page-size:${resource}`),\n search: () => page.getByTestId(`list-search:${resource}`),\n filtersContainer: () => page.getByTestId(`list-filters:${resource}`),\n filterButton: () => page.getByTestId(`${filterBase}:button`),\n filterPanel: () => page.getByTestId(`${filterBase}:panel`),\n filterAddRow: (groupPath = []) => page.getByTestId(`${groupBase(groupPath)}:add-row`),\n filterRowField: (rowPath) => page.getByTestId(`${rowBase(rowPath)}:field`),\n filterRowFieldOption: (rowPath, field) =>\n page.getByTestId(`${rowBase(rowPath)}:field:option:${field}`),\n filterRowOp: (rowPath) => page.getByTestId(`${rowBase(rowPath)}:op`),\n filterRowOpOption: (rowPath, op) => page.getByTestId(`${rowBase(rowPath)}:op:option:${op}`),\n filterRowValue: (rowPath) => page.getByTestId(`${rowBase(rowPath)}:value`),\n filterRowRemove: (rowPath) => page.getByTestId(`${rowBase(rowPath)}:remove`),\n filterApply: () => page.getByTestId(`${filterBase}:apply`),\n filterReset: () => page.getByTestId(`${filterBase}:reset`),\n filterBadge: (index) => page.getByTestId(`${filterBase}:badge:${index}`),\n filterBadgeRemove: (index) => page.getByTestId(`${filterBase}:badge:${index}:remove`),\n filterClearAll: () => page.getByTestId(`${filterBase}:clear-all`),\n filterPinned: (field) => page.getByTestId(`${filterBase}:pinned:${field}`),\n bulkBar: () => page.getByTestId(`list-bulk-bar:${resource}`),\n bulkAction: (actionName) => page.getByTestId(`bulk-action:${resource}:${actionName}`),\n paginationPrev: () => page.getByTestId(`list-pagination-prev:${resource}`),\n paginationNext: () => page.getByTestId(`list-pagination-next:${resource}`),\n // Deliberately unscoped — see the interface doc: the component's own id\n // carries no resource segment.\n loadMore: () => page.getByTestId('cursor-load-more'),\n empty: () => page.getByTestId(`list-empty:${resource}`),\n toolbar: () => page.getByTestId(`list-toolbar:${resource}`),\n table: () => page.getByTestId(`list-table:${resource}`),\n savedViews: () => page.getByTestId(`list-saved-views:${resource}`),\n savedViewsMenu: () => page.getByTestId(`list-saved-views-menu:${resource}`),\n // The trailing colon is load-bearing: it separates an ENTRY\n // (`list-saved-view:<r>:<uuid>`) from the trigger\n // (`list-saved-views:<r>`, plural) and from every sibling control\n // (`list-saved-view-save:<r>`, `list-saved-view-section:<r>:<id>`, …).\n savedViewEntries: () => page.locator(`[data-testid^=\"list-saved-view:${resource}:\"]`),\n savedView: (viewId) => page.getByTestId(`list-saved-view:${resource}:${viewId}`),\n savedViewSave: () => page.getByTestId(`list-saved-view-save:${resource}`),\n savedViewSaveInput: () => page.getByTestId(`list-saved-view-save-input:${resource}`),\n savedViewSaveConfirm: () => page.getByTestId(`list-saved-view-save-confirm:${resource}`),\n savedViewManage: () => page.getByTestId(`list-saved-view-manage:${resource}`),\n savedViewManageDelete: (viewId) =>\n page.getByTestId(`list-saved-view-manage-delete:${resource}:${viewId}`),\n };\n}\n\nfunction createRowObject<L extends ClickableLocator>(\n page: PlaywrightPage<L>,\n resource: string,\n recordId: string,\n): RowObject<L> {\n const rowId = `list-row:${resource}:${recordId}`;\n return {\n root: () => page.getByTestId(rowId),\n cell: (fieldName) => page.getByTestId(`list-cell:${resource}:${recordId}:${fieldName}`),\n action: (actionName) =>\n page.getByTestId(`list-row-action:${resource}:${recordId}:${actionName}`),\n checkbox: () => page.getByTestId(`list-row-checkbox:${resource}:${recordId}`),\n open: async () => {\n await page.getByTestId(rowId).click();\n },\n };\n}\n","import type { PlaywrightPage } from './types.js';\n\n/**\n * The relation picker's addresses, as a test drives them.\n *\n * ⚠️ **Every id here was wrong until SP9 task 5, and nothing could see it.**\n * This object used to ask for `picker-dialog:` / `picker-popover:` /\n * `picker-search:` / `picker-option:` / `picker-clear:` /\n * `picker-quickcreate:`, and `@burojs/react`'s `useTestIds()` composed exactly\n * the same six — while `@burojs/ui-shadcn`'s `<RelationPicker>` has always\n * emitted `relation-picker:<target>:…`. Neither vocabulary had a single\n * producer. The cross-check that exists to prevent this\n * (`examples/marketplace-ops/tests/page-object-vocabulary.test.tsx`) was green\n * throughout, because it compared these two copies with each other and neither\n * of them with the component. The producer side is pinned now, in\n * `packages/ui-shadcn/tests/relation-picker-testids.test.tsx`.\n *\n * `quickCreate` is not re-spelled but REMOVED: quick-create inside the picker\n * is deferred to the picker's v2 and there is no element to address. `popover`\n * is likewise gone — the popover content carries no id; {@link PickerObject.list}\n * is the element inside it that does.\n *\n * **`trigger()` is the standalone spelling.** Inside a `<ResourceForm>` the\n * form overrides the trigger's testid with its own\n * `form-field-input:<resource>:<view>:<field>`, so a form field's picker is\n * opened through `resource(r).create().field(f).input()` and only its popover\n * children are addressed through this object.\n */\nexport interface PickerObject<L> {\n /** The standalone trigger button. In a form, use the form field's `input()`. */\n trigger: () => L;\n /** The popover's search box. */\n search: () => L;\n /** The popover's result list. */\n list: () => L;\n /** One result row, by the target record's id. */\n option: (value: string) => L;\n /** The \"No results.\" row. */\n empty: () => L;\n /** The Clear link — present only when the picker is clearable and set. */\n clear: () => L;\n /** The \"Advanced search\" link that escalates to {@link PickerObject.dialog}. */\n advanced: () => L;\n /** The \"Open list\" link to the target's own list page. */\n viewAll: () => L;\n /** The escalation modal. */\n dialog: () => L;\n /** The escalation modal's own search box. */\n dialogSearch: () => L;\n /** The disabled trigger shown when the target resource is unregistered. */\n unregistered: () => L;\n}\n\nexport function createPickerObject<L>(\n page: PlaywrightPage<L>,\n targetResource: string,\n): PickerObject<L> {\n const prefix = `relation-picker:${targetResource}`;\n return {\n trigger: () => page.getByTestId(prefix),\n search: () => page.getByTestId(`${prefix}:search`),\n list: () => page.getByTestId(`${prefix}:list`),\n option: (value) => page.getByTestId(`${prefix}:option:${value}`),\n empty: () => page.getByTestId(`${prefix}:empty`),\n clear: () => page.getByTestId(`${prefix}:clear`),\n advanced: () => page.getByTestId(`${prefix}:advanced`),\n viewAll: () => page.getByTestId(`${prefix}:view-all`),\n dialog: () => page.getByTestId(`relation-picker-dialog:${targetResource}`),\n dialogSearch: () => page.getByTestId(`relation-picker-dialog:${targetResource}:search`),\n unregistered: () => page.getByTestId(`${prefix}:unregistered`),\n };\n}\n","import { type FormView, createFormView } from './form-view.js';\nimport type { ClickableLocator, PlaywrightPage } from './types.js';\n\nexport interface ShowView<L extends ClickableLocator> extends FormView<L> {\n /**\n * The show page root — `useTestIds`' `pageShow(resource, recordId)`.\n *\n * The ONE show accessor that uses the record id. Every other locator on this\n * view comes from `createFormView`, whose testids carry no record id at all\n * (see {@link import('./resource-object.js').ResourceObject.show}), so this\n * is what lets a test assert it is looking at the show page OF A GIVEN\n * RECORD rather than at whatever detail page happens to be mounted.\n */\n root: () => L;\n}\n\nexport function createShowView<L extends ClickableLocator>(\n page: PlaywrightPage<L>,\n resource: string,\n recordId: string,\n): ShowView<L> {\n return {\n ...createFormView(page, resource, 'show'),\n root: () => page.getByTestId(`page-show:${resource}:${recordId}`),\n };\n}\n","import { type FormView, createFormView } from './form-view.js';\nimport { type ListView, type ListViewOptions, createListView } from './list-view.js';\nimport { type PickerObject, createPickerObject } from './picker-object.js';\nimport { type ShowView, createShowView } from './show-view.js';\nimport type { ClickableLocator, PlaywrightPage } from './types.js';\n\nexport interface ResourceObject<L extends ClickableLocator> {\n name: string;\n list: (options?: ListViewOptions) => ListView<L>;\n /**\n * ⚠️ `recordId` is accepted and IGNORED: the form testids\n * (`form-field:<resource>:edit:<field>`) carry no record id, so an edit view\n * addressed for record A and one addressed for record B are the same\n * locators. Only one edit form is ever mounted at a time, so this is\n * harmless in practice — but the parameter reads like a scope and is not\n * one.\n */\n edit: (recordId: string) => FormView<L>;\n /**\n * ⚠️ `recordId` reaches {@link ShowView.root} and NOTHING ELSE. The show\n * view's field/section/action locators all come from `createFormView`, whose\n * testids carry no record id, so they are the same locators for record A and\n * record B. `root()` is the one accessor that is genuinely scoped to the\n * record — assert on it first if a test needs to know WHICH record's page is\n * mounted.\n */\n show: (recordId: string) => ShowView<L>;\n create: () => FormView<L>;\n picker: () => PickerObject<L>;\n}\n\nexport function createResourceObject<L extends ClickableLocator>(\n page: PlaywrightPage<L>,\n name: string,\n): ResourceObject<L> {\n return {\n name,\n list: (options) => createListView(page, name, options),\n edit: (_recordId) => createFormView(page, name, 'edit'),\n show: (recordId) => createShowView(page, name, recordId),\n create: () => createFormView(page, name, 'create'),\n picker: () => createPickerObject(page, name),\n };\n}\n","import { type ResourceObject, createResourceObject } from './resource-object.js';\nimport type { ClickableLocator, PlaywrightPage } from './types.js';\n\nexport interface BuroPage<L extends ClickableLocator> {\n resource: (name: string) => ResourceObject<L>;\n /**\n * The sidebar entry for a registered resource — `<NavMain>`'s\n * `tid.navMenuItem(resource)`. The whole navigation half of a suite used to\n * spell this string by hand even though the factory existed (SP3 stage C\n * final review, Important 2).\n */\n navMenuItem: (resource: string) => L;\n /** The sidebar entry for an adopter-declared custom page, keyed by page key\n * rather than resource name (`tid.sidebarCustomPage`). */\n sidebarCustomPage: (pageKey: string) => L;\n contextPicker: (sliceName: string) => L;\n /** One option inside an open context picker — the half that actually\n * performs a switch. Without it a caller can open the picker and not choose. */\n contextPickerOption: (sliceName: string, value: string) => L;\n /**\n * The header language switcher (`<LanguageSwitch>`) and one locale button\n * inside it. The trigger carries `data-language-state=\"<code>\"`, which is\n * how a test reads the ACTIVE locale without opening anything.\n */\n languageSwitch: () => L;\n languageSwitchOption: (localeCode: string) => L;\n /** `<LoginForm>`'s three controls, in the order a sign-in uses them. */\n login: () => LoginFormObject<L>;\n /**\n * `<TaskRunPanel>`'s three addressable parts, keyed by the RUN's name — a\n * task run has no resource to key on (a long server-side operation has no\n * record, a bulk run spans a selection).\n *\n * `root()` carries `data-state`, which is how a spec reads the outcome\n * without matching translated text.\n */\n taskRun: (name: string) => TaskRunObject<L>;\n notification: (type: 'success' | 'error' | 'progress' | 'info') => L;\n /**\n * ⚠️ Addresses the RECORD-LESS spelling only. `useTestIds`'\n * `actionConfirmDialog(resource, scope, action, recordId?)` appends the\n * record id when one is supplied, so a row-scoped confirmation renders a\n * testid this locator does not match.\n */\n actionConfirm: (resource: string, scope: string, actionName: string) => L;\n page: PlaywrightPage<L>;\n}\n\nexport interface TaskRunObject<L> {\n root: () => L;\n /** The bar; its `aria-valuenow`/`aria-valuemax` are the counts. */\n progress: () => L;\n /** One named unit that did not succeed — a refused row, or the step a run stopped at. */\n failure: (subject: string) => L;\n}\n\nexport interface LoginFormObject<L> {\n email: () => L;\n password: () => L;\n submit: () => L;\n}\n\nexport function createBuroPage<L extends ClickableLocator>(page: PlaywrightPage<L>): BuroPage<L> {\n return {\n resource: (name) => createResourceObject(page, name),\n navMenuItem: (resource) => page.getByTestId(`nav-menu-item:${resource}`),\n sidebarCustomPage: (pageKey) => page.getByTestId(`sidebar-custompage:${pageKey}`),\n contextPicker: (sliceName) => page.getByTestId(`context-picker:${sliceName}`),\n contextPickerOption: (sliceName, value) =>\n page.getByTestId(`context-picker-option:${sliceName}:${value}`),\n languageSwitch: () => page.getByTestId('language-switch'),\n languageSwitchOption: (localeCode) => page.getByTestId(`language-switch:${localeCode}`),\n login: () => ({\n email: () => page.getByTestId('login-email'),\n password: () => page.getByTestId('login-password'),\n submit: () => page.getByTestId('login-submit'),\n }),\n taskRun: (name) => ({\n root: () => page.getByTestId(`task-run:${name}`),\n progress: () => page.getByTestId(`task-run-progress:${name}`),\n failure: (subject) => page.getByTestId(`task-run-failure:${name}:${subject}`),\n }),\n notification: (type) => page.getByTestId(`notification:${type}`),\n actionConfirm: (resource, scope, actionName) =>\n page.getByTestId(`action-confirm-dialog:${resource}:${scope}:${actionName}`),\n page,\n };\n}\n","import type { PlaywrightPage } from './types.js';\n\nexport interface ConfirmDialogObject<L> {\n dialog: () => L;\n yes: () => L;\n no: () => L;\n}\n\nexport function createConfirmDialog<L>(\n page: PlaywrightPage<L>,\n resource: string,\n scope: string,\n actionName: string,\n): ConfirmDialogObject<L> {\n return {\n dialog: () => page.getByTestId(`action-confirm-dialog:${resource}:${scope}:${actionName}`),\n yes: () => page.getByTestId(`action-confirm-yes:${resource}:${scope}:${actionName}`),\n no: () => page.getByTestId(`action-confirm-no:${resource}:${scope}:${actionName}`),\n };\n}\n","/**\n * `@burojs/test-helpers` — the Playwright entry point.\n *\n * Everything exported here is loadable inside a `@playwright/test` process:\n * page objects over the testid convention `useTestIds()` (`@burojs/react`)\n * emits, and nothing else. The vitest-based provider contract moved to\n * `@burojs/test-helpers/provider-contract` — see that module for the measured\n * reason it can no longer share an entry point with these.\n */\n\n// Constants\nexport const PACKAGE_NAME = '@burojs/test-helpers' as const;\nexport const PACKAGE_VERSION = '0.0.0' as const;\n\n// Types\nexport type { PlaywrightPage, PlaywrightLocator, ClickableLocator } from './types.js';\n\n// Page object factories\nexport {\n createBuroPage,\n type BuroPage,\n type LoginFormObject,\n type TaskRunObject,\n} from './buro-page.js';\nexport { createResourceObject, type ResourceObject } from './resource-object.js';\nexport {\n createListView,\n type ListView,\n type RowObject,\n type ListViewOptions,\n type FilterRowPath,\n} from './list-view.js';\nexport { createFormView, type FormView, type FormFieldObject } from './form-view.js';\nexport { createShowView, type ShowView } from './show-view.js';\nexport { createPickerObject, type PickerObject } from './picker-object.js';\nexport { createConfirmDialog, type ConfirmDialogObject } from './confirm-dialog.js';\n"],"mappings":"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"}
1
+ {"version":3,"file":"index.js","names":[],"sources":["../src/form-view.ts","../src/list-view.ts","../src/picker-object.ts","../src/show-view.ts","../src/resource-object.ts","../src/buro-page.ts","../src/confirm-dialog.ts","../src/index.ts"],"sourcesContent":["import type { ClickableLocator, PlaywrightPage } from './types.js';\n\nexport interface FormFieldObject<L> {\n input: () => L;\n error: () => L;\n help: () => L;\n}\n\nexport interface FormView<L extends ClickableLocator> {\n field: (fieldName: string) => FormFieldObject<L>;\n submit: () => L;\n cancel: () => L;\n action: (actionName: string) => L;\n /**\n * A keyed node of the record screen (a panel, tabs, a step) by its key,\n * inside `[data-buro-region=\"<region>\"]` (default the body). Nodes carry no\n * test id of their own: the key and the region locate them.\n */\n node: (key: string, region?: 'record-header' | 'record-body') => L;\n}\n\nexport function createFormView<L extends ClickableLocator>(\n page: PlaywrightPage<L>,\n resource: string,\n view: 'create' | 'edit' | 'show',\n): FormView<L> {\n return {\n field: (fieldName) => {\n page.getByTestId(`form-field:${resource}:${view}:${fieldName}`);\n return {\n input: () => page.getByTestId(`form-field-input:${resource}:${view}:${fieldName}`),\n error: () => page.getByTestId(`form-field-error:${resource}:${view}:${fieldName}`),\n help: () => page.getByTestId(`form-field-help:${resource}:${view}:${fieldName}`),\n };\n },\n submit: () => page.getByTestId(`form-submit:${resource}:${view}`),\n cancel: () => page.getByTestId(`form-cancel:${resource}:${view}`),\n action: (actionName) => page.getByTestId(`form-action:${resource}:${view}:${actionName}`),\n node: (key, region = 'record-body') =>\n page.locator(`[data-buro-region=\"${region}\"] [data-buro-key=\"${key}\"]`),\n };\n}\n","import type { ClickableLocator, PlaywrightPage } from './types.js';\n\n/**\n * A row's address inside the filter-builder draft: `[]` is the root group,\n * `[0]` is its first row, `[1, 0]` is the first row of the group nested at\n * top-level index 1. Mirrors `DraftPath` in\n * `packages/ui-shadcn/src/list-kit/filter-builder/draft.ts` — the testid\n * suffix is `path.join('-')` (an empty path renders as the empty string, so\n * the root group's own testid carries a trailing `:`, e.g.\n * `list-filter:<r>:group::add-row`).\n */\nexport type FilterRowPath = readonly number[];\n\nexport interface ListView<L extends ClickableLocator> {\n goto: () => Promise<void>;\n row: (recordId: string) => RowObject<L>;\n /**\n * EVERY rendered row of this resource, as one locator — the set, not a\n * named member of it. `row(recordId)` cannot express it: a row's testid\n * embeds its record id, so the set is only addressable by prefix.\n *\n * This is the accessor a list assertion is actually built on\n * (`await expect(list.rows()).toHaveCount(n)`), and it was missing for as\n * long as the only consumer of this package was a mock that never counted\n * anything.\n */\n rows: () => L;\n /**\n * One column across every rendered row, in row order — again a set, again\n * prefix-addressed. Reading a whole column through ONE locator matters for\n * more than convenience: a per-row loop awaits between reads and can meet a\n * detached node mid-re-render, which turns a polling wait into a thrown\n * error instead of another attempt.\n */\n column: (fieldName: string) => L;\n /** The column header's menu trigger (sort / pin / hide / move). */\n columnMenu: (fieldName: string) => L;\n /** The sort item inside an OPEN column header menu. */\n columnSort: (fieldName: string, direction: 'asc' | 'desc') => L;\n /**\n * The reorder item inside an OPEN column header menu — moves the column one\n * position left or right.\n *\n * ⚠️ What it moves is **column layout**, which is a different persistence\n * mechanism from everything else on this object: order, width and pinning\n * live in raw `localStorage` under `buro:table-layout:<resource>:{order,\n * sizing,pinning}` and are carried by neither the URL nor a saved view. See\n * {@link ListView.savedViews}.\n */\n columnMove: (fieldName: string, direction: 'left' | 'right') => L;\n /** The Columns menu trigger — show/hide columns, reset widths, restore defaults. */\n columnsToggle: () => L;\n /**\n * One column's row inside the OPEN Columns menu. A column the resource\n * declares in `views.list.columns` is REQUIRED unless\n * `views.list.requiredColumns` says otherwise, and renders `disabled` with\n * `data-required=\"true\"` — only `optionalColumns` are actually clickable.\n */\n columnsOption: (columnId: string) => L;\n /** The rows-per-page select trigger; its text is the active page size. */\n pageSize: () => L;\n search: () => L;\n // ── Filter builder (SP2) ─────────────────────────────────────────────\n // The chip toolbar this file used to model (`list-dyn-filter:*`,\n // `list-add-filter:*`) is gone — SP2 replaced it with a popover panel:\n // open the panel, add a row, pick a field/operator/value, Apply. Applied\n // rows then render as removable badges OUTSIDE the panel. See\n // `packages/ui-shadcn/src/list-kit/filter-builder/*` for the components\n // these testids come from.\n /** The filter region container — `list-filters:<resource>`. */\n filtersContainer: () => L;\n /** Opens/closes the filter-builder popover. */\n filterButton: () => L;\n /** The popover body once open. */\n filterPanel: () => L;\n /** \"+ Filter\" — appends a new predicate row to the group at `groupPath` (root: `[]`). */\n filterAddRow: (groupPath?: FilterRowPath) => L;\n /** Field-select trigger for the row at `path`. */\n filterRowField: (path: FilterRowPath) => L;\n /** One field option inside an open field-select. */\n filterRowFieldOption: (path: FilterRowPath, field: string) => L;\n /** Operator-select trigger for the row at `path`. */\n filterRowOp: (path: FilterRowPath) => L;\n /** One operator option inside an open operator-select. */\n filterRowOpOption: (path: FilterRowPath, op: string) => L;\n /** Value control trigger/input for the row at `path` — shape depends on the field kind (text box, enum/relation/boolean popover). */\n filterRowValue: (path: FilterRowPath) => L;\n /** Removes the row at `path` from the draft (inside the panel, before Apply). */\n filterRowRemove: (path: FilterRowPath) => L;\n /** Commits the draft — applies it and closes the panel. */\n filterApply: () => L;\n /** Clears the draft back to empty (still requires Apply to take effect). */\n filterReset: () => L;\n /** One applied-filter badge outside the panel, by its top-level index. */\n filterBadge: (index: number) => L;\n /** A badge's × — removes that row immediately, no Apply needed. */\n filterBadgeRemove: (index: number) => L;\n /** \"Clear all\" — empties both the pinned slot and the builder group. */\n filterClearAll: () => L;\n /** An always-visible pinned control (`views.list.filters.pinned`) for `field`, rendered outside the panel. */\n filterPinned: (field: string) => L;\n /**\n * The floating bulk bar, which appears as soon as ANY row is selected —\n * even for a resource that declares no bulk action, because it also carries\n * the built-in Export entry and the selection count.\n */\n bulkBar: () => L;\n /** One declared bulk action's button inside the bar (`actions.bulk`). */\n bulkAction: (actionName: string) => L;\n paginationPrev: () => L;\n paginationNext: () => L;\n /**\n * The load-more button a CURSOR-paginated list renders instead of the\n * numbered pager (`views.list.pagination: 'cursor'` → `<CursorLoadMore>`).\n *\n * ⚠️ NOT resource-scoped, because the control itself is not: `CursorLoadMore`\n * carries a bare `data-testid=\"cursor-load-more\"` with no resource segment,\n * unlike every other id above. A page rendering two cursor lists at once\n * would need `.first()`/`.nth()` to tell them apart. Recorded rather than\n * papered over — inventing a scoped id here would only make this object\n * disagree with the component.\n *\n * ⚠️ `<CursorLoadMore>` renders NOTHING when there is no next page, so the\n * end of a cursor list is \"this locator resolves to no element\", not a\n * disabled button. That is what makes it usable as an end-of-list assertion.\n *\n * Like `columnMenu`/`columnSort`, this id has no `useTestIds` factory behind\n * it, so `page-object-vocabulary` tests have nothing to check it against\n * (the pre-existing F6 gap — see the saved-views block below for what that\n * costs, measured).\n */\n loadMore: () => L;\n empty: () => L;\n toolbar: () => L;\n table: () => L;\n // ── Saved views ──────────────────────────────────────────────────────\n // `<ResourceListPage>` renders the Views dropdown UNCONDITIONALLY, and\n // `<BuroProvider>` mounts a localStorage-backed settings store when the\n // adopter supplies none — so \"Save current view…\" is on every list screen\n // of every app built on this framework, whether or not anyone declared\n // `views.list.savedViews`. Until these accessors existed, no page object\n // could address any of it: the only browser tests that ever tried\n // (the since-deleted `apps/playground`'s saved-views spec) spelled the ids by\n // hand, and marked six of their eleven tests `test.fixme` — including both\n // round-trips.\n //\n // ⚠️ **A view carries exactly six keys** — `filters`, `filterGroup`,\n // `sort`, `pageSize`, `search`, `visibleColumns`\n // (`SavedViewSettingValue`). NOT the page number (applying a view always\n // opens page one), NOT the selection, and NOT column layout: width, order\n // and pinning are a separate store (see {@link ListView.columnMove}), so a\n // view restores WHICH columns are shown and never HOW WIDE or in what\n // order.\n //\n // ⚠️ **NONE OF THE THIRTEEN ACCESSORS ADDED WITH THIS BLOCK HAS A\n // `useTestIds` FACTORY** (saved views, the Columns menu, the page-size\n // select and `columnMove`), so `page-object-vocabulary.test.tsx` has nothing\n // to compare them against — the F6 gap again, with thirteen more instances.\n // SP5 task 8 settled it as: NOT closed, and here is the measured cost, so\n // that the next person decides on numbers rather than on the word \"gap\".\n //\n // Both sides are independently pinned, just never against each other:\n // `tests/buro-page.test.ts` pins the string THIS file hands out, and on the\n // component side eight of the thirteen ids are spelled as literals in\n // `packages/ui-shadcn/tests/*` while ten of the thirteen accessors are\n // driven by `examples/marketplace-ops`' browser suite. So a rename in\n // `@burojs/ui-shadcn` reddens something in every case **but one**:\n //\n // `savedViewsMenu` (`list-saved-views-menu:<r>`) is spelled by no package\n // test and addressed by no browser spec, so renaming it in\n // `resource-list-page.tsx` is invisible everywhere and this accessor would\n // quietly point at nothing.\n //\n // Closing the family properly means ~13 new factories on the public\n // `TestIdBuilder` plus rewiring `resource-list-page.tsx`,\n // `columns-toggle.tsx` and `resource-list.tsx` to compose through them —\n // a public-surface decision, not a test fix, and it covers\n // `columnMenu`/`columnSort`/`columnMove` and `loadMore` at the same time.\n /** The Views dropdown trigger in the list toolbar. */\n savedViews: () => L;\n /** The dropdown body once open. */\n savedViewsMenu: () => L;\n /**\n * EVERY view entry in the open dropdown (adopter presets and user-saved\n * alike), as one locator — the set, not a named member of it, because a\n * user view's id is a uuid minted at save time and no test can know it in\n * advance. Prefix-addressed for the same reason {@link ListView.rows} is.\n */\n savedViewEntries: () => L;\n /** One view entry by id — for an adopter preset declared in `views.list.savedViews`, whose id IS known. */\n savedView: (viewId: string) => L;\n /** \"Save current view…\" — opens the save dialog. */\n savedViewSave: () => L;\n /** The label input inside the open save dialog. */\n savedViewSaveInput: () => L;\n /** The save dialog's confirm button; disabled while the label is empty. */\n savedViewSaveConfirm: () => L;\n /**\n * \"Manage views…\" — opens the manage dialog. Rendered only when at least\n * one entry exists, so `toHaveCount(0)` is a usable \"this browser has no\n * saved views\" assertion.\n */\n savedViewManage: () => L;\n /** One entry's Delete button inside the open manage dialog. */\n savedViewManageDelete: (viewId: string) => L;\n}\n\nexport interface RowObject<L extends ClickableLocator> {\n /**\n * The row element itself. Without it a caller cannot say \"this row is on\n * screen\" or \"this row is gone\" — the two assertions a list test needs\n * most, and the ones `cell()`/`action()` cannot stand in for (a row can be\n * present while a given cell or action is not).\n */\n root: () => L;\n cell: (fieldName: string) => L;\n action: (actionName: string) => L;\n checkbox: () => L;\n open: () => Promise<void>;\n}\n\nexport interface ListViewOptions {\n /**\n * What {@link ListView.goto} navigates to. Defaults to `/<resource>`, which\n * assumes the route is named after the resource — often false (a `order`\n * resource routed at `/orders`), and it carries no query string, so an app\n * whose test isolation travels on the URL has to pass its whole launch URL\n * here.\n */\n basePath?: string;\n}\n\n/** `path.join('-')` — see `FilterRowPath`'s doc for the empty-path edge case. */\nfunction pathStr(path: FilterRowPath): string {\n return path.join('-');\n}\n\nexport function createListView<L extends ClickableLocator>(\n page: PlaywrightPage<L>,\n resource: string,\n options: ListViewOptions = {},\n): ListView<L> {\n const path = options.basePath ?? `/${resource}`;\n const filterBase = `list-filter:${resource}`;\n const sortBase = (field: string) => `list-sort:${resource}:${field}`;\n const rowBase = (rowPath: FilterRowPath) => `${filterBase}:row:${pathStr(rowPath)}`;\n const groupBase = (groupPath: FilterRowPath) => `${filterBase}:group:${pathStr(groupPath)}`;\n return {\n goto: async () => {\n await page.goto(path);\n },\n row: (recordId) => createRowObject(page, resource, recordId),\n rows: () => page.locator(`[data-testid^=\"list-row:${resource}:\"]`),\n column: (fieldName) =>\n page.locator(`[data-testid^=\"list-cell:${resource}:\"][data-testid$=\":${fieldName}\"]`),\n columnMenu: (fieldName) => page.getByTestId(sortBase(fieldName)),\n columnSort: (fieldName, direction) =>\n page.getByTestId(`${sortBase(fieldName)}:sort-${direction}`),\n columnMove: (fieldName, direction) =>\n page.getByTestId(`${sortBase(fieldName)}:move-${direction}`),\n columnsToggle: () => page.getByTestId(`list-columns:${resource}`),\n columnsOption: (columnId) => page.getByTestId(`list-columns-option:${resource}:${columnId}`),\n pageSize: () => page.getByTestId(`list-page-size:${resource}`),\n search: () => page.getByTestId(`list-search:${resource}`),\n filtersContainer: () => page.getByTestId(`list-filters:${resource}`),\n filterButton: () => page.getByTestId(`${filterBase}:button`),\n filterPanel: () => page.getByTestId(`${filterBase}:panel`),\n filterAddRow: (groupPath = []) => page.getByTestId(`${groupBase(groupPath)}:add-row`),\n filterRowField: (rowPath) => page.getByTestId(`${rowBase(rowPath)}:field`),\n filterRowFieldOption: (rowPath, field) =>\n page.getByTestId(`${rowBase(rowPath)}:field:option:${field}`),\n filterRowOp: (rowPath) => page.getByTestId(`${rowBase(rowPath)}:op`),\n filterRowOpOption: (rowPath, op) => page.getByTestId(`${rowBase(rowPath)}:op:option:${op}`),\n filterRowValue: (rowPath) => page.getByTestId(`${rowBase(rowPath)}:value`),\n filterRowRemove: (rowPath) => page.getByTestId(`${rowBase(rowPath)}:remove`),\n filterApply: () => page.getByTestId(`${filterBase}:apply`),\n filterReset: () => page.getByTestId(`${filterBase}:reset`),\n filterBadge: (index) => page.getByTestId(`${filterBase}:badge:${index}`),\n filterBadgeRemove: (index) => page.getByTestId(`${filterBase}:badge:${index}:remove`),\n filterClearAll: () => page.getByTestId(`${filterBase}:clear-all`),\n filterPinned: (field) => page.getByTestId(`${filterBase}:pinned:${field}`),\n bulkBar: () => page.getByTestId(`list-bulk-bar:${resource}`),\n bulkAction: (actionName) => page.getByTestId(`bulk-action:${resource}:${actionName}`),\n paginationPrev: () => page.getByTestId(`list-pagination-prev:${resource}`),\n paginationNext: () => page.getByTestId(`list-pagination-next:${resource}`),\n // Deliberately unscoped — see the interface doc: the component's own id\n // carries no resource segment.\n loadMore: () => page.getByTestId('cursor-load-more'),\n empty: () => page.getByTestId(`list-empty:${resource}`),\n toolbar: () => page.getByTestId(`list-toolbar:${resource}`),\n table: () => page.getByTestId(`list-table:${resource}`),\n savedViews: () => page.getByTestId(`list-saved-views:${resource}`),\n savedViewsMenu: () => page.getByTestId(`list-saved-views-menu:${resource}`),\n // The trailing colon is load-bearing: it separates an ENTRY\n // (`list-saved-view:<r>:<uuid>`) from the trigger\n // (`list-saved-views:<r>`, plural) and from every sibling control\n // (`list-saved-view-save:<r>`, `list-saved-view-section:<r>:<id>`, …).\n savedViewEntries: () => page.locator(`[data-testid^=\"list-saved-view:${resource}:\"]`),\n savedView: (viewId) => page.getByTestId(`list-saved-view:${resource}:${viewId}`),\n savedViewSave: () => page.getByTestId(`list-saved-view-save:${resource}`),\n savedViewSaveInput: () => page.getByTestId(`list-saved-view-save-input:${resource}`),\n savedViewSaveConfirm: () => page.getByTestId(`list-saved-view-save-confirm:${resource}`),\n savedViewManage: () => page.getByTestId(`list-saved-view-manage:${resource}`),\n savedViewManageDelete: (viewId) =>\n page.getByTestId(`list-saved-view-manage-delete:${resource}:${viewId}`),\n };\n}\n\nfunction createRowObject<L extends ClickableLocator>(\n page: PlaywrightPage<L>,\n resource: string,\n recordId: string,\n): RowObject<L> {\n const rowId = `list-row:${resource}:${recordId}`;\n return {\n root: () => page.getByTestId(rowId),\n cell: (fieldName) => page.getByTestId(`list-cell:${resource}:${recordId}:${fieldName}`),\n action: (actionName) =>\n page.getByTestId(`list-row-action:${resource}:${recordId}:${actionName}`),\n checkbox: () => page.getByTestId(`list-row-checkbox:${resource}:${recordId}`),\n open: async () => {\n await page.getByTestId(rowId).click();\n },\n };\n}\n","import type { PlaywrightPage } from './types.js';\n\n/**\n * The relation picker's addresses, as a test drives them.\n *\n * ⚠️ **Every id here was wrong until SP9 task 5, and nothing could see it.**\n * This object used to ask for `picker-dialog:` / `picker-popover:` /\n * `picker-search:` / `picker-option:` / `picker-clear:` /\n * `picker-quickcreate:`, and `@burojs/react`'s `useTestIds()` composed exactly\n * the same six — while `@burojs/ui-shadcn`'s `<RelationPicker>` has always\n * emitted `relation-picker:<target>:…`. Neither vocabulary had a single\n * producer. The cross-check that exists to prevent this\n * (`examples/marketplace-ops/tests/page-object-vocabulary.test.tsx`) was green\n * throughout, because it compared these two copies with each other and neither\n * of them with the component. The producer side is pinned now, in\n * `packages/ui-shadcn/tests/relation-picker-testids.test.tsx`.\n *\n * `quickCreate` is not re-spelled but REMOVED: quick-create inside the picker\n * is deferred to the picker's v2 and there is no element to address. `popover`\n * is likewise gone — the popover content carries no id; {@link PickerObject.list}\n * is the element inside it that does.\n *\n * **`trigger()` is the standalone spelling.** Inside a record form the\n * form overrides the trigger's testid with its own\n * `form-field-input:<resource>:<view>:<field>`, so a form field's picker is\n * opened through `resource(r).create().field(f).input()` and only its popover\n * children are addressed through this object.\n */\nexport interface PickerObject<L> {\n /** The standalone trigger button. In a form, use the form field's `input()`. */\n trigger: () => L;\n /** The popover's search box. */\n search: () => L;\n /** The popover's result list. */\n list: () => L;\n /** One result row, by the target record's id. */\n option: (value: string) => L;\n /** The \"No results.\" row. */\n empty: () => L;\n /** The Clear link — present only when the picker is clearable and set. */\n clear: () => L;\n /** The \"Advanced search\" link that escalates to {@link PickerObject.dialog}. */\n advanced: () => L;\n /** The \"Open list\" link to the target's own list page. */\n viewAll: () => L;\n /** The escalation modal. */\n dialog: () => L;\n /** The escalation modal's own search box. */\n dialogSearch: () => L;\n /** The disabled trigger shown when the target resource is unregistered. */\n unregistered: () => L;\n}\n\nexport function createPickerObject<L>(\n page: PlaywrightPage<L>,\n targetResource: string,\n): PickerObject<L> {\n const prefix = `relation-picker:${targetResource}`;\n return {\n trigger: () => page.getByTestId(prefix),\n search: () => page.getByTestId(`${prefix}:search`),\n list: () => page.getByTestId(`${prefix}:list`),\n option: (value) => page.getByTestId(`${prefix}:option:${value}`),\n empty: () => page.getByTestId(`${prefix}:empty`),\n clear: () => page.getByTestId(`${prefix}:clear`),\n advanced: () => page.getByTestId(`${prefix}:advanced`),\n viewAll: () => page.getByTestId(`${prefix}:view-all`),\n dialog: () => page.getByTestId(`relation-picker-dialog:${targetResource}`),\n dialogSearch: () => page.getByTestId(`relation-picker-dialog:${targetResource}:search`),\n unregistered: () => page.getByTestId(`${prefix}:unregistered`),\n };\n}\n","import { type FormView, createFormView } from './form-view.js';\nimport type { ClickableLocator, PlaywrightPage } from './types.js';\n\nexport interface ShowView<L extends ClickableLocator> extends FormView<L> {\n /**\n * The show page root — `useTestIds`' `pageShow(resource, recordId)`.\n *\n * The ONE show accessor that uses the record id. Every other locator on this\n * view comes from `createFormView`, whose testids carry no record id at all\n * (see {@link import('./resource-object.js').ResourceObject.show}), so this\n * is what lets a test assert it is looking at the show page OF A GIVEN\n * RECORD rather than at whatever detail page happens to be mounted.\n */\n root: () => L;\n}\n\nexport function createShowView<L extends ClickableLocator>(\n page: PlaywrightPage<L>,\n resource: string,\n recordId: string,\n): ShowView<L> {\n return {\n ...createFormView(page, resource, 'show'),\n root: () => page.getByTestId(`page-show:${resource}:${recordId}`),\n };\n}\n","import { type FormView, createFormView } from './form-view.js';\nimport { type ListView, type ListViewOptions, createListView } from './list-view.js';\nimport { type PickerObject, createPickerObject } from './picker-object.js';\nimport { type ShowView, createShowView } from './show-view.js';\nimport type { ClickableLocator, PlaywrightPage } from './types.js';\n\nexport interface ResourceObject<L extends ClickableLocator> {\n name: string;\n list: (options?: ListViewOptions) => ListView<L>;\n /**\n * ⚠️ `recordId` is accepted and IGNORED: the form testids\n * (`form-field:<resource>:edit:<field>`) carry no record id, so an edit view\n * addressed for record A and one addressed for record B are the same\n * locators. Only one edit form is ever mounted at a time, so this is\n * harmless in practice — but the parameter reads like a scope and is not\n * one.\n */\n edit: (recordId: string) => FormView<L>;\n /**\n * ⚠️ `recordId` reaches {@link ShowView.root} and NOTHING ELSE. The show\n * view's field/section/action locators all come from `createFormView`, whose\n * testids carry no record id, so they are the same locators for record A and\n * record B. `root()` is the one accessor that is genuinely scoped to the\n * record — assert on it first if a test needs to know WHICH record's page is\n * mounted.\n */\n show: (recordId: string) => ShowView<L>;\n create: () => FormView<L>;\n picker: () => PickerObject<L>;\n}\n\nexport function createResourceObject<L extends ClickableLocator>(\n page: PlaywrightPage<L>,\n name: string,\n): ResourceObject<L> {\n return {\n name,\n list: (options) => createListView(page, name, options),\n edit: (_recordId) => createFormView(page, name, 'edit'),\n show: (recordId) => createShowView(page, name, recordId),\n create: () => createFormView(page, name, 'create'),\n picker: () => createPickerObject(page, name),\n };\n}\n","import { type ResourceObject, createResourceObject } from './resource-object.js';\nimport type { ClickableLocator, PlaywrightPage } from './types.js';\n\nexport interface BuroPage<L extends ClickableLocator> {\n resource: (name: string) => ResourceObject<L>;\n /**\n * The sidebar entry for a registered resource — `<NavMain>`'s\n * `tid.navMenuItem(resource)`. The whole navigation half of a suite used to\n * spell this string by hand even though the factory existed (SP3 stage C\n * final review, Important 2).\n */\n navMenuItem: (resource: string) => L;\n /** The sidebar entry for an adopter-declared custom page, keyed by page key\n * rather than resource name (`tid.sidebarCustomPage`). */\n sidebarCustomPage: (pageKey: string) => L;\n contextPicker: (sliceName: string) => L;\n /** One option inside an open context picker — the half that actually\n * performs a switch. Without it a caller can open the picker and not choose. */\n contextPickerOption: (sliceName: string, value: string) => L;\n /**\n * The header language switcher (`<LanguageSwitch>`) and one locale button\n * inside it. The trigger carries `data-language-state=\"<code>\"`, which is\n * how a test reads the ACTIVE locale without opening anything.\n */\n languageSwitch: () => L;\n languageSwitchOption: (localeCode: string) => L;\n /** `<LoginForm>`'s three controls, in the order a sign-in uses them. */\n login: () => LoginFormObject<L>;\n /**\n * `<TaskRunPanel>`'s three addressable parts, keyed by the RUN's name — a\n * task run has no resource to key on (a long server-side operation has no\n * record, a bulk run spans a selection).\n *\n * `root()` carries `data-state`, which is how a spec reads the outcome\n * without matching translated text.\n */\n taskRun: (name: string) => TaskRunObject<L>;\n notification: (type: 'success' | 'error' | 'progress' | 'info') => L;\n /**\n * A flow's confirm dialog on a toolbar surface (`form-action:<resource>:<scope>:<action>`): the\n * dialog a flow with `confirm` (or a fired warning) asks through before it writes (P4). Its\n * buttons are the same id with `:proceed` / `:cancel` in place of `:dialog`. A row's flow asks\n * through `list-row-action:<resource>:<id>:<action>:confirm:dialog`.\n */\n actionConfirm: (resource: string, scope: string, actionName: string) => L;\n page: PlaywrightPage<L>;\n}\n\nexport interface TaskRunObject<L> {\n root: () => L;\n /** The bar; its `aria-valuenow`/`aria-valuemax` are the counts. */\n progress: () => L;\n /** One named unit that did not succeed — a refused row, or the step a run stopped at. */\n failure: (subject: string) => L;\n}\n\nexport interface LoginFormObject<L> {\n email: () => L;\n password: () => L;\n submit: () => L;\n}\n\nexport function createBuroPage<L extends ClickableLocator>(page: PlaywrightPage<L>): BuroPage<L> {\n return {\n resource: (name) => createResourceObject(page, name),\n navMenuItem: (resource) => page.getByTestId(`nav-menu-item:${resource}`),\n sidebarCustomPage: (pageKey) => page.getByTestId(`sidebar-custompage:${pageKey}`),\n contextPicker: (sliceName) => page.getByTestId(`context-picker:${sliceName}`),\n contextPickerOption: (sliceName, value) =>\n page.getByTestId(`context-picker-option:${sliceName}:${value}`),\n languageSwitch: () => page.getByTestId('language-switch'),\n languageSwitchOption: (localeCode) => page.getByTestId(`language-switch:${localeCode}`),\n login: () => ({\n email: () => page.getByTestId('login-email'),\n password: () => page.getByTestId('login-password'),\n submit: () => page.getByTestId('login-submit'),\n }),\n taskRun: (name) => ({\n root: () => page.getByTestId(`task-run:${name}`),\n progress: () => page.getByTestId(`task-run-progress:${name}`),\n failure: (subject) => page.getByTestId(`task-run-failure:${name}:${subject}`),\n }),\n notification: (type) => page.getByTestId(`notification:${type}`),\n actionConfirm: (resource, scope, actionName) =>\n page.getByTestId(`form-action:${resource}:${scope}:${actionName}:confirm:dialog`),\n page,\n };\n}\n","import type { PlaywrightPage } from './types.js';\n\nexport interface ConfirmDialogObject<L> {\n dialog: () => L;\n yes: () => L;\n no: () => L;\n}\n\nexport function createConfirmDialog<L>(\n page: PlaywrightPage<L>,\n resource: string,\n scope: string,\n actionName: string,\n): ConfirmDialogObject<L> {\n return {\n dialog: () => page.getByTestId(`action-confirm-dialog:${resource}:${scope}:${actionName}`),\n yes: () => page.getByTestId(`action-confirm-yes:${resource}:${scope}:${actionName}`),\n no: () => page.getByTestId(`action-confirm-no:${resource}:${scope}:${actionName}`),\n };\n}\n","/**\n * `@burojs/test-helpers` — the Playwright entry point.\n *\n * Everything exported here is loadable inside a `@playwright/test` process:\n * page objects over the testid convention `useTestIds()` (`@burojs/react`)\n * emits, and nothing else. The vitest-based provider contract moved to\n * `@burojs/test-helpers/provider-contract` — see that module for the measured\n * reason it can no longer share an entry point with these.\n */\n\n// Constants\nexport const PACKAGE_NAME = '@burojs/test-helpers' as const;\nexport const PACKAGE_VERSION = '0.0.0' as const;\n\n// Types\nexport type { PlaywrightPage, PlaywrightLocator, ClickableLocator } from './types.js';\n\n// Page object factories\nexport {\n createBuroPage,\n type BuroPage,\n type LoginFormObject,\n type TaskRunObject,\n} from './buro-page.js';\nexport { createResourceObject, type ResourceObject } from './resource-object.js';\nexport {\n createListView,\n type ListView,\n type RowObject,\n type ListViewOptions,\n type FilterRowPath,\n} from './list-view.js';\nexport { createFormView, type FormView, type FormFieldObject } from './form-view.js';\nexport { createShowView, type ShowView } from './show-view.js';\nexport { createPickerObject, type PickerObject } from './picker-object.js';\nexport { createConfirmDialog, type ConfirmDialogObject } from './confirm-dialog.js';\n"],"mappings":";AAqBA,SAAgB,eACd,MACA,UACA,MACa;CACb,OAAO;EACL,QAAQ,cAAc;GACpB,KAAK,YAAY,cAAc,SAAS,GAAG,KAAK,GAAG,WAAW;GAC9D,OAAO;IACL,aAAa,KAAK,YAAY,oBAAoB,SAAS,GAAG,KAAK,GAAG,WAAW;IACjF,aAAa,KAAK,YAAY,oBAAoB,SAAS,GAAG,KAAK,GAAG,WAAW;IACjF,YAAY,KAAK,YAAY,mBAAmB,SAAS,GAAG,KAAK,GAAG,WAAW;GACjF;EACF;EACA,cAAc,KAAK,YAAY,eAAe,SAAS,GAAG,MAAM;EAChE,cAAc,KAAK,YAAY,eAAe,SAAS,GAAG,MAAM;EAChE,SAAS,eAAe,KAAK,YAAY,eAAe,SAAS,GAAG,KAAK,GAAG,YAAY;EACxF,OAAO,KAAK,SAAS,kBACnB,KAAK,QAAQ,sBAAsB,OAAO,qBAAqB,IAAI,GAAG;CAC1E;AACF;;;ACgMA,SAAS,QAAQ,MAA6B;CAC5C,OAAO,KAAK,KAAK,GAAG;AACtB;AAEA,SAAgB,eACd,MACA,UACA,UAA2B,CAAC,GACf;CACb,MAAM,OAAO,QAAQ,YAAY,IAAI;CACrC,MAAM,aAAa,eAAe;CAClC,MAAM,YAAY,UAAkB,aAAa,SAAS,GAAG;CAC7D,MAAM,WAAW,YAA2B,GAAG,WAAW,OAAO,QAAQ,OAAO;CAChF,MAAM,aAAa,cAA6B,GAAG,WAAW,SAAS,QAAQ,SAAS;CACxF,OAAO;EACL,MAAM,YAAY;GAChB,MAAM,KAAK,KAAK,IAAI;EACtB;EACA,MAAM,aAAa,gBAAgB,MAAM,UAAU,QAAQ;EAC3D,YAAY,KAAK,QAAQ,2BAA2B,SAAS,IAAI;EACjE,SAAS,cACP,KAAK,QAAQ,4BAA4B,SAAS,qBAAqB,UAAU,GAAG;EACtF,aAAa,cAAc,KAAK,YAAY,SAAS,SAAS,CAAC;EAC/D,aAAa,WAAW,cACtB,KAAK,YAAY,GAAG,SAAS,SAAS,EAAE,QAAQ,WAAW;EAC7D,aAAa,WAAW,cACtB,KAAK,YAAY,GAAG,SAAS,SAAS,EAAE,QAAQ,WAAW;EAC7D,qBAAqB,KAAK,YAAY,gBAAgB,UAAU;EAChE,gBAAgB,aAAa,KAAK,YAAY,uBAAuB,SAAS,GAAG,UAAU;EAC3F,gBAAgB,KAAK,YAAY,kBAAkB,UAAU;EAC7D,cAAc,KAAK,YAAY,eAAe,UAAU;EACxD,wBAAwB,KAAK,YAAY,gBAAgB,UAAU;EACnE,oBAAoB,KAAK,YAAY,GAAG,WAAW,QAAQ;EAC3D,mBAAmB,KAAK,YAAY,GAAG,WAAW,OAAO;EACzD,eAAe,YAAY,CAAC,MAAM,KAAK,YAAY,GAAG,UAAU,SAAS,EAAE,SAAS;EACpF,iBAAiB,YAAY,KAAK,YAAY,GAAG,QAAQ,OAAO,EAAE,OAAO;EACzE,uBAAuB,SAAS,UAC9B,KAAK,YAAY,GAAG,QAAQ,OAAO,EAAE,gBAAgB,OAAO;EAC9D,cAAc,YAAY,KAAK,YAAY,GAAG,QAAQ,OAAO,EAAE,IAAI;EACnE,oBAAoB,SAAS,OAAO,KAAK,YAAY,GAAG,QAAQ,OAAO,EAAE,aAAa,IAAI;EAC1F,iBAAiB,YAAY,KAAK,YAAY,GAAG,QAAQ,OAAO,EAAE,OAAO;EACzE,kBAAkB,YAAY,KAAK,YAAY,GAAG,QAAQ,OAAO,EAAE,QAAQ;EAC3E,mBAAmB,KAAK,YAAY,GAAG,WAAW,OAAO;EACzD,mBAAmB,KAAK,YAAY,GAAG,WAAW,OAAO;EACzD,cAAc,UAAU,KAAK,YAAY,GAAG,WAAW,SAAS,OAAO;EACvE,oBAAoB,UAAU,KAAK,YAAY,GAAG,WAAW,SAAS,MAAM,QAAQ;EACpF,sBAAsB,KAAK,YAAY,GAAG,WAAW,WAAW;EAChE,eAAe,UAAU,KAAK,YAAY,GAAG,WAAW,UAAU,OAAO;EACzE,eAAe,KAAK,YAAY,iBAAiB,UAAU;EAC3D,aAAa,eAAe,KAAK,YAAY,eAAe,SAAS,GAAG,YAAY;EACpF,sBAAsB,KAAK,YAAY,wBAAwB,UAAU;EACzE,sBAAsB,KAAK,YAAY,wBAAwB,UAAU;EAGzE,gBAAgB,KAAK,YAAY,kBAAkB;EACnD,aAAa,KAAK,YAAY,cAAc,UAAU;EACtD,eAAe,KAAK,YAAY,gBAAgB,UAAU;EAC1D,aAAa,KAAK,YAAY,cAAc,UAAU;EACtD,kBAAkB,KAAK,YAAY,oBAAoB,UAAU;EACjE,sBAAsB,KAAK,YAAY,yBAAyB,UAAU;EAK1E,wBAAwB,KAAK,QAAQ,kCAAkC,SAAS,IAAI;EACpF,YAAY,WAAW,KAAK,YAAY,mBAAmB,SAAS,GAAG,QAAQ;EAC/E,qBAAqB,KAAK,YAAY,wBAAwB,UAAU;EACxE,0BAA0B,KAAK,YAAY,8BAA8B,UAAU;EACnF,4BAA4B,KAAK,YAAY,gCAAgC,UAAU;EACvF,uBAAuB,KAAK,YAAY,0BAA0B,UAAU;EAC5E,wBAAwB,WACtB,KAAK,YAAY,iCAAiC,SAAS,GAAG,QAAQ;CAC1E;AACF;AAEA,SAAS,gBACP,MACA,UACA,UACc;CACd,MAAM,QAAQ,YAAY,SAAS,GAAG;CACtC,OAAO;EACL,YAAY,KAAK,YAAY,KAAK;EAClC,OAAO,cAAc,KAAK,YAAY,aAAa,SAAS,GAAG,SAAS,GAAG,WAAW;EACtF,SAAS,eACP,KAAK,YAAY,mBAAmB,SAAS,GAAG,SAAS,GAAG,YAAY;EAC1E,gBAAgB,KAAK,YAAY,qBAAqB,SAAS,GAAG,UAAU;EAC5E,MAAM,YAAY;GAChB,MAAM,KAAK,YAAY,KAAK,CAAC,CAAC,MAAM;EACtC;CACF;AACF;;;AC/QA,SAAgB,mBACd,MACA,gBACiB;CACjB,MAAM,SAAS,mBAAmB;CAClC,OAAO;EACL,eAAe,KAAK,YAAY,MAAM;EACtC,cAAc,KAAK,YAAY,GAAG,OAAO,QAAQ;EACjD,YAAY,KAAK,YAAY,GAAG,OAAO,MAAM;EAC7C,SAAS,UAAU,KAAK,YAAY,GAAG,OAAO,UAAU,OAAO;EAC/D,aAAa,KAAK,YAAY,GAAG,OAAO,OAAO;EAC/C,aAAa,KAAK,YAAY,GAAG,OAAO,OAAO;EAC/C,gBAAgB,KAAK,YAAY,GAAG,OAAO,UAAU;EACrD,eAAe,KAAK,YAAY,GAAG,OAAO,UAAU;EACpD,cAAc,KAAK,YAAY,0BAA0B,gBAAgB;EACzE,oBAAoB,KAAK,YAAY,0BAA0B,eAAe,QAAQ;EACtF,oBAAoB,KAAK,YAAY,GAAG,OAAO,cAAc;CAC/D;AACF;;;ACvDA,SAAgB,eACd,MACA,UACA,UACa;CACb,OAAO;EACL,GAAG,eAAe,MAAM,UAAU,MAAM;EACxC,YAAY,KAAK,YAAY,aAAa,SAAS,GAAG,UAAU;CAClE;AACF;;;ACMA,SAAgB,qBACd,MACA,MACmB;CACnB,OAAO;EACL;EACA,OAAO,YAAY,eAAe,MAAM,MAAM,OAAO;EACrD,OAAO,cAAc,eAAe,MAAM,MAAM,MAAM;EACtD,OAAO,aAAa,eAAe,MAAM,MAAM,QAAQ;EACvD,cAAc,eAAe,MAAM,MAAM,QAAQ;EACjD,cAAc,mBAAmB,MAAM,IAAI;CAC7C;AACF;;;ACmBA,SAAgB,eAA2C,MAAsC;CAC/F,OAAO;EACL,WAAW,SAAS,qBAAqB,MAAM,IAAI;EACnD,cAAc,aAAa,KAAK,YAAY,iBAAiB,UAAU;EACvE,oBAAoB,YAAY,KAAK,YAAY,sBAAsB,SAAS;EAChF,gBAAgB,cAAc,KAAK,YAAY,kBAAkB,WAAW;EAC5E,sBAAsB,WAAW,UAC/B,KAAK,YAAY,yBAAyB,UAAU,GAAG,OAAO;EAChE,sBAAsB,KAAK,YAAY,iBAAiB;EACxD,uBAAuB,eAAe,KAAK,YAAY,mBAAmB,YAAY;EACtF,cAAc;GACZ,aAAa,KAAK,YAAY,aAAa;GAC3C,gBAAgB,KAAK,YAAY,gBAAgB;GACjD,cAAc,KAAK,YAAY,cAAc;EAC/C;EACA,UAAU,UAAU;GAClB,YAAY,KAAK,YAAY,YAAY,MAAM;GAC/C,gBAAgB,KAAK,YAAY,qBAAqB,MAAM;GAC5D,UAAU,YAAY,KAAK,YAAY,oBAAoB,KAAK,GAAG,SAAS;EAC9E;EACA,eAAe,SAAS,KAAK,YAAY,gBAAgB,MAAM;EAC/D,gBAAgB,UAAU,OAAO,eAC/B,KAAK,YAAY,eAAe,SAAS,GAAG,MAAM,GAAG,WAAW,gBAAgB;EAClF;CACF;AACF;;;AC/EA,SAAgB,oBACd,MACA,UACA,OACA,YACwB;CACxB,OAAO;EACL,cAAc,KAAK,YAAY,yBAAyB,SAAS,GAAG,MAAM,GAAG,YAAY;EACzF,WAAW,KAAK,YAAY,sBAAsB,SAAS,GAAG,MAAM,GAAG,YAAY;EACnF,UAAU,KAAK,YAAY,qBAAqB,SAAS,GAAG,MAAM,GAAG,YAAY;CACnF;AACF;;;ACRA,MAAa,eAAe;AAC5B,MAAa,kBAAkB"}
@@ -13,7 +13,8 @@ function context(signal) {
13
13
  signal,
14
14
  requestContext: REQUEST_CONTEXT,
15
15
  session: SESSION,
16
- providerServiceKey: "contract-provider"
16
+ providerServiceKey: "contract-provider",
17
+ idempotencyKey: "contract-idempotency-key"
17
18
  });
18
19
  }
19
20
  function queryShape(kind, value) {
@@ -44,6 +45,15 @@ async function assertBatch(provider, fixture, operations, expected) {
44
45
  function dataError(value) {
45
46
  (0, vitest.expect)((0, _burojs_core.isDataError)(value)).toBe(true);
46
47
  }
48
+ function mutationExpectationRequired(specimen) {
49
+ if (specimen.lifecycle !== "mutation") return;
50
+ (0, vitest.expect)(specimen.expect, `the mutation.${specimen.kind} error specimen must declare \`expect: { kind }\` — a write's error kind decides whether a screen offers to send it again`).toBeDefined();
51
+ }
52
+ function errorExpectation(value, expected) {
53
+ if (expected === void 0) return;
54
+ (0, vitest.expect)(value.kind, `the error specimen declares kind '${expected.kind}' (code ${value.code})`).toBe(expected.kind);
55
+ if (expected.code !== void 0) (0, vitest.expect)(value.code).toBe(expected.code);
56
+ }
47
57
  function livePlan(value) {
48
58
  (0, vitest.expect)(value, "descriptors.live was supplied, so live.open must return a plan").not.toBeNull();
49
59
  (0, vitest.expect)(value).toBeTypeOf("object");
@@ -78,6 +88,8 @@ Object.freeze({
78
88
  batch: _burojs_core.validateBatchResult,
79
89
  mutation: _burojs_core.validateMutationResult,
80
90
  dataError,
91
+ errorExpectation,
92
+ mutationExpectationRequired,
81
93
  livePlan,
82
94
  liveDescriptorRequired,
83
95
  cursorDeclarationRequired,
@@ -110,7 +122,6 @@ function dataProviderContract(name, fixture) {
110
122
  });
111
123
  for (const kind of [
112
124
  "createOne",
113
- "createMany",
114
125
  "updateOne",
115
126
  "updateMany",
116
127
  "deleteOne",
@@ -132,23 +143,32 @@ function dataProviderContract(name, fixture) {
132
143
  descriptor
133
144
  }, controller.signal);
134
145
  });
135
- for (const specimen of fixture.descriptors.errors ?? []) (0, vitest.it)(`returns a structured error for the ${specimen.lifecycle}.${specimen.kind} error specimen`, async () => {
136
- const provider = await fixture.create();
137
- const controller = new AbortController();
138
- const handler = specimen.lifecycle === "query" ? provider.queries?.[specimen.kind] : provider.mutations?.[specimen.kind];
139
- (0, vitest.expect)(handler).toBeTypeOf("function");
140
- try {
141
- await handler(specimen.descriptor, context(controller.signal));
142
- throw new Error("Provider error specimen resolved instead of rejecting.");
143
- } catch (error) {
144
- dataError(error);
145
- }
146
- observed(fixture, {
147
- lifecycle: specimen.lifecycle,
148
- kind: specimen.kind,
149
- descriptor: specimen.descriptor
150
- }, controller.signal);
151
- });
146
+ for (const specimen of fixture.descriptors.errors ?? []) {
147
+ const expectation = specimen.expect === void 0 ? "" : ` (${specimen.expect.kind}${specimen.expect.code === void 0 ? "" : `/${specimen.expect.code}`})`;
148
+ (0, vitest.it)(`returns a structured error for the ${specimen.lifecycle}.${specimen.kind} error specimen${expectation}`, async () => {
149
+ mutationExpectationRequired(specimen);
150
+ const provider = await fixture.create();
151
+ const controller = new AbortController();
152
+ const handler = specimen.lifecycle === "query" ? provider.queries?.[specimen.kind] : provider.mutations?.[specimen.kind];
153
+ (0, vitest.expect)(handler).toBeTypeOf("function");
154
+ let rejection;
155
+ let resolved = false;
156
+ try {
157
+ await handler(specimen.descriptor, context(controller.signal));
158
+ resolved = true;
159
+ } catch (error) {
160
+ rejection = error;
161
+ }
162
+ (0, vitest.expect)(resolved, "Provider error specimen resolved instead of rejecting.").toBe(false);
163
+ dataError(rejection);
164
+ errorExpectation(rejection, specimen.expect);
165
+ observed(fixture, {
166
+ lifecycle: specimen.lifecycle,
167
+ kind: specimen.kind,
168
+ descriptor: specimen.descriptor
169
+ }, controller.signal);
170
+ });
171
+ }
152
172
  (0, vitest.it)("reports absent optional capabilities without invoking fallback handlers", async () => {
153
173
  const provider = await fixture.create();
154
174
  const before = fixture.observeCalls().length;
@@ -170,7 +190,6 @@ function dataProviderContract(name, fixture) {
170
190
  }
171
191
  for (const kind of [
172
192
  "createOne",
173
- "createMany",
174
193
  "updateOne",
175
194
  "updateMany",
176
195
  "deleteOne",
@@ -1 +1 @@
1
- {"version":3,"file":"provider-contract.cjs","names":["expect","isDataError","validateBatchResult","validateMutationResult","resolveDataCapability"],"sources":["../src/data-provider-contract.ts"],"sourcesContent":["import {\n type BatchedQueryOperation,\n type DataError,\n type DataProvider,\n type ErasedMutationDescriptor,\n type ErasedQueryDescriptor,\n type LiveObserver,\n isDataError,\n resolveDataCapability,\n validateBatchResult,\n validateMutationResult,\n} from '@burojs/core';\nimport { describe, expect, it } from 'vitest';\n\ntype QueryKind = ErasedQueryDescriptor['kind'];\ntype MutationKind = ErasedMutationDescriptor['kind'];\n\nexport interface ProviderObservedCall {\n readonly lifecycle: 'query' | 'mutation' | 'batch' | 'live-open' | 'live-subscribe';\n readonly kind?: QueryKind | MutationKind;\n readonly descriptor?: ErasedQueryDescriptor | ErasedMutationDescriptor;\n readonly descriptors?: readonly ErasedQueryDescriptor[];\n readonly signal?: AbortSignal;\n}\n\n/** Specimens supplied by an adapter author; unsupported optional methods are omitted. */\nexport interface ProviderContractDescriptors {\n readonly queries?: Partial<Record<QueryKind, ErasedQueryDescriptor>>;\n readonly mutations?: Partial<Record<MutationKind, ErasedMutationDescriptor>>;\n readonly batch?: readonly BatchedQueryOperation[];\n /** Independent batch groups prove that grouping is explicit, never merged by the helper. */\n readonly batchGroups?: readonly (readonly BatchedQueryOperation[])[];\n /** List specimens for both pagination protocols, including expected totals/pageInfo. */\n readonly pagination?: {\n readonly offset?: ErasedQueryDescriptor;\n readonly cursor?: ErasedQueryDescriptor;\n };\n readonly live?: ErasedQueryDescriptor;\n /** Error specimens must make the selected handler reject with a DataError. */\n readonly errors?: readonly ProviderContractErrorSpecimen[];\n /** Hand-authored mapped results; assertions never derive expectations from an adapter. */\n readonly expected?: {\n readonly queries?: Partial<Record<QueryKind, unknown>>;\n readonly mutations?: Partial<Record<MutationKind, unknown>>;\n readonly batch?: unknown;\n readonly pagination?: { readonly offset?: unknown; readonly cursor?: unknown };\n };\n}\n\nexport type ProviderContractErrorSpecimen =\n | {\n readonly lifecycle: 'query';\n readonly kind: QueryKind;\n readonly descriptor: ErasedQueryDescriptor;\n }\n | {\n readonly lifecycle: 'mutation';\n readonly kind: MutationKind;\n readonly descriptor: ErasedMutationDescriptor;\n };\n\nexport interface ProviderContractFixture {\n create(): DataProvider | Promise<DataProvider>;\n descriptors: ProviderContractDescriptors;\n observeCalls(): readonly ProviderObservedCall[];\n}\n\nconst SESSION = Object.freeze({\n sessionKey: 'contract-session',\n scopeKey: 'contract-scope',\n generation: 1,\n});\nconst REQUEST_CONTEXT = Object.freeze({ contract: 'provider' });\n\nfunction context(signal: AbortSignal) {\n return Object.freeze({\n signal,\n requestContext: REQUEST_CONTEXT,\n session: SESSION,\n providerServiceKey: 'contract-provider',\n });\n}\n\nfunction queryShape(kind: QueryKind, value: unknown): void {\n if (kind === 'custom') return;\n expect(value).toBeTypeOf('object');\n if (kind === 'list' || kind === 'many')\n expect((value as { data?: unknown }).data).toBeInstanceOf(Array);\n if (kind === 'one') expect(value).toHaveProperty('data');\n if (kind === 'count')\n expect([null, 'number']).toContain(typeof (value as { total?: unknown }).total);\n}\n\nfunction observed(\n fixture: ProviderContractFixture,\n expected: Omit<ProviderObservedCall, 'signal'>,\n signal: AbortSignal,\n): void {\n expect(fixture.observeCalls()).toContainEqual(expect.objectContaining({ ...expected, signal }));\n}\n\nasync function assertBatch(\n provider: DataProvider,\n fixture: ProviderContractFixture,\n operations: readonly BatchedQueryOperation[],\n expected: unknown,\n): Promise<void> {\n expect(provider.batch?.queries).toBeTypeOf('function');\n const controller = new AbortController();\n const result = await provider.batch!.queries(operations, context(controller.signal));\n validateBatchResult(operations, result);\n if (expected !== undefined) expect(result).toEqual(expected);\n for (const entry of result.results) if (entry.status === 'error') dataError(entry.error);\n observed(\n fixture,\n { lifecycle: 'batch', descriptors: operations.map((operation) => operation.descriptor) },\n controller.signal,\n );\n}\n\nfunction dataError(value: unknown): asserts value is DataError {\n expect(isDataError(value)).toBe(true);\n}\n\n/**\n * A supplied `descriptors.live` IS the adapter author's declaration that this\n * descriptor should produce a plan — an adapter with no live support omits the\n * descriptor (or the `live` slot) and is skipped instead. So a null plan here\n * is a failure, not a skip.\n *\n * Split out as its own assertion, rather than left inline, so the suite's own\n * tests can prove it rejects — the same reason `queryShape`/`observed`/`batch`\n * already are.\n */\nfunction livePlan(value: unknown): void {\n expect(value, 'descriptors.live was supplied, so live.open must return a plan').not.toBeNull();\n expect(value).toBeTypeOf('object');\n expect((value as { subscribe?: unknown } | null)?.subscribe).toBeTypeOf('function');\n}\n\n/**\n * The OTHER door into the same free green. `livePlan` closes \"supplied a\n * descriptor, returned a null plan\"; this closes \"provider has a `live` slot,\n * fixture supplied no descriptor\" — which used to `return` silently, leaving\n * the live cell green with nothing executed. Deleting the single line\n * `live: listDescriptor` from a fixture left `examples/marketplace-ops`'s\n * hasura contract at 35/35 green with the live `it` still passing.\n *\n * The rule is asymmetric on purpose: a provider with NO `live` slot is a\n * provider without live support, and skipping is right. A provider that HAS\n * one and a fixture that says nothing about it is an untested capability\n * reported as a pass.\n *\n * Split out for the same reason `livePlan` is: the suite's own tests drive it\n * directly to prove it rejects.\n */\nfunction liveDescriptorRequired(hasLiveSlot: boolean, descriptor: unknown): void {\n if (!hasLiveSlot) return;\n expect(\n descriptor,\n 'the provider exposes a `live` slot, so the fixture must supply `descriptors.live` — omitting it silently skips the whole live cell',\n ).toBeDefined();\n}\n\n/**\n * The THIRD door into a free green, and the one this suite carried longest:\n * the cursor pagination cell.\n *\n * It used to be `if (descriptor === undefined || ...) return;` — so a fixture\n * that simply said nothing about cursors got a pass for a cell titled\n * \"preserves cursor pagination\". MEASURED, not argued: with\n * `@burojs/hasura`'s keyset implementation sabotaged so that the keyset\n * predicate was dropped entirely — every cursor page returning page one,\n * forever — `examples/marketplace-ops/tests/hasura-provider-contract.test.ts`\n * stayed 35/35 green, cursor cell included.\n *\n * Unlike `live`, cursor support cannot be read off the provider object: there\n * is no `queries.listCursor` slot, only `queries.list` taking a pagination\n * shape it may or may not serve. So the rule is a DECLARATION rule instead —\n * a fixture whose provider lists must SAY which of the two it is:\n *\n * - `descriptors.pagination.cursor` — \"this provider serves cursor pages\",\n * and the cell below then holds it to that (`pageInfo`, and a second page\n * that is really a second page); or\n * - an `errors` specimen carrying a cursor list descriptor — \"this provider\n * refuses cursor pagination\", proven by execution.\n *\n * Silence is the one thing that is no longer allowed. Both demo fixtures\n * already chose the second form deliberately, which is exactly why the\n * vacuity survived so long: the suite looked adequately used.\n */\nfunction cursorDeclarationRequired(\n hasListSlot: boolean,\n descriptors: ProviderContractDescriptors,\n): void {\n if (!hasListSlot) return;\n const refusalSpecimen = (descriptors.errors ?? []).some(\n (specimen) =>\n specimen.lifecycle === 'query' &&\n specimen.kind === 'list' &&\n (specimen.descriptor as { pagination?: { kind?: unknown } }).pagination?.kind === 'cursor',\n );\n expect(\n descriptors.pagination?.cursor !== undefined || refusalSpecimen,\n 'the provider exposes a `list` slot, so the fixture must state its cursor-pagination position: supply `descriptors.pagination.cursor` for a provider that serves cursor pages, or an `errors` specimen holding a cursor list descriptor for one that refuses. Saying nothing used to pass this cell for a provider whose cursor paging was entirely broken.',\n ).toBe(true);\n}\n\n/**\n * What a cursor page must carry, beyond the `data` array every list shares.\n *\n * `pageInfo` is what makes a cursor list navigable at all — `nextCursor` is\n * the only thing a caller can hand back, and the framework's infinite-list\n * observer stops paging the moment it is absent. A provider that answered a\n * cursor descriptor with a bare `{data}` would render a single page and look\n * finished, which the old cell accepted.\n *\n * (Named without its package: this module is asserted to import nothing but\n * core contracts, and `tests/package.test.ts` greps the source for the\n * forbidden names.)\n */\nfunction cursorPageShape(value: unknown): void {\n const pageInfo = (value as { pageInfo?: { nextCursor?: unknown; hasNextPage?: unknown } })\n .pageInfo;\n expect(\n pageInfo,\n 'a cursor list must return `pageInfo` — without it there is nothing to page with',\n ).toBeTypeOf('object');\n const next = pageInfo?.nextCursor;\n if (next !== undefined) expect(next).toBeTypeOf('string');\n const has = pageInfo?.hasNextPage;\n if (has !== undefined) expect(has).toBeTypeOf('boolean');\n}\n\n/**\n * The first page of a supplied cursor descriptor must really BE a first page —\n * it has to mint a `nextCursor` the cell can then page with.\n *\n * The residual free-green door this closes (found by sabotage at the SP4 final\n * review, and reproducible: hard-code `hasNextPage = false` in\n * `@burojs/hasura`'s cursor branch). `cursorPageShape` only type-checks\n * `nextCursor`/`hasNextPage` WHEN PRESENT, and the cell's second-page half then\n * read `if (next === undefined) return;`. So a provider that minted no cursor\n * on page one skipped the entire \"the second page really differs\" half and\n * passed a cell named \"preserves cursor pagination\" — the same shape of vacuity\n * SP4 task 3 removed from this cell's outer skip, one level in.\n *\n * The rule is the same one `livePlan` applies to `descriptors.live`: supplying\n * `descriptors.pagination.cursor` is the AUTHOR'S DECLARATION that this\n * descriptor pages, so a provider answering it with a finished list is a\n * failure, not a skip. It is satisfied by sizing the descriptor's `limit` below\n * the fixture's row count — the fixture author controls both. A provider that\n * genuinely cannot mint a cursor does not serve cursor pages: declare the\n * `errors` refusal specimen instead, which `cursorDeclarationRequired` already\n * accepts as the other honest position.\n */\nfunction cursorPagesRequired(value: unknown): void {\n const pageInfo = (value as { pageInfo?: { nextCursor?: unknown; hasNextPage?: unknown } })\n .pageInfo;\n expect(\n pageInfo?.hasNextPage,\n 'a supplied `descriptors.pagination.cursor` declares that this descriptor PAGES, so its first page must report `hasNextPage: true`. Size the descriptor `limit` below the fixture row count; a provider that cannot page belongs in an `errors` refusal specimen instead. (A first page that reports itself finished used to skip the whole second-page half of this cell.)',\n ).toBe(true);\n const next = pageInfo?.nextCursor;\n expect(\n typeof next === 'string' && next.length > 0,\n 'a first page reporting `hasNextPage: true` must also mint a non-empty `nextCursor` — it is the only thing a caller can hand back, and without it the infinite-list observer stops paging',\n ).toBe(true);\n}\n\n/** @internal Direct assertion seam for the contract suite's intentionally broken-provider tests. */\nexport const __providerContractAssertions = Object.freeze({\n queryShape,\n observed,\n batch: validateBatchResult,\n mutation: validateMutationResult,\n dataError,\n livePlan,\n liveDescriptorRequired,\n cursorDeclarationRequired,\n cursorPageShape,\n cursorPagesRequired,\n});\n\n/**\n * Registers transport-agnostic provider conformance tests. It intentionally\n * imports only core contracts: adapters supply their own deterministic client\n * and expose provider-boundary observations through the fixture.\n */\nexport function dataProviderContract(name: string, fixture: ProviderContractFixture): void {\n describe(`${name} data provider contract`, () => {\n for (const kind of ['list', 'one', 'many', 'count', 'custom'] as const) {\n it(`preserves the ${kind} query descriptor and AbortSignal`, async () => {\n const provider = await fixture.create();\n const descriptor = fixture.descriptors.queries?.[kind];\n const handler = provider.queries?.[kind];\n if (descriptor === undefined || handler === undefined) return;\n const controller = new AbortController();\n const result = await handler(descriptor as never, context(controller.signal));\n queryShape(kind, result);\n const expected = fixture.descriptors.expected?.queries?.[kind];\n if (expected !== undefined) expect(result).toEqual(expected);\n observed(fixture, { lifecycle: 'query', kind, descriptor }, controller.signal);\n });\n }\n\n for (const kind of [\n 'createOne',\n 'createMany',\n 'updateOne',\n 'updateMany',\n 'deleteOne',\n 'deleteMany',\n 'custom',\n ] as const) {\n it(`validates ${kind} mutation output and preserves its request`, async () => {\n const provider = await fixture.create();\n const descriptor = fixture.descriptors.mutations?.[kind];\n const handler = provider.mutations?.[kind];\n if (descriptor === undefined || handler === undefined) return;\n const controller = new AbortController();\n const result = await handler(descriptor as never, context(controller.signal));\n validateMutationResult(descriptor, result);\n const expected = fixture.descriptors.expected?.mutations?.[kind];\n if (expected !== undefined) expect(result).toEqual(expected);\n observed(fixture, { lifecycle: 'mutation', kind, descriptor }, controller.signal);\n });\n }\n\n for (const specimen of fixture.descriptors.errors ?? []) {\n it(`returns a structured error for the ${specimen.lifecycle}.${specimen.kind} error specimen`, async () => {\n const provider = await fixture.create();\n const controller = new AbortController();\n const handler =\n specimen.lifecycle === 'query'\n ? provider.queries?.[specimen.kind as QueryKind]\n : provider.mutations?.[specimen.kind as MutationKind];\n expect(handler).toBeTypeOf('function');\n try {\n await (\n handler as (\n descriptor: never,\n operation: ReturnType<typeof context>,\n ) => Promise<unknown>\n )(specimen.descriptor as never, context(controller.signal));\n throw new Error('Provider error specimen resolved instead of rejecting.');\n } catch (error) {\n dataError(error);\n }\n observed(\n fixture,\n { lifecycle: specimen.lifecycle, kind: specimen.kind, descriptor: specimen.descriptor },\n controller.signal,\n );\n });\n }\n\n it('reports absent optional capabilities without invoking fallback handlers', async () => {\n const provider = await fixture.create();\n const before = fixture.observeCalls().length;\n for (const kind of ['list', 'one', 'many', 'count', 'custom'] as const) {\n if (provider.queries?.[kind] === undefined) {\n const capability = resolveDataCapability(provider, { lifecycle: 'query', kind });\n expect(capability).toMatchObject({ kind: 'capability', code: 'missing-data-capability' });\n }\n }\n for (const kind of [\n 'createOne',\n 'createMany',\n 'updateOne',\n 'updateMany',\n 'deleteOne',\n 'deleteMany',\n 'custom',\n ] as const) {\n if (provider.mutations?.[kind] === undefined) {\n const capability = resolveDataCapability(provider, { lifecycle: 'mutation', kind });\n expect(capability).toMatchObject({ kind: 'capability', code: 'missing-data-capability' });\n }\n }\n expect(fixture.observeCalls()).toHaveLength(before);\n });\n\n it('validates batch IDs, kinds, partial errors, and the shared AbortSignal', async () => {\n const provider = await fixture.create();\n const operations = fixture.descriptors.batch;\n if (provider.batch?.queries === undefined || operations === undefined) return;\n await assertBatch(provider, fixture, operations, fixture.descriptors.expected?.batch);\n });\n\n for (const [index, operations] of (fixture.descriptors.batchGroups ?? []).entries()) {\n it(`keeps batch group ${index + 1} isolated and validates its partial results`, async () => {\n const provider = await fixture.create();\n await assertBatch(provider, fixture, operations, undefined);\n });\n }\n\n it('preserves offset pagination and its mapped total/page shape', async () => {\n const provider = await fixture.create();\n const descriptor = fixture.descriptors.pagination?.offset;\n if (descriptor === undefined || provider.queries?.list === undefined) return;\n const controller = new AbortController();\n const result = await provider.queries.list(descriptor as never, context(controller.signal));\n queryShape('list', result);\n const expected = fixture.descriptors.expected?.pagination?.offset;\n if (expected !== undefined) expect(result).toEqual(expected);\n observed(fixture, { lifecycle: 'query', kind: 'list', descriptor }, controller.signal);\n });\n\n it('preserves cursor pagination, and pages with the cursor it minted', async () => {\n const provider = await fixture.create();\n const descriptor = fixture.descriptors.pagination?.cursor;\n // A fixture that says NOTHING about cursors fails here rather than\n // skipping — see `cursorDeclarationRequired`.\n cursorDeclarationRequired(provider.queries?.list !== undefined, fixture.descriptors);\n if (descriptor === undefined || provider.queries?.list === undefined) return;\n const controller = new AbortController();\n const result = await provider.queries.list(descriptor as never, context(controller.signal));\n queryShape('list', result);\n cursorPageShape(result);\n const expected = fixture.descriptors.expected?.pagination?.cursor;\n if (expected !== undefined) expect(result).toEqual(expected);\n observed(fixture, { lifecycle: 'query', kind: 'list', descriptor }, controller.signal);\n\n // The half a one-page assertion cannot reach: a provider that ACCEPTS\n // `after` and ignores it returns page one again, forever, and every\n // shape check above still passes. The second page is therefore fetched\n // and required to be a different, non-empty page.\n //\n // This used to read `if (next === undefined) return;`, which handed the\n // whole half back as a free green to any provider that minted no cursor\n // — see `cursorPagesRequired`, which is now what stands here.\n cursorPagesRequired(result);\n const next = (result as { pageInfo?: { nextCursor?: string } }).pageInfo?.nextCursor;\n if (next === undefined) return; // unreachable past `cursorPagesRequired`; narrows the type\n const second = new AbortController();\n const page2 = (await provider.queries.list(\n {\n ...(descriptor as unknown as Record<string, unknown>),\n pagination: {\n ...((descriptor as unknown as { pagination?: Record<string, unknown> }).pagination ??\n {}),\n after: next,\n },\n } as never,\n context(second.signal),\n )) as { data?: readonly unknown[] };\n expect(\n page2.data,\n 'a minted nextCursor promises a further page; the page it addresses must not be empty',\n ).not.toHaveLength(0);\n expect(\n page2.data?.[0],\n 'the page after a cursor must differ from the page that minted it — a provider ignoring `after` lands here',\n ).not.toEqual((result as { data?: readonly unknown[] }).data?.[0]);\n });\n\n it('opens live plans with the complete descriptor and subscribes with an AbortSignal', async () => {\n const provider = await fixture.create();\n const descriptor = fixture.descriptors.live;\n liveDescriptorRequired(provider.live !== undefined, descriptor);\n if (provider.live === undefined || descriptor === undefined) return;\n const openController = new AbortController();\n const plan = await provider.live.open(descriptor, context(openController.signal));\n observed(fixture, { lifecycle: 'live-open', descriptor }, openController.signal);\n // A null plan used to `return` here, which handed every adapter a free\n // green on the whole live cell: `subscribe` was never reached, so\n // nothing about the plan was ever checked, and the `it` passed for an\n // adapter that had done no live work at all.\n livePlan(plan);\n if (plan === null) return; // unreachable past `livePlan`; narrows the type\n const subscribeController = new AbortController();\n const observer: LiveObserver = {\n next: () => undefined,\n status: () => undefined,\n error: () => undefined,\n complete: () => undefined,\n };\n const unsubscribe = await plan.subscribe(observer, { signal: subscribeController.signal });\n observed(fixture, { lifecycle: 'live-subscribe' }, subscribeController.signal);\n await unsubscribe();\n });\n });\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"provider-contract.cjs","names":["expect","isDataError","validateBatchResult","validateMutationResult","resolveDataCapability"],"sources":["../src/data-provider-contract.ts"],"sourcesContent":["import {\n type BatchedQueryOperation,\n type DataError,\n type DataErrorKind,\n type DataProvider,\n type ErasedMutationDescriptor,\n type ErasedQueryDescriptor,\n type LiveObserver,\n isDataError,\n resolveDataCapability,\n validateBatchResult,\n validateMutationResult,\n} from '@burojs/core';\nimport { describe, expect, it } from 'vitest';\n\ntype QueryKind = ErasedQueryDescriptor['kind'];\ntype MutationKind = ErasedMutationDescriptor['kind'];\n\nexport interface ProviderObservedCall {\n readonly lifecycle: 'query' | 'mutation' | 'batch' | 'live-open' | 'live-subscribe';\n readonly kind?: QueryKind | MutationKind;\n readonly descriptor?: ErasedQueryDescriptor | ErasedMutationDescriptor;\n readonly descriptors?: readonly ErasedQueryDescriptor[];\n readonly signal?: AbortSignal;\n}\n\n/** Specimens supplied by an adapter author; unsupported optional methods are omitted. */\nexport interface ProviderContractDescriptors {\n readonly queries?: Partial<Record<QueryKind, ErasedQueryDescriptor>>;\n readonly mutations?: Partial<Record<MutationKind, ErasedMutationDescriptor>>;\n readonly batch?: readonly BatchedQueryOperation[];\n /** Independent batch groups prove that grouping is explicit, never merged by the helper. */\n readonly batchGroups?: readonly (readonly BatchedQueryOperation[])[];\n /** List specimens for both pagination protocols, including expected totals/pageInfo. */\n readonly pagination?: {\n readonly offset?: ErasedQueryDescriptor;\n readonly cursor?: ErasedQueryDescriptor;\n };\n readonly live?: ErasedQueryDescriptor;\n /** Error specimens must make the selected handler reject with a DataError. */\n readonly errors?: readonly ProviderContractErrorSpecimen[];\n /** Hand-authored mapped results; assertions never derive expectations from an adapter. */\n readonly expected?: {\n readonly queries?: Partial<Record<QueryKind, unknown>>;\n readonly mutations?: Partial<Record<MutationKind, unknown>>;\n readonly batch?: unknown;\n readonly pagination?: { readonly offset?: unknown; readonly cursor?: unknown };\n };\n}\n\n/**\n * What an error specimen must reject with, beyond being a structural DataError. A fixture uses it\n * to hold its provider to the error kinds the framework decides on — above all the `sent`\n * obligation: a mutation that reached the backend and got no readable answer (a gateway 5xx, an\n * unreadable 2xx) must reject as `sent`, never as `transport`, or a screen offers a retry that\n * can apply the write twice.\n */\nexport interface ProviderContractErrorExpectation {\n readonly kind: DataErrorKind;\n readonly code?: string;\n}\n\nexport type ProviderContractErrorSpecimen =\n | {\n readonly lifecycle: 'query';\n readonly kind: QueryKind;\n readonly descriptor: ErasedQueryDescriptor;\n readonly expect?: ProviderContractErrorExpectation;\n }\n | {\n readonly lifecycle: 'mutation';\n readonly kind: MutationKind;\n readonly descriptor: ErasedMutationDescriptor;\n /**\n * Required on a write. The dangerous direction is a lost or garbled answer reported as a\n * refusal (`validation`, `conflict`, `forbidden`, `not-found`): a screen reads \"nothing\n * was applied\" and offers to send again. A write specimen that does not say what it must\n * reject with cannot catch that, so every one says.\n */\n readonly expect: ProviderContractErrorExpectation;\n };\n\nexport interface ProviderContractFixture {\n create(): DataProvider | Promise<DataProvider>;\n descriptors: ProviderContractDescriptors;\n observeCalls(): readonly ProviderObservedCall[];\n}\n\nconst SESSION = Object.freeze({\n sessionKey: 'contract-session',\n scopeKey: 'contract-scope',\n generation: 1,\n});\nconst REQUEST_CONTEXT = Object.freeze({ contract: 'provider' });\n\nfunction context(signal: AbortSignal) {\n return Object.freeze({\n signal,\n requestContext: REQUEST_CONTEXT,\n session: SESSION,\n providerServiceKey: 'contract-provider',\n idempotencyKey: 'contract-idempotency-key',\n });\n}\n\nfunction queryShape(kind: QueryKind, value: unknown): void {\n if (kind === 'custom') return;\n expect(value).toBeTypeOf('object');\n if (kind === 'list' || kind === 'many')\n expect((value as { data?: unknown }).data).toBeInstanceOf(Array);\n if (kind === 'one') expect(value).toHaveProperty('data');\n if (kind === 'count')\n expect([null, 'number']).toContain(typeof (value as { total?: unknown }).total);\n}\n\nfunction observed(\n fixture: ProviderContractFixture,\n expected: Omit<ProviderObservedCall, 'signal'>,\n signal: AbortSignal,\n): void {\n expect(fixture.observeCalls()).toContainEqual(expect.objectContaining({ ...expected, signal }));\n}\n\nasync function assertBatch(\n provider: DataProvider,\n fixture: ProviderContractFixture,\n operations: readonly BatchedQueryOperation[],\n expected: unknown,\n): Promise<void> {\n expect(provider.batch?.queries).toBeTypeOf('function');\n const controller = new AbortController();\n const result = await provider.batch!.queries(operations, context(controller.signal));\n validateBatchResult(operations, result);\n if (expected !== undefined) expect(result).toEqual(expected);\n for (const entry of result.results) if (entry.status === 'error') dataError(entry.error);\n observed(\n fixture,\n { lifecycle: 'batch', descriptors: operations.map((operation) => operation.descriptor) },\n controller.signal,\n );\n}\n\nfunction dataError(value: unknown): asserts value is DataError {\n expect(isDataError(value)).toBe(true);\n}\n\n/**\n * A write specimen must declare its `expect`. The type requires it; this holds a JavaScript\n * fixture, or one built through a cast, to the same rule.\n */\nfunction mutationExpectationRequired(specimen: ProviderContractErrorSpecimen): void {\n if (specimen.lifecycle !== 'mutation') return;\n expect(\n (specimen as { expect?: unknown }).expect,\n `the mutation.${specimen.kind} error specimen must declare \\`expect: { kind }\\` — a write's error kind decides whether a screen offers to send it again`,\n ).toBeDefined();\n}\n\n/** A specimen's `expect`, when it has one: the kind (and the code, when given) must match. */\nfunction errorExpectation(\n value: DataError,\n expected: ProviderContractErrorExpectation | undefined,\n): void {\n if (expected === undefined) return;\n expect(\n value.kind,\n `the error specimen declares kind '${expected.kind}' (code ${value.code})`,\n ).toBe(expected.kind);\n if (expected.code !== undefined) expect(value.code).toBe(expected.code);\n}\n\n/**\n * A supplied `descriptors.live` IS the adapter author's declaration that this\n * descriptor should produce a plan — an adapter with no live support omits the\n * descriptor (or the `live` slot) and is skipped instead. So a null plan here\n * is a failure, not a skip.\n *\n * Split out as its own assertion, rather than left inline, so the suite's own\n * tests can prove it rejects — the same reason `queryShape`/`observed`/`batch`\n * already are.\n */\nfunction livePlan(value: unknown): void {\n expect(value, 'descriptors.live was supplied, so live.open must return a plan').not.toBeNull();\n expect(value).toBeTypeOf('object');\n expect((value as { subscribe?: unknown } | null)?.subscribe).toBeTypeOf('function');\n}\n\n/**\n * The OTHER door into the same free green. `livePlan` closes \"supplied a\n * descriptor, returned a null plan\"; this closes \"provider has a `live` slot,\n * fixture supplied no descriptor\" — which used to `return` silently, leaving\n * the live cell green with nothing executed. Deleting the single line\n * `live: listDescriptor` from a fixture left `examples/marketplace-ops`'s\n * hasura contract at 35/35 green with the live `it` still passing.\n *\n * The rule is asymmetric on purpose: a provider with NO `live` slot is a\n * provider without live support, and skipping is right. A provider that HAS\n * one and a fixture that says nothing about it is an untested capability\n * reported as a pass.\n *\n * Split out for the same reason `livePlan` is: the suite's own tests drive it\n * directly to prove it rejects.\n */\nfunction liveDescriptorRequired(hasLiveSlot: boolean, descriptor: unknown): void {\n if (!hasLiveSlot) return;\n expect(\n descriptor,\n 'the provider exposes a `live` slot, so the fixture must supply `descriptors.live` — omitting it silently skips the whole live cell',\n ).toBeDefined();\n}\n\n/**\n * The THIRD door into a free green, and the one this suite carried longest:\n * the cursor pagination cell.\n *\n * It used to be `if (descriptor === undefined || ...) return;` — so a fixture\n * that simply said nothing about cursors got a pass for a cell titled\n * \"preserves cursor pagination\". MEASURED, not argued: with\n * `@burojs/hasura`'s keyset implementation sabotaged so that the keyset\n * predicate was dropped entirely — every cursor page returning page one,\n * forever — `examples/marketplace-ops/tests/hasura-provider-contract.test.ts`\n * stayed 35/35 green, cursor cell included.\n *\n * Unlike `live`, cursor support cannot be read off the provider object: there\n * is no `queries.listCursor` slot, only `queries.list` taking a pagination\n * shape it may or may not serve. So the rule is a DECLARATION rule instead —\n * a fixture whose provider lists must SAY which of the two it is:\n *\n * - `descriptors.pagination.cursor` — \"this provider serves cursor pages\",\n * and the cell below then holds it to that (`pageInfo`, and a second page\n * that is really a second page); or\n * - an `errors` specimen carrying a cursor list descriptor — \"this provider\n * refuses cursor pagination\", proven by execution.\n *\n * Silence is the one thing that is no longer allowed. Both demo fixtures\n * already chose the second form deliberately, which is exactly why the\n * vacuity survived so long: the suite looked adequately used.\n */\nfunction cursorDeclarationRequired(\n hasListSlot: boolean,\n descriptors: ProviderContractDescriptors,\n): void {\n if (!hasListSlot) return;\n const refusalSpecimen = (descriptors.errors ?? []).some(\n (specimen) =>\n specimen.lifecycle === 'query' &&\n specimen.kind === 'list' &&\n (specimen.descriptor as { pagination?: { kind?: unknown } }).pagination?.kind === 'cursor',\n );\n expect(\n descriptors.pagination?.cursor !== undefined || refusalSpecimen,\n 'the provider exposes a `list` slot, so the fixture must state its cursor-pagination position: supply `descriptors.pagination.cursor` for a provider that serves cursor pages, or an `errors` specimen holding a cursor list descriptor for one that refuses. Saying nothing used to pass this cell for a provider whose cursor paging was entirely broken.',\n ).toBe(true);\n}\n\n/**\n * What a cursor page must carry, beyond the `data` array every list shares.\n *\n * `pageInfo` is what makes a cursor list navigable at all — `nextCursor` is\n * the only thing a caller can hand back, and the framework's infinite-list\n * observer stops paging the moment it is absent. A provider that answered a\n * cursor descriptor with a bare `{data}` would render a single page and look\n * finished, which the old cell accepted.\n *\n * (Named without its package: this module is asserted to import nothing but\n * core contracts, and `tests/package.test.ts` greps the source for the\n * forbidden names.)\n */\nfunction cursorPageShape(value: unknown): void {\n const pageInfo = (value as { pageInfo?: { nextCursor?: unknown; hasNextPage?: unknown } })\n .pageInfo;\n expect(\n pageInfo,\n 'a cursor list must return `pageInfo` — without it there is nothing to page with',\n ).toBeTypeOf('object');\n const next = pageInfo?.nextCursor;\n if (next !== undefined) expect(next).toBeTypeOf('string');\n const has = pageInfo?.hasNextPage;\n if (has !== undefined) expect(has).toBeTypeOf('boolean');\n}\n\n/**\n * The first page of a supplied cursor descriptor must really BE a first page —\n * it has to mint a `nextCursor` the cell can then page with.\n *\n * The residual free-green door this closes (found by sabotage at the SP4 final\n * review, and reproducible: hard-code `hasNextPage = false` in\n * `@burojs/hasura`'s cursor branch). `cursorPageShape` only type-checks\n * `nextCursor`/`hasNextPage` WHEN PRESENT, and the cell's second-page half then\n * read `if (next === undefined) return;`. So a provider that minted no cursor\n * on page one skipped the entire \"the second page really differs\" half and\n * passed a cell named \"preserves cursor pagination\" — the same shape of vacuity\n * SP4 task 3 removed from this cell's outer skip, one level in.\n *\n * The rule is the same one `livePlan` applies to `descriptors.live`: supplying\n * `descriptors.pagination.cursor` is the AUTHOR'S DECLARATION that this\n * descriptor pages, so a provider answering it with a finished list is a\n * failure, not a skip. It is satisfied by sizing the descriptor's `limit` below\n * the fixture's row count — the fixture author controls both. A provider that\n * genuinely cannot mint a cursor does not serve cursor pages: declare the\n * `errors` refusal specimen instead, which `cursorDeclarationRequired` already\n * accepts as the other honest position.\n */\nfunction cursorPagesRequired(value: unknown): void {\n const pageInfo = (value as { pageInfo?: { nextCursor?: unknown; hasNextPage?: unknown } })\n .pageInfo;\n expect(\n pageInfo?.hasNextPage,\n 'a supplied `descriptors.pagination.cursor` declares that this descriptor PAGES, so its first page must report `hasNextPage: true`. Size the descriptor `limit` below the fixture row count; a provider that cannot page belongs in an `errors` refusal specimen instead. (A first page that reports itself finished used to skip the whole second-page half of this cell.)',\n ).toBe(true);\n const next = pageInfo?.nextCursor;\n expect(\n typeof next === 'string' && next.length > 0,\n 'a first page reporting `hasNextPage: true` must also mint a non-empty `nextCursor` — it is the only thing a caller can hand back, and without it the infinite-list observer stops paging',\n ).toBe(true);\n}\n\n/** @internal Direct assertion seam for the contract suite's intentionally broken-provider tests. */\nexport const __providerContractAssertions = Object.freeze({\n queryShape,\n observed,\n batch: validateBatchResult,\n mutation: validateMutationResult,\n dataError,\n errorExpectation,\n mutationExpectationRequired,\n livePlan,\n liveDescriptorRequired,\n cursorDeclarationRequired,\n cursorPageShape,\n cursorPagesRequired,\n});\n\n/**\n * Registers transport-agnostic provider conformance tests. It intentionally\n * imports only core contracts: adapters supply their own deterministic client\n * and expose provider-boundary observations through the fixture.\n */\nexport function dataProviderContract(name: string, fixture: ProviderContractFixture): void {\n describe(`${name} data provider contract`, () => {\n for (const kind of ['list', 'one', 'many', 'count', 'custom'] as const) {\n it(`preserves the ${kind} query descriptor and AbortSignal`, async () => {\n const provider = await fixture.create();\n const descriptor = fixture.descriptors.queries?.[kind];\n const handler = provider.queries?.[kind];\n if (descriptor === undefined || handler === undefined) return;\n const controller = new AbortController();\n const result = await handler(descriptor as never, context(controller.signal));\n queryShape(kind, result);\n const expected = fixture.descriptors.expected?.queries?.[kind];\n if (expected !== undefined) expect(result).toEqual(expected);\n observed(fixture, { lifecycle: 'query', kind, descriptor }, controller.signal);\n });\n }\n\n for (const kind of [\n 'createOne',\n 'updateOne',\n 'updateMany',\n 'deleteOne',\n 'deleteMany',\n 'custom',\n ] as const) {\n it(`validates ${kind} mutation output and preserves its request`, async () => {\n const provider = await fixture.create();\n const descriptor = fixture.descriptors.mutations?.[kind];\n const handler = provider.mutations?.[kind];\n if (descriptor === undefined || handler === undefined) return;\n const controller = new AbortController();\n const result = await handler(descriptor as never, context(controller.signal));\n validateMutationResult(descriptor, result);\n const expected = fixture.descriptors.expected?.mutations?.[kind];\n if (expected !== undefined) expect(result).toEqual(expected);\n observed(fixture, { lifecycle: 'mutation', kind, descriptor }, controller.signal);\n });\n }\n\n for (const specimen of fixture.descriptors.errors ?? []) {\n const expectation =\n specimen.expect === undefined\n ? ''\n : ` (${specimen.expect.kind}${specimen.expect.code === undefined ? '' : `/${specimen.expect.code}`})`;\n it(`returns a structured error for the ${specimen.lifecycle}.${specimen.kind} error specimen${expectation}`, async () => {\n mutationExpectationRequired(specimen);\n const provider = await fixture.create();\n const controller = new AbortController();\n const handler =\n specimen.lifecycle === 'query'\n ? provider.queries?.[specimen.kind as QueryKind]\n : provider.mutations?.[specimen.kind as MutationKind];\n expect(handler).toBeTypeOf('function');\n let rejection: unknown;\n let resolved = false;\n try {\n await (\n handler as (\n descriptor: never,\n operation: ReturnType<typeof context>,\n ) => Promise<unknown>\n )(specimen.descriptor as never, context(controller.signal));\n resolved = true;\n } catch (error) {\n rejection = error;\n }\n expect(resolved, 'Provider error specimen resolved instead of rejecting.').toBe(false);\n dataError(rejection);\n errorExpectation(rejection, specimen.expect);\n observed(\n fixture,\n { lifecycle: specimen.lifecycle, kind: specimen.kind, descriptor: specimen.descriptor },\n controller.signal,\n );\n });\n }\n\n it('reports absent optional capabilities without invoking fallback handlers', async () => {\n const provider = await fixture.create();\n const before = fixture.observeCalls().length;\n for (const kind of ['list', 'one', 'many', 'count', 'custom'] as const) {\n if (provider.queries?.[kind] === undefined) {\n const capability = resolveDataCapability(provider, { lifecycle: 'query', kind });\n expect(capability).toMatchObject({ kind: 'capability', code: 'missing-data-capability' });\n }\n }\n for (const kind of [\n 'createOne',\n 'updateOne',\n 'updateMany',\n 'deleteOne',\n 'deleteMany',\n 'custom',\n ] as const) {\n if (provider.mutations?.[kind] === undefined) {\n const capability = resolveDataCapability(provider, { lifecycle: 'mutation', kind });\n expect(capability).toMatchObject({ kind: 'capability', code: 'missing-data-capability' });\n }\n }\n expect(fixture.observeCalls()).toHaveLength(before);\n });\n\n it('validates batch IDs, kinds, partial errors, and the shared AbortSignal', async () => {\n const provider = await fixture.create();\n const operations = fixture.descriptors.batch;\n if (provider.batch?.queries === undefined || operations === undefined) return;\n await assertBatch(provider, fixture, operations, fixture.descriptors.expected?.batch);\n });\n\n for (const [index, operations] of (fixture.descriptors.batchGroups ?? []).entries()) {\n it(`keeps batch group ${index + 1} isolated and validates its partial results`, async () => {\n const provider = await fixture.create();\n await assertBatch(provider, fixture, operations, undefined);\n });\n }\n\n it('preserves offset pagination and its mapped total/page shape', async () => {\n const provider = await fixture.create();\n const descriptor = fixture.descriptors.pagination?.offset;\n if (descriptor === undefined || provider.queries?.list === undefined) return;\n const controller = new AbortController();\n const result = await provider.queries.list(descriptor as never, context(controller.signal));\n queryShape('list', result);\n const expected = fixture.descriptors.expected?.pagination?.offset;\n if (expected !== undefined) expect(result).toEqual(expected);\n observed(fixture, { lifecycle: 'query', kind: 'list', descriptor }, controller.signal);\n });\n\n it('preserves cursor pagination, and pages with the cursor it minted', async () => {\n const provider = await fixture.create();\n const descriptor = fixture.descriptors.pagination?.cursor;\n // A fixture that says NOTHING about cursors fails here rather than\n // skipping — see `cursorDeclarationRequired`.\n cursorDeclarationRequired(provider.queries?.list !== undefined, fixture.descriptors);\n if (descriptor === undefined || provider.queries?.list === undefined) return;\n const controller = new AbortController();\n const result = await provider.queries.list(descriptor as never, context(controller.signal));\n queryShape('list', result);\n cursorPageShape(result);\n const expected = fixture.descriptors.expected?.pagination?.cursor;\n if (expected !== undefined) expect(result).toEqual(expected);\n observed(fixture, { lifecycle: 'query', kind: 'list', descriptor }, controller.signal);\n\n // The half a one-page assertion cannot reach: a provider that ACCEPTS\n // `after` and ignores it returns page one again, forever, and every\n // shape check above still passes. The second page is therefore fetched\n // and required to be a different, non-empty page.\n //\n // This used to read `if (next === undefined) return;`, which handed the\n // whole half back as a free green to any provider that minted no cursor\n // — see `cursorPagesRequired`, which is now what stands here.\n cursorPagesRequired(result);\n const next = (result as { pageInfo?: { nextCursor?: string } }).pageInfo?.nextCursor;\n if (next === undefined) return; // unreachable past `cursorPagesRequired`; narrows the type\n const second = new AbortController();\n const page2 = (await provider.queries.list(\n {\n ...(descriptor as unknown as Record<string, unknown>),\n pagination: {\n ...((descriptor as unknown as { pagination?: Record<string, unknown> }).pagination ??\n {}),\n after: next,\n },\n } as never,\n context(second.signal),\n )) as { data?: readonly unknown[] };\n expect(\n page2.data,\n 'a minted nextCursor promises a further page; the page it addresses must not be empty',\n ).not.toHaveLength(0);\n expect(\n page2.data?.[0],\n 'the page after a cursor must differ from the page that minted it — a provider ignoring `after` lands here',\n ).not.toEqual((result as { data?: readonly unknown[] }).data?.[0]);\n });\n\n it('opens live plans with the complete descriptor and subscribes with an AbortSignal', async () => {\n const provider = await fixture.create();\n const descriptor = fixture.descriptors.live;\n liveDescriptorRequired(provider.live !== undefined, descriptor);\n if (provider.live === undefined || descriptor === undefined) return;\n const openController = new AbortController();\n const plan = await provider.live.open(descriptor, context(openController.signal));\n observed(fixture, { lifecycle: 'live-open', descriptor }, openController.signal);\n // A null plan used to `return` here, which handed every adapter a free\n // green on the whole live cell: `subscribe` was never reached, so\n // nothing about the plan was ever checked, and the `it` passed for an\n // adapter that had done no live work at all.\n livePlan(plan);\n if (plan === null) return; // unreachable past `livePlan`; narrows the type\n const subscribeController = new AbortController();\n const observer: LiveObserver = {\n next: () => undefined,\n status: () => undefined,\n error: () => undefined,\n complete: () => undefined,\n };\n const unsubscribe = await plan.subscribe(observer, { signal: subscribeController.signal });\n observed(fixture, { lifecycle: 'live-subscribe' }, subscribeController.signal);\n await unsubscribe();\n });\n });\n}\n"],"mappings":"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"}
@@ -1,4 +1,4 @@
1
- import { BatchedQueryOperation, DataProvider, ErasedMutationDescriptor, ErasedQueryDescriptor } from "@burojs/core";
1
+ import { BatchedQueryOperation, DataErrorKind, DataProvider, ErasedMutationDescriptor, ErasedQueryDescriptor } from "@burojs/core";
2
2
  //#region src/data-provider-contract.d.ts
3
3
  type QueryKind = ErasedQueryDescriptor['kind'];
4
4
  type MutationKind = ErasedMutationDescriptor['kind'];
@@ -35,14 +35,33 @@ interface ProviderContractDescriptors {
35
35
  };
36
36
  };
37
37
  }
38
+ /**
39
+ * What an error specimen must reject with, beyond being a structural DataError. A fixture uses it
40
+ * to hold its provider to the error kinds the framework decides on — above all the `sent`
41
+ * obligation: a mutation that reached the backend and got no readable answer (a gateway 5xx, an
42
+ * unreadable 2xx) must reject as `sent`, never as `transport`, or a screen offers a retry that
43
+ * can apply the write twice.
44
+ */
45
+ interface ProviderContractErrorExpectation {
46
+ readonly kind: DataErrorKind;
47
+ readonly code?: string;
48
+ }
38
49
  type ProviderContractErrorSpecimen = {
39
50
  readonly lifecycle: 'query';
40
51
  readonly kind: QueryKind;
41
52
  readonly descriptor: ErasedQueryDescriptor;
53
+ readonly expect?: ProviderContractErrorExpectation;
42
54
  } | {
43
55
  readonly lifecycle: 'mutation';
44
56
  readonly kind: MutationKind;
45
57
  readonly descriptor: ErasedMutationDescriptor;
58
+ /**
59
+ * Required on a write. The dangerous direction is a lost or garbled answer reported as a
60
+ * refusal (`validation`, `conflict`, `forbidden`, `not-found`): a screen reads "nothing
61
+ * was applied" and offers to send again. A write specimen that does not say what it must
62
+ * reject with cannot catch that, so every one says.
63
+ */
64
+ readonly expect: ProviderContractErrorExpectation;
46
65
  };
47
66
  interface ProviderContractFixture {
48
67
  create(): DataProvider | Promise<DataProvider>;
@@ -56,5 +75,5 @@ interface ProviderContractFixture {
56
75
  */
57
76
  declare function dataProviderContract(name: string, fixture: ProviderContractFixture): void;
58
77
  //#endregion
59
- export { type ProviderContractDescriptors, type ProviderContractFixture, type ProviderObservedCall, dataProviderContract };
78
+ export { type ProviderContractDescriptors, type ProviderContractErrorExpectation, type ProviderContractErrorSpecimen, type ProviderContractFixture, type ProviderObservedCall, dataProviderContract };
60
79
  //# sourceMappingURL=provider-contract.d.cts.map
@@ -1,4 +1,4 @@
1
- import { BatchedQueryOperation, DataProvider, ErasedMutationDescriptor, ErasedQueryDescriptor, validateBatchResult, validateMutationResult } from "@burojs/core";
1
+ import { BatchedQueryOperation, DataErrorKind, DataProvider, ErasedMutationDescriptor, ErasedQueryDescriptor, validateBatchResult, validateMutationResult } from "@burojs/core";
2
2
  //#region src/data-provider-contract.d.ts
3
3
  type QueryKind = ErasedQueryDescriptor['kind'];
4
4
  type MutationKind = ErasedMutationDescriptor['kind'];
@@ -35,14 +35,33 @@ interface ProviderContractDescriptors {
35
35
  };
36
36
  };
37
37
  }
38
+ /**
39
+ * What an error specimen must reject with, beyond being a structural DataError. A fixture uses it
40
+ * to hold its provider to the error kinds the framework decides on — above all the `sent`
41
+ * obligation: a mutation that reached the backend and got no readable answer (a gateway 5xx, an
42
+ * unreadable 2xx) must reject as `sent`, never as `transport`, or a screen offers a retry that
43
+ * can apply the write twice.
44
+ */
45
+ interface ProviderContractErrorExpectation {
46
+ readonly kind: DataErrorKind;
47
+ readonly code?: string;
48
+ }
38
49
  type ProviderContractErrorSpecimen = {
39
50
  readonly lifecycle: 'query';
40
51
  readonly kind: QueryKind;
41
52
  readonly descriptor: ErasedQueryDescriptor;
53
+ readonly expect?: ProviderContractErrorExpectation;
42
54
  } | {
43
55
  readonly lifecycle: 'mutation';
44
56
  readonly kind: MutationKind;
45
57
  readonly descriptor: ErasedMutationDescriptor;
58
+ /**
59
+ * Required on a write. The dangerous direction is a lost or garbled answer reported as a
60
+ * refusal (`validation`, `conflict`, `forbidden`, `not-found`): a screen reads "nothing
61
+ * was applied" and offers to send again. A write specimen that does not say what it must
62
+ * reject with cannot catch that, so every one says.
63
+ */
64
+ readonly expect: ProviderContractErrorExpectation;
46
65
  };
47
66
  interface ProviderContractFixture {
48
67
  create(): DataProvider | Promise<DataProvider>;
@@ -56,5 +75,5 @@ interface ProviderContractFixture {
56
75
  */
57
76
  declare function dataProviderContract(name: string, fixture: ProviderContractFixture): void;
58
77
  //#endregion
59
- export { type ProviderContractDescriptors, type ProviderContractFixture, type ProviderObservedCall, dataProviderContract };
78
+ export { type ProviderContractDescriptors, type ProviderContractErrorExpectation, type ProviderContractErrorSpecimen, type ProviderContractFixture, type ProviderObservedCall, dataProviderContract };
60
79
  //# sourceMappingURL=provider-contract.d.ts.map
@@ -12,7 +12,8 @@ function context(signal) {
12
12
  signal,
13
13
  requestContext: REQUEST_CONTEXT,
14
14
  session: SESSION,
15
- providerServiceKey: "contract-provider"
15
+ providerServiceKey: "contract-provider",
16
+ idempotencyKey: "contract-idempotency-key"
16
17
  });
17
18
  }
18
19
  function queryShape(kind, value) {
@@ -43,6 +44,15 @@ async function assertBatch(provider, fixture, operations, expected) {
43
44
  function dataError(value) {
44
45
  expect(isDataError(value)).toBe(true);
45
46
  }
47
+ function mutationExpectationRequired(specimen) {
48
+ if (specimen.lifecycle !== "mutation") return;
49
+ expect(specimen.expect, `the mutation.${specimen.kind} error specimen must declare \`expect: { kind }\` — a write's error kind decides whether a screen offers to send it again`).toBeDefined();
50
+ }
51
+ function errorExpectation(value, expected) {
52
+ if (expected === void 0) return;
53
+ expect(value.kind, `the error specimen declares kind '${expected.kind}' (code ${value.code})`).toBe(expected.kind);
54
+ if (expected.code !== void 0) expect(value.code).toBe(expected.code);
55
+ }
46
56
  function livePlan(value) {
47
57
  expect(value, "descriptors.live was supplied, so live.open must return a plan").not.toBeNull();
48
58
  expect(value).toBeTypeOf("object");
@@ -77,6 +87,8 @@ Object.freeze({
77
87
  batch: validateBatchResult,
78
88
  mutation: validateMutationResult,
79
89
  dataError,
90
+ errorExpectation,
91
+ mutationExpectationRequired,
80
92
  livePlan,
81
93
  liveDescriptorRequired,
82
94
  cursorDeclarationRequired,
@@ -109,7 +121,6 @@ function dataProviderContract(name, fixture) {
109
121
  });
110
122
  for (const kind of [
111
123
  "createOne",
112
- "createMany",
113
124
  "updateOne",
114
125
  "updateMany",
115
126
  "deleteOne",
@@ -131,23 +142,32 @@ function dataProviderContract(name, fixture) {
131
142
  descriptor
132
143
  }, controller.signal);
133
144
  });
134
- for (const specimen of fixture.descriptors.errors ?? []) it(`returns a structured error for the ${specimen.lifecycle}.${specimen.kind} error specimen`, async () => {
135
- const provider = await fixture.create();
136
- const controller = new AbortController();
137
- const handler = specimen.lifecycle === "query" ? provider.queries?.[specimen.kind] : provider.mutations?.[specimen.kind];
138
- expect(handler).toBeTypeOf("function");
139
- try {
140
- await handler(specimen.descriptor, context(controller.signal));
141
- throw new Error("Provider error specimen resolved instead of rejecting.");
142
- } catch (error) {
143
- dataError(error);
144
- }
145
- observed(fixture, {
146
- lifecycle: specimen.lifecycle,
147
- kind: specimen.kind,
148
- descriptor: specimen.descriptor
149
- }, controller.signal);
150
- });
145
+ for (const specimen of fixture.descriptors.errors ?? []) {
146
+ const expectation = specimen.expect === void 0 ? "" : ` (${specimen.expect.kind}${specimen.expect.code === void 0 ? "" : `/${specimen.expect.code}`})`;
147
+ it(`returns a structured error for the ${specimen.lifecycle}.${specimen.kind} error specimen${expectation}`, async () => {
148
+ mutationExpectationRequired(specimen);
149
+ const provider = await fixture.create();
150
+ const controller = new AbortController();
151
+ const handler = specimen.lifecycle === "query" ? provider.queries?.[specimen.kind] : provider.mutations?.[specimen.kind];
152
+ expect(handler).toBeTypeOf("function");
153
+ let rejection;
154
+ let resolved = false;
155
+ try {
156
+ await handler(specimen.descriptor, context(controller.signal));
157
+ resolved = true;
158
+ } catch (error) {
159
+ rejection = error;
160
+ }
161
+ expect(resolved, "Provider error specimen resolved instead of rejecting.").toBe(false);
162
+ dataError(rejection);
163
+ errorExpectation(rejection, specimen.expect);
164
+ observed(fixture, {
165
+ lifecycle: specimen.lifecycle,
166
+ kind: specimen.kind,
167
+ descriptor: specimen.descriptor
168
+ }, controller.signal);
169
+ });
170
+ }
151
171
  it("reports absent optional capabilities without invoking fallback handlers", async () => {
152
172
  const provider = await fixture.create();
153
173
  const before = fixture.observeCalls().length;
@@ -169,7 +189,6 @@ function dataProviderContract(name, fixture) {
169
189
  }
170
190
  for (const kind of [
171
191
  "createOne",
172
- "createMany",
173
192
  "updateOne",
174
193
  "updateMany",
175
194
  "deleteOne",
@@ -1 +1 @@
1
- {"version":3,"file":"provider-contract.js","names":[],"sources":["../src/data-provider-contract.ts"],"sourcesContent":["import {\n type BatchedQueryOperation,\n type DataError,\n type DataProvider,\n type ErasedMutationDescriptor,\n type ErasedQueryDescriptor,\n type LiveObserver,\n isDataError,\n resolveDataCapability,\n validateBatchResult,\n validateMutationResult,\n} from '@burojs/core';\nimport { describe, expect, it } from 'vitest';\n\ntype QueryKind = ErasedQueryDescriptor['kind'];\ntype MutationKind = ErasedMutationDescriptor['kind'];\n\nexport interface ProviderObservedCall {\n readonly lifecycle: 'query' | 'mutation' | 'batch' | 'live-open' | 'live-subscribe';\n readonly kind?: QueryKind | MutationKind;\n readonly descriptor?: ErasedQueryDescriptor | ErasedMutationDescriptor;\n readonly descriptors?: readonly ErasedQueryDescriptor[];\n readonly signal?: AbortSignal;\n}\n\n/** Specimens supplied by an adapter author; unsupported optional methods are omitted. */\nexport interface ProviderContractDescriptors {\n readonly queries?: Partial<Record<QueryKind, ErasedQueryDescriptor>>;\n readonly mutations?: Partial<Record<MutationKind, ErasedMutationDescriptor>>;\n readonly batch?: readonly BatchedQueryOperation[];\n /** Independent batch groups prove that grouping is explicit, never merged by the helper. */\n readonly batchGroups?: readonly (readonly BatchedQueryOperation[])[];\n /** List specimens for both pagination protocols, including expected totals/pageInfo. */\n readonly pagination?: {\n readonly offset?: ErasedQueryDescriptor;\n readonly cursor?: ErasedQueryDescriptor;\n };\n readonly live?: ErasedQueryDescriptor;\n /** Error specimens must make the selected handler reject with a DataError. */\n readonly errors?: readonly ProviderContractErrorSpecimen[];\n /** Hand-authored mapped results; assertions never derive expectations from an adapter. */\n readonly expected?: {\n readonly queries?: Partial<Record<QueryKind, unknown>>;\n readonly mutations?: Partial<Record<MutationKind, unknown>>;\n readonly batch?: unknown;\n readonly pagination?: { readonly offset?: unknown; readonly cursor?: unknown };\n };\n}\n\nexport type ProviderContractErrorSpecimen =\n | {\n readonly lifecycle: 'query';\n readonly kind: QueryKind;\n readonly descriptor: ErasedQueryDescriptor;\n }\n | {\n readonly lifecycle: 'mutation';\n readonly kind: MutationKind;\n readonly descriptor: ErasedMutationDescriptor;\n };\n\nexport interface ProviderContractFixture {\n create(): DataProvider | Promise<DataProvider>;\n descriptors: ProviderContractDescriptors;\n observeCalls(): readonly ProviderObservedCall[];\n}\n\nconst SESSION = Object.freeze({\n sessionKey: 'contract-session',\n scopeKey: 'contract-scope',\n generation: 1,\n});\nconst REQUEST_CONTEXT = Object.freeze({ contract: 'provider' });\n\nfunction context(signal: AbortSignal) {\n return Object.freeze({\n signal,\n requestContext: REQUEST_CONTEXT,\n session: SESSION,\n providerServiceKey: 'contract-provider',\n });\n}\n\nfunction queryShape(kind: QueryKind, value: unknown): void {\n if (kind === 'custom') return;\n expect(value).toBeTypeOf('object');\n if (kind === 'list' || kind === 'many')\n expect((value as { data?: unknown }).data).toBeInstanceOf(Array);\n if (kind === 'one') expect(value).toHaveProperty('data');\n if (kind === 'count')\n expect([null, 'number']).toContain(typeof (value as { total?: unknown }).total);\n}\n\nfunction observed(\n fixture: ProviderContractFixture,\n expected: Omit<ProviderObservedCall, 'signal'>,\n signal: AbortSignal,\n): void {\n expect(fixture.observeCalls()).toContainEqual(expect.objectContaining({ ...expected, signal }));\n}\n\nasync function assertBatch(\n provider: DataProvider,\n fixture: ProviderContractFixture,\n operations: readonly BatchedQueryOperation[],\n expected: unknown,\n): Promise<void> {\n expect(provider.batch?.queries).toBeTypeOf('function');\n const controller = new AbortController();\n const result = await provider.batch!.queries(operations, context(controller.signal));\n validateBatchResult(operations, result);\n if (expected !== undefined) expect(result).toEqual(expected);\n for (const entry of result.results) if (entry.status === 'error') dataError(entry.error);\n observed(\n fixture,\n { lifecycle: 'batch', descriptors: operations.map((operation) => operation.descriptor) },\n controller.signal,\n );\n}\n\nfunction dataError(value: unknown): asserts value is DataError {\n expect(isDataError(value)).toBe(true);\n}\n\n/**\n * A supplied `descriptors.live` IS the adapter author's declaration that this\n * descriptor should produce a plan — an adapter with no live support omits the\n * descriptor (or the `live` slot) and is skipped instead. So a null plan here\n * is a failure, not a skip.\n *\n * Split out as its own assertion, rather than left inline, so the suite's own\n * tests can prove it rejects — the same reason `queryShape`/`observed`/`batch`\n * already are.\n */\nfunction livePlan(value: unknown): void {\n expect(value, 'descriptors.live was supplied, so live.open must return a plan').not.toBeNull();\n expect(value).toBeTypeOf('object');\n expect((value as { subscribe?: unknown } | null)?.subscribe).toBeTypeOf('function');\n}\n\n/**\n * The OTHER door into the same free green. `livePlan` closes \"supplied a\n * descriptor, returned a null plan\"; this closes \"provider has a `live` slot,\n * fixture supplied no descriptor\" — which used to `return` silently, leaving\n * the live cell green with nothing executed. Deleting the single line\n * `live: listDescriptor` from a fixture left `examples/marketplace-ops`'s\n * hasura contract at 35/35 green with the live `it` still passing.\n *\n * The rule is asymmetric on purpose: a provider with NO `live` slot is a\n * provider without live support, and skipping is right. A provider that HAS\n * one and a fixture that says nothing about it is an untested capability\n * reported as a pass.\n *\n * Split out for the same reason `livePlan` is: the suite's own tests drive it\n * directly to prove it rejects.\n */\nfunction liveDescriptorRequired(hasLiveSlot: boolean, descriptor: unknown): void {\n if (!hasLiveSlot) return;\n expect(\n descriptor,\n 'the provider exposes a `live` slot, so the fixture must supply `descriptors.live` — omitting it silently skips the whole live cell',\n ).toBeDefined();\n}\n\n/**\n * The THIRD door into a free green, and the one this suite carried longest:\n * the cursor pagination cell.\n *\n * It used to be `if (descriptor === undefined || ...) return;` — so a fixture\n * that simply said nothing about cursors got a pass for a cell titled\n * \"preserves cursor pagination\". MEASURED, not argued: with\n * `@burojs/hasura`'s keyset implementation sabotaged so that the keyset\n * predicate was dropped entirely — every cursor page returning page one,\n * forever — `examples/marketplace-ops/tests/hasura-provider-contract.test.ts`\n * stayed 35/35 green, cursor cell included.\n *\n * Unlike `live`, cursor support cannot be read off the provider object: there\n * is no `queries.listCursor` slot, only `queries.list` taking a pagination\n * shape it may or may not serve. So the rule is a DECLARATION rule instead —\n * a fixture whose provider lists must SAY which of the two it is:\n *\n * - `descriptors.pagination.cursor` — \"this provider serves cursor pages\",\n * and the cell below then holds it to that (`pageInfo`, and a second page\n * that is really a second page); or\n * - an `errors` specimen carrying a cursor list descriptor — \"this provider\n * refuses cursor pagination\", proven by execution.\n *\n * Silence is the one thing that is no longer allowed. Both demo fixtures\n * already chose the second form deliberately, which is exactly why the\n * vacuity survived so long: the suite looked adequately used.\n */\nfunction cursorDeclarationRequired(\n hasListSlot: boolean,\n descriptors: ProviderContractDescriptors,\n): void {\n if (!hasListSlot) return;\n const refusalSpecimen = (descriptors.errors ?? []).some(\n (specimen) =>\n specimen.lifecycle === 'query' &&\n specimen.kind === 'list' &&\n (specimen.descriptor as { pagination?: { kind?: unknown } }).pagination?.kind === 'cursor',\n );\n expect(\n descriptors.pagination?.cursor !== undefined || refusalSpecimen,\n 'the provider exposes a `list` slot, so the fixture must state its cursor-pagination position: supply `descriptors.pagination.cursor` for a provider that serves cursor pages, or an `errors` specimen holding a cursor list descriptor for one that refuses. Saying nothing used to pass this cell for a provider whose cursor paging was entirely broken.',\n ).toBe(true);\n}\n\n/**\n * What a cursor page must carry, beyond the `data` array every list shares.\n *\n * `pageInfo` is what makes a cursor list navigable at all — `nextCursor` is\n * the only thing a caller can hand back, and the framework's infinite-list\n * observer stops paging the moment it is absent. A provider that answered a\n * cursor descriptor with a bare `{data}` would render a single page and look\n * finished, which the old cell accepted.\n *\n * (Named without its package: this module is asserted to import nothing but\n * core contracts, and `tests/package.test.ts` greps the source for the\n * forbidden names.)\n */\nfunction cursorPageShape(value: unknown): void {\n const pageInfo = (value as { pageInfo?: { nextCursor?: unknown; hasNextPage?: unknown } })\n .pageInfo;\n expect(\n pageInfo,\n 'a cursor list must return `pageInfo` — without it there is nothing to page with',\n ).toBeTypeOf('object');\n const next = pageInfo?.nextCursor;\n if (next !== undefined) expect(next).toBeTypeOf('string');\n const has = pageInfo?.hasNextPage;\n if (has !== undefined) expect(has).toBeTypeOf('boolean');\n}\n\n/**\n * The first page of a supplied cursor descriptor must really BE a first page —\n * it has to mint a `nextCursor` the cell can then page with.\n *\n * The residual free-green door this closes (found by sabotage at the SP4 final\n * review, and reproducible: hard-code `hasNextPage = false` in\n * `@burojs/hasura`'s cursor branch). `cursorPageShape` only type-checks\n * `nextCursor`/`hasNextPage` WHEN PRESENT, and the cell's second-page half then\n * read `if (next === undefined) return;`. So a provider that minted no cursor\n * on page one skipped the entire \"the second page really differs\" half and\n * passed a cell named \"preserves cursor pagination\" — the same shape of vacuity\n * SP4 task 3 removed from this cell's outer skip, one level in.\n *\n * The rule is the same one `livePlan` applies to `descriptors.live`: supplying\n * `descriptors.pagination.cursor` is the AUTHOR'S DECLARATION that this\n * descriptor pages, so a provider answering it with a finished list is a\n * failure, not a skip. It is satisfied by sizing the descriptor's `limit` below\n * the fixture's row count — the fixture author controls both. A provider that\n * genuinely cannot mint a cursor does not serve cursor pages: declare the\n * `errors` refusal specimen instead, which `cursorDeclarationRequired` already\n * accepts as the other honest position.\n */\nfunction cursorPagesRequired(value: unknown): void {\n const pageInfo = (value as { pageInfo?: { nextCursor?: unknown; hasNextPage?: unknown } })\n .pageInfo;\n expect(\n pageInfo?.hasNextPage,\n 'a supplied `descriptors.pagination.cursor` declares that this descriptor PAGES, so its first page must report `hasNextPage: true`. Size the descriptor `limit` below the fixture row count; a provider that cannot page belongs in an `errors` refusal specimen instead. (A first page that reports itself finished used to skip the whole second-page half of this cell.)',\n ).toBe(true);\n const next = pageInfo?.nextCursor;\n expect(\n typeof next === 'string' && next.length > 0,\n 'a first page reporting `hasNextPage: true` must also mint a non-empty `nextCursor` — it is the only thing a caller can hand back, and without it the infinite-list observer stops paging',\n ).toBe(true);\n}\n\n/** @internal Direct assertion seam for the contract suite's intentionally broken-provider tests. */\nexport const __providerContractAssertions = Object.freeze({\n queryShape,\n observed,\n batch: validateBatchResult,\n mutation: validateMutationResult,\n dataError,\n livePlan,\n liveDescriptorRequired,\n cursorDeclarationRequired,\n cursorPageShape,\n cursorPagesRequired,\n});\n\n/**\n * Registers transport-agnostic provider conformance tests. It intentionally\n * imports only core contracts: adapters supply their own deterministic client\n * and expose provider-boundary observations through the fixture.\n */\nexport function dataProviderContract(name: string, fixture: ProviderContractFixture): void {\n describe(`${name} data provider contract`, () => {\n for (const kind of ['list', 'one', 'many', 'count', 'custom'] as const) {\n it(`preserves the ${kind} query descriptor and AbortSignal`, async () => {\n const provider = await fixture.create();\n const descriptor = fixture.descriptors.queries?.[kind];\n const handler = provider.queries?.[kind];\n if (descriptor === undefined || handler === undefined) return;\n const controller = new AbortController();\n const result = await handler(descriptor as never, context(controller.signal));\n queryShape(kind, result);\n const expected = fixture.descriptors.expected?.queries?.[kind];\n if (expected !== undefined) expect(result).toEqual(expected);\n observed(fixture, { lifecycle: 'query', kind, descriptor }, controller.signal);\n });\n }\n\n for (const kind of [\n 'createOne',\n 'createMany',\n 'updateOne',\n 'updateMany',\n 'deleteOne',\n 'deleteMany',\n 'custom',\n ] as const) {\n it(`validates ${kind} mutation output and preserves its request`, async () => {\n const provider = await fixture.create();\n const descriptor = fixture.descriptors.mutations?.[kind];\n const handler = provider.mutations?.[kind];\n if (descriptor === undefined || handler === undefined) return;\n const controller = new AbortController();\n const result = await handler(descriptor as never, context(controller.signal));\n validateMutationResult(descriptor, result);\n const expected = fixture.descriptors.expected?.mutations?.[kind];\n if (expected !== undefined) expect(result).toEqual(expected);\n observed(fixture, { lifecycle: 'mutation', kind, descriptor }, controller.signal);\n });\n }\n\n for (const specimen of fixture.descriptors.errors ?? []) {\n it(`returns a structured error for the ${specimen.lifecycle}.${specimen.kind} error specimen`, async () => {\n const provider = await fixture.create();\n const controller = new AbortController();\n const handler =\n specimen.lifecycle === 'query'\n ? provider.queries?.[specimen.kind as QueryKind]\n : provider.mutations?.[specimen.kind as MutationKind];\n expect(handler).toBeTypeOf('function');\n try {\n await (\n handler as (\n descriptor: never,\n operation: ReturnType<typeof context>,\n ) => Promise<unknown>\n )(specimen.descriptor as never, context(controller.signal));\n throw new Error('Provider error specimen resolved instead of rejecting.');\n } catch (error) {\n dataError(error);\n }\n observed(\n fixture,\n { lifecycle: specimen.lifecycle, kind: specimen.kind, descriptor: specimen.descriptor },\n controller.signal,\n );\n });\n }\n\n it('reports absent optional capabilities without invoking fallback handlers', async () => {\n const provider = await fixture.create();\n const before = fixture.observeCalls().length;\n for (const kind of ['list', 'one', 'many', 'count', 'custom'] as const) {\n if (provider.queries?.[kind] === undefined) {\n const capability = resolveDataCapability(provider, { lifecycle: 'query', kind });\n expect(capability).toMatchObject({ kind: 'capability', code: 'missing-data-capability' });\n }\n }\n for (const kind of [\n 'createOne',\n 'createMany',\n 'updateOne',\n 'updateMany',\n 'deleteOne',\n 'deleteMany',\n 'custom',\n ] as const) {\n if (provider.mutations?.[kind] === undefined) {\n const capability = resolveDataCapability(provider, { lifecycle: 'mutation', kind });\n expect(capability).toMatchObject({ kind: 'capability', code: 'missing-data-capability' });\n }\n }\n expect(fixture.observeCalls()).toHaveLength(before);\n });\n\n it('validates batch IDs, kinds, partial errors, and the shared AbortSignal', async () => {\n const provider = await fixture.create();\n const operations = fixture.descriptors.batch;\n if (provider.batch?.queries === undefined || operations === undefined) return;\n await assertBatch(provider, fixture, operations, fixture.descriptors.expected?.batch);\n });\n\n for (const [index, operations] of (fixture.descriptors.batchGroups ?? []).entries()) {\n it(`keeps batch group ${index + 1} isolated and validates its partial results`, async () => {\n const provider = await fixture.create();\n await assertBatch(provider, fixture, operations, undefined);\n });\n }\n\n it('preserves offset pagination and its mapped total/page shape', async () => {\n const provider = await fixture.create();\n const descriptor = fixture.descriptors.pagination?.offset;\n if (descriptor === undefined || provider.queries?.list === undefined) return;\n const controller = new AbortController();\n const result = await provider.queries.list(descriptor as never, context(controller.signal));\n queryShape('list', result);\n const expected = fixture.descriptors.expected?.pagination?.offset;\n if (expected !== undefined) expect(result).toEqual(expected);\n observed(fixture, { lifecycle: 'query', kind: 'list', descriptor }, controller.signal);\n });\n\n it('preserves cursor pagination, and pages with the cursor it minted', async () => {\n const provider = await fixture.create();\n const descriptor = fixture.descriptors.pagination?.cursor;\n // A fixture that says NOTHING about cursors fails here rather than\n // skipping — see `cursorDeclarationRequired`.\n cursorDeclarationRequired(provider.queries?.list !== undefined, fixture.descriptors);\n if (descriptor === undefined || provider.queries?.list === undefined) return;\n const controller = new AbortController();\n const result = await provider.queries.list(descriptor as never, context(controller.signal));\n queryShape('list', result);\n cursorPageShape(result);\n const expected = fixture.descriptors.expected?.pagination?.cursor;\n if (expected !== undefined) expect(result).toEqual(expected);\n observed(fixture, { lifecycle: 'query', kind: 'list', descriptor }, controller.signal);\n\n // The half a one-page assertion cannot reach: a provider that ACCEPTS\n // `after` and ignores it returns page one again, forever, and every\n // shape check above still passes. The second page is therefore fetched\n // and required to be a different, non-empty page.\n //\n // This used to read `if (next === undefined) return;`, which handed the\n // whole half back as a free green to any provider that minted no cursor\n // — see `cursorPagesRequired`, which is now what stands here.\n cursorPagesRequired(result);\n const next = (result as { pageInfo?: { nextCursor?: string } }).pageInfo?.nextCursor;\n if (next === undefined) return; // unreachable past `cursorPagesRequired`; narrows the type\n const second = new AbortController();\n const page2 = (await provider.queries.list(\n {\n ...(descriptor as unknown as Record<string, unknown>),\n pagination: {\n ...((descriptor as unknown as { pagination?: Record<string, unknown> }).pagination ??\n {}),\n after: next,\n },\n } as never,\n context(second.signal),\n )) as { data?: readonly unknown[] };\n expect(\n page2.data,\n 'a minted nextCursor promises a further page; the page it addresses must not be empty',\n ).not.toHaveLength(0);\n expect(\n page2.data?.[0],\n 'the page after a cursor must differ from the page that minted it — a provider ignoring `after` lands here',\n ).not.toEqual((result as { data?: readonly unknown[] }).data?.[0]);\n });\n\n it('opens live plans with the complete descriptor and subscribes with an AbortSignal', async () => {\n const provider = await fixture.create();\n const descriptor = fixture.descriptors.live;\n liveDescriptorRequired(provider.live !== undefined, descriptor);\n if (provider.live === undefined || descriptor === undefined) return;\n const openController = new AbortController();\n const plan = await provider.live.open(descriptor, context(openController.signal));\n observed(fixture, { lifecycle: 'live-open', descriptor }, openController.signal);\n // A null plan used to `return` here, which handed every adapter a free\n // green on the whole live cell: `subscribe` was never reached, so\n // nothing about the plan was ever checked, and the `it` passed for an\n // adapter that had done no live work at all.\n livePlan(plan);\n if (plan === null) return; // unreachable past `livePlan`; narrows the type\n const subscribeController = new AbortController();\n const observer: LiveObserver = {\n next: () => undefined,\n status: () => undefined,\n error: () => undefined,\n complete: () => undefined,\n };\n const unsubscribe = await plan.subscribe(observer, { signal: subscribeController.signal });\n observed(fixture, { lifecycle: 'live-subscribe' }, subscribeController.signal);\n await unsubscribe();\n });\n });\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"provider-contract.js","names":[],"sources":["../src/data-provider-contract.ts"],"sourcesContent":["import {\n type BatchedQueryOperation,\n type DataError,\n type DataErrorKind,\n type DataProvider,\n type ErasedMutationDescriptor,\n type ErasedQueryDescriptor,\n type LiveObserver,\n isDataError,\n resolveDataCapability,\n validateBatchResult,\n validateMutationResult,\n} from '@burojs/core';\nimport { describe, expect, it } from 'vitest';\n\ntype QueryKind = ErasedQueryDescriptor['kind'];\ntype MutationKind = ErasedMutationDescriptor['kind'];\n\nexport interface ProviderObservedCall {\n readonly lifecycle: 'query' | 'mutation' | 'batch' | 'live-open' | 'live-subscribe';\n readonly kind?: QueryKind | MutationKind;\n readonly descriptor?: ErasedQueryDescriptor | ErasedMutationDescriptor;\n readonly descriptors?: readonly ErasedQueryDescriptor[];\n readonly signal?: AbortSignal;\n}\n\n/** Specimens supplied by an adapter author; unsupported optional methods are omitted. */\nexport interface ProviderContractDescriptors {\n readonly queries?: Partial<Record<QueryKind, ErasedQueryDescriptor>>;\n readonly mutations?: Partial<Record<MutationKind, ErasedMutationDescriptor>>;\n readonly batch?: readonly BatchedQueryOperation[];\n /** Independent batch groups prove that grouping is explicit, never merged by the helper. */\n readonly batchGroups?: readonly (readonly BatchedQueryOperation[])[];\n /** List specimens for both pagination protocols, including expected totals/pageInfo. */\n readonly pagination?: {\n readonly offset?: ErasedQueryDescriptor;\n readonly cursor?: ErasedQueryDescriptor;\n };\n readonly live?: ErasedQueryDescriptor;\n /** Error specimens must make the selected handler reject with a DataError. */\n readonly errors?: readonly ProviderContractErrorSpecimen[];\n /** Hand-authored mapped results; assertions never derive expectations from an adapter. */\n readonly expected?: {\n readonly queries?: Partial<Record<QueryKind, unknown>>;\n readonly mutations?: Partial<Record<MutationKind, unknown>>;\n readonly batch?: unknown;\n readonly pagination?: { readonly offset?: unknown; readonly cursor?: unknown };\n };\n}\n\n/**\n * What an error specimen must reject with, beyond being a structural DataError. A fixture uses it\n * to hold its provider to the error kinds the framework decides on — above all the `sent`\n * obligation: a mutation that reached the backend and got no readable answer (a gateway 5xx, an\n * unreadable 2xx) must reject as `sent`, never as `transport`, or a screen offers a retry that\n * can apply the write twice.\n */\nexport interface ProviderContractErrorExpectation {\n readonly kind: DataErrorKind;\n readonly code?: string;\n}\n\nexport type ProviderContractErrorSpecimen =\n | {\n readonly lifecycle: 'query';\n readonly kind: QueryKind;\n readonly descriptor: ErasedQueryDescriptor;\n readonly expect?: ProviderContractErrorExpectation;\n }\n | {\n readonly lifecycle: 'mutation';\n readonly kind: MutationKind;\n readonly descriptor: ErasedMutationDescriptor;\n /**\n * Required on a write. The dangerous direction is a lost or garbled answer reported as a\n * refusal (`validation`, `conflict`, `forbidden`, `not-found`): a screen reads \"nothing\n * was applied\" and offers to send again. A write specimen that does not say what it must\n * reject with cannot catch that, so every one says.\n */\n readonly expect: ProviderContractErrorExpectation;\n };\n\nexport interface ProviderContractFixture {\n create(): DataProvider | Promise<DataProvider>;\n descriptors: ProviderContractDescriptors;\n observeCalls(): readonly ProviderObservedCall[];\n}\n\nconst SESSION = Object.freeze({\n sessionKey: 'contract-session',\n scopeKey: 'contract-scope',\n generation: 1,\n});\nconst REQUEST_CONTEXT = Object.freeze({ contract: 'provider' });\n\nfunction context(signal: AbortSignal) {\n return Object.freeze({\n signal,\n requestContext: REQUEST_CONTEXT,\n session: SESSION,\n providerServiceKey: 'contract-provider',\n idempotencyKey: 'contract-idempotency-key',\n });\n}\n\nfunction queryShape(kind: QueryKind, value: unknown): void {\n if (kind === 'custom') return;\n expect(value).toBeTypeOf('object');\n if (kind === 'list' || kind === 'many')\n expect((value as { data?: unknown }).data).toBeInstanceOf(Array);\n if (kind === 'one') expect(value).toHaveProperty('data');\n if (kind === 'count')\n expect([null, 'number']).toContain(typeof (value as { total?: unknown }).total);\n}\n\nfunction observed(\n fixture: ProviderContractFixture,\n expected: Omit<ProviderObservedCall, 'signal'>,\n signal: AbortSignal,\n): void {\n expect(fixture.observeCalls()).toContainEqual(expect.objectContaining({ ...expected, signal }));\n}\n\nasync function assertBatch(\n provider: DataProvider,\n fixture: ProviderContractFixture,\n operations: readonly BatchedQueryOperation[],\n expected: unknown,\n): Promise<void> {\n expect(provider.batch?.queries).toBeTypeOf('function');\n const controller = new AbortController();\n const result = await provider.batch!.queries(operations, context(controller.signal));\n validateBatchResult(operations, result);\n if (expected !== undefined) expect(result).toEqual(expected);\n for (const entry of result.results) if (entry.status === 'error') dataError(entry.error);\n observed(\n fixture,\n { lifecycle: 'batch', descriptors: operations.map((operation) => operation.descriptor) },\n controller.signal,\n );\n}\n\nfunction dataError(value: unknown): asserts value is DataError {\n expect(isDataError(value)).toBe(true);\n}\n\n/**\n * A write specimen must declare its `expect`. The type requires it; this holds a JavaScript\n * fixture, or one built through a cast, to the same rule.\n */\nfunction mutationExpectationRequired(specimen: ProviderContractErrorSpecimen): void {\n if (specimen.lifecycle !== 'mutation') return;\n expect(\n (specimen as { expect?: unknown }).expect,\n `the mutation.${specimen.kind} error specimen must declare \\`expect: { kind }\\` — a write's error kind decides whether a screen offers to send it again`,\n ).toBeDefined();\n}\n\n/** A specimen's `expect`, when it has one: the kind (and the code, when given) must match. */\nfunction errorExpectation(\n value: DataError,\n expected: ProviderContractErrorExpectation | undefined,\n): void {\n if (expected === undefined) return;\n expect(\n value.kind,\n `the error specimen declares kind '${expected.kind}' (code ${value.code})`,\n ).toBe(expected.kind);\n if (expected.code !== undefined) expect(value.code).toBe(expected.code);\n}\n\n/**\n * A supplied `descriptors.live` IS the adapter author's declaration that this\n * descriptor should produce a plan — an adapter with no live support omits the\n * descriptor (or the `live` slot) and is skipped instead. So a null plan here\n * is a failure, not a skip.\n *\n * Split out as its own assertion, rather than left inline, so the suite's own\n * tests can prove it rejects — the same reason `queryShape`/`observed`/`batch`\n * already are.\n */\nfunction livePlan(value: unknown): void {\n expect(value, 'descriptors.live was supplied, so live.open must return a plan').not.toBeNull();\n expect(value).toBeTypeOf('object');\n expect((value as { subscribe?: unknown } | null)?.subscribe).toBeTypeOf('function');\n}\n\n/**\n * The OTHER door into the same free green. `livePlan` closes \"supplied a\n * descriptor, returned a null plan\"; this closes \"provider has a `live` slot,\n * fixture supplied no descriptor\" — which used to `return` silently, leaving\n * the live cell green with nothing executed. Deleting the single line\n * `live: listDescriptor` from a fixture left `examples/marketplace-ops`'s\n * hasura contract at 35/35 green with the live `it` still passing.\n *\n * The rule is asymmetric on purpose: a provider with NO `live` slot is a\n * provider without live support, and skipping is right. A provider that HAS\n * one and a fixture that says nothing about it is an untested capability\n * reported as a pass.\n *\n * Split out for the same reason `livePlan` is: the suite's own tests drive it\n * directly to prove it rejects.\n */\nfunction liveDescriptorRequired(hasLiveSlot: boolean, descriptor: unknown): void {\n if (!hasLiveSlot) return;\n expect(\n descriptor,\n 'the provider exposes a `live` slot, so the fixture must supply `descriptors.live` — omitting it silently skips the whole live cell',\n ).toBeDefined();\n}\n\n/**\n * The THIRD door into a free green, and the one this suite carried longest:\n * the cursor pagination cell.\n *\n * It used to be `if (descriptor === undefined || ...) return;` — so a fixture\n * that simply said nothing about cursors got a pass for a cell titled\n * \"preserves cursor pagination\". MEASURED, not argued: with\n * `@burojs/hasura`'s keyset implementation sabotaged so that the keyset\n * predicate was dropped entirely — every cursor page returning page one,\n * forever — `examples/marketplace-ops/tests/hasura-provider-contract.test.ts`\n * stayed 35/35 green, cursor cell included.\n *\n * Unlike `live`, cursor support cannot be read off the provider object: there\n * is no `queries.listCursor` slot, only `queries.list` taking a pagination\n * shape it may or may not serve. So the rule is a DECLARATION rule instead —\n * a fixture whose provider lists must SAY which of the two it is:\n *\n * - `descriptors.pagination.cursor` — \"this provider serves cursor pages\",\n * and the cell below then holds it to that (`pageInfo`, and a second page\n * that is really a second page); or\n * - an `errors` specimen carrying a cursor list descriptor — \"this provider\n * refuses cursor pagination\", proven by execution.\n *\n * Silence is the one thing that is no longer allowed. Both demo fixtures\n * already chose the second form deliberately, which is exactly why the\n * vacuity survived so long: the suite looked adequately used.\n */\nfunction cursorDeclarationRequired(\n hasListSlot: boolean,\n descriptors: ProviderContractDescriptors,\n): void {\n if (!hasListSlot) return;\n const refusalSpecimen = (descriptors.errors ?? []).some(\n (specimen) =>\n specimen.lifecycle === 'query' &&\n specimen.kind === 'list' &&\n (specimen.descriptor as { pagination?: { kind?: unknown } }).pagination?.kind === 'cursor',\n );\n expect(\n descriptors.pagination?.cursor !== undefined || refusalSpecimen,\n 'the provider exposes a `list` slot, so the fixture must state its cursor-pagination position: supply `descriptors.pagination.cursor` for a provider that serves cursor pages, or an `errors` specimen holding a cursor list descriptor for one that refuses. Saying nothing used to pass this cell for a provider whose cursor paging was entirely broken.',\n ).toBe(true);\n}\n\n/**\n * What a cursor page must carry, beyond the `data` array every list shares.\n *\n * `pageInfo` is what makes a cursor list navigable at all — `nextCursor` is\n * the only thing a caller can hand back, and the framework's infinite-list\n * observer stops paging the moment it is absent. A provider that answered a\n * cursor descriptor with a bare `{data}` would render a single page and look\n * finished, which the old cell accepted.\n *\n * (Named without its package: this module is asserted to import nothing but\n * core contracts, and `tests/package.test.ts` greps the source for the\n * forbidden names.)\n */\nfunction cursorPageShape(value: unknown): void {\n const pageInfo = (value as { pageInfo?: { nextCursor?: unknown; hasNextPage?: unknown } })\n .pageInfo;\n expect(\n pageInfo,\n 'a cursor list must return `pageInfo` — without it there is nothing to page with',\n ).toBeTypeOf('object');\n const next = pageInfo?.nextCursor;\n if (next !== undefined) expect(next).toBeTypeOf('string');\n const has = pageInfo?.hasNextPage;\n if (has !== undefined) expect(has).toBeTypeOf('boolean');\n}\n\n/**\n * The first page of a supplied cursor descriptor must really BE a first page —\n * it has to mint a `nextCursor` the cell can then page with.\n *\n * The residual free-green door this closes (found by sabotage at the SP4 final\n * review, and reproducible: hard-code `hasNextPage = false` in\n * `@burojs/hasura`'s cursor branch). `cursorPageShape` only type-checks\n * `nextCursor`/`hasNextPage` WHEN PRESENT, and the cell's second-page half then\n * read `if (next === undefined) return;`. So a provider that minted no cursor\n * on page one skipped the entire \"the second page really differs\" half and\n * passed a cell named \"preserves cursor pagination\" — the same shape of vacuity\n * SP4 task 3 removed from this cell's outer skip, one level in.\n *\n * The rule is the same one `livePlan` applies to `descriptors.live`: supplying\n * `descriptors.pagination.cursor` is the AUTHOR'S DECLARATION that this\n * descriptor pages, so a provider answering it with a finished list is a\n * failure, not a skip. It is satisfied by sizing the descriptor's `limit` below\n * the fixture's row count — the fixture author controls both. A provider that\n * genuinely cannot mint a cursor does not serve cursor pages: declare the\n * `errors` refusal specimen instead, which `cursorDeclarationRequired` already\n * accepts as the other honest position.\n */\nfunction cursorPagesRequired(value: unknown): void {\n const pageInfo = (value as { pageInfo?: { nextCursor?: unknown; hasNextPage?: unknown } })\n .pageInfo;\n expect(\n pageInfo?.hasNextPage,\n 'a supplied `descriptors.pagination.cursor` declares that this descriptor PAGES, so its first page must report `hasNextPage: true`. Size the descriptor `limit` below the fixture row count; a provider that cannot page belongs in an `errors` refusal specimen instead. (A first page that reports itself finished used to skip the whole second-page half of this cell.)',\n ).toBe(true);\n const next = pageInfo?.nextCursor;\n expect(\n typeof next === 'string' && next.length > 0,\n 'a first page reporting `hasNextPage: true` must also mint a non-empty `nextCursor` — it is the only thing a caller can hand back, and without it the infinite-list observer stops paging',\n ).toBe(true);\n}\n\n/** @internal Direct assertion seam for the contract suite's intentionally broken-provider tests. */\nexport const __providerContractAssertions = Object.freeze({\n queryShape,\n observed,\n batch: validateBatchResult,\n mutation: validateMutationResult,\n dataError,\n errorExpectation,\n mutationExpectationRequired,\n livePlan,\n liveDescriptorRequired,\n cursorDeclarationRequired,\n cursorPageShape,\n cursorPagesRequired,\n});\n\n/**\n * Registers transport-agnostic provider conformance tests. It intentionally\n * imports only core contracts: adapters supply their own deterministic client\n * and expose provider-boundary observations through the fixture.\n */\nexport function dataProviderContract(name: string, fixture: ProviderContractFixture): void {\n describe(`${name} data provider contract`, () => {\n for (const kind of ['list', 'one', 'many', 'count', 'custom'] as const) {\n it(`preserves the ${kind} query descriptor and AbortSignal`, async () => {\n const provider = await fixture.create();\n const descriptor = fixture.descriptors.queries?.[kind];\n const handler = provider.queries?.[kind];\n if (descriptor === undefined || handler === undefined) return;\n const controller = new AbortController();\n const result = await handler(descriptor as never, context(controller.signal));\n queryShape(kind, result);\n const expected = fixture.descriptors.expected?.queries?.[kind];\n if (expected !== undefined) expect(result).toEqual(expected);\n observed(fixture, { lifecycle: 'query', kind, descriptor }, controller.signal);\n });\n }\n\n for (const kind of [\n 'createOne',\n 'updateOne',\n 'updateMany',\n 'deleteOne',\n 'deleteMany',\n 'custom',\n ] as const) {\n it(`validates ${kind} mutation output and preserves its request`, async () => {\n const provider = await fixture.create();\n const descriptor = fixture.descriptors.mutations?.[kind];\n const handler = provider.mutations?.[kind];\n if (descriptor === undefined || handler === undefined) return;\n const controller = new AbortController();\n const result = await handler(descriptor as never, context(controller.signal));\n validateMutationResult(descriptor, result);\n const expected = fixture.descriptors.expected?.mutations?.[kind];\n if (expected !== undefined) expect(result).toEqual(expected);\n observed(fixture, { lifecycle: 'mutation', kind, descriptor }, controller.signal);\n });\n }\n\n for (const specimen of fixture.descriptors.errors ?? []) {\n const expectation =\n specimen.expect === undefined\n ? ''\n : ` (${specimen.expect.kind}${specimen.expect.code === undefined ? '' : `/${specimen.expect.code}`})`;\n it(`returns a structured error for the ${specimen.lifecycle}.${specimen.kind} error specimen${expectation}`, async () => {\n mutationExpectationRequired(specimen);\n const provider = await fixture.create();\n const controller = new AbortController();\n const handler =\n specimen.lifecycle === 'query'\n ? provider.queries?.[specimen.kind as QueryKind]\n : provider.mutations?.[specimen.kind as MutationKind];\n expect(handler).toBeTypeOf('function');\n let rejection: unknown;\n let resolved = false;\n try {\n await (\n handler as (\n descriptor: never,\n operation: ReturnType<typeof context>,\n ) => Promise<unknown>\n )(specimen.descriptor as never, context(controller.signal));\n resolved = true;\n } catch (error) {\n rejection = error;\n }\n expect(resolved, 'Provider error specimen resolved instead of rejecting.').toBe(false);\n dataError(rejection);\n errorExpectation(rejection, specimen.expect);\n observed(\n fixture,\n { lifecycle: specimen.lifecycle, kind: specimen.kind, descriptor: specimen.descriptor },\n controller.signal,\n );\n });\n }\n\n it('reports absent optional capabilities without invoking fallback handlers', async () => {\n const provider = await fixture.create();\n const before = fixture.observeCalls().length;\n for (const kind of ['list', 'one', 'many', 'count', 'custom'] as const) {\n if (provider.queries?.[kind] === undefined) {\n const capability = resolveDataCapability(provider, { lifecycle: 'query', kind });\n expect(capability).toMatchObject({ kind: 'capability', code: 'missing-data-capability' });\n }\n }\n for (const kind of [\n 'createOne',\n 'updateOne',\n 'updateMany',\n 'deleteOne',\n 'deleteMany',\n 'custom',\n ] as const) {\n if (provider.mutations?.[kind] === undefined) {\n const capability = resolveDataCapability(provider, { lifecycle: 'mutation', kind });\n expect(capability).toMatchObject({ kind: 'capability', code: 'missing-data-capability' });\n }\n }\n expect(fixture.observeCalls()).toHaveLength(before);\n });\n\n it('validates batch IDs, kinds, partial errors, and the shared AbortSignal', async () => {\n const provider = await fixture.create();\n const operations = fixture.descriptors.batch;\n if (provider.batch?.queries === undefined || operations === undefined) return;\n await assertBatch(provider, fixture, operations, fixture.descriptors.expected?.batch);\n });\n\n for (const [index, operations] of (fixture.descriptors.batchGroups ?? []).entries()) {\n it(`keeps batch group ${index + 1} isolated and validates its partial results`, async () => {\n const provider = await fixture.create();\n await assertBatch(provider, fixture, operations, undefined);\n });\n }\n\n it('preserves offset pagination and its mapped total/page shape', async () => {\n const provider = await fixture.create();\n const descriptor = fixture.descriptors.pagination?.offset;\n if (descriptor === undefined || provider.queries?.list === undefined) return;\n const controller = new AbortController();\n const result = await provider.queries.list(descriptor as never, context(controller.signal));\n queryShape('list', result);\n const expected = fixture.descriptors.expected?.pagination?.offset;\n if (expected !== undefined) expect(result).toEqual(expected);\n observed(fixture, { lifecycle: 'query', kind: 'list', descriptor }, controller.signal);\n });\n\n it('preserves cursor pagination, and pages with the cursor it minted', async () => {\n const provider = await fixture.create();\n const descriptor = fixture.descriptors.pagination?.cursor;\n // A fixture that says NOTHING about cursors fails here rather than\n // skipping — see `cursorDeclarationRequired`.\n cursorDeclarationRequired(provider.queries?.list !== undefined, fixture.descriptors);\n if (descriptor === undefined || provider.queries?.list === undefined) return;\n const controller = new AbortController();\n const result = await provider.queries.list(descriptor as never, context(controller.signal));\n queryShape('list', result);\n cursorPageShape(result);\n const expected = fixture.descriptors.expected?.pagination?.cursor;\n if (expected !== undefined) expect(result).toEqual(expected);\n observed(fixture, { lifecycle: 'query', kind: 'list', descriptor }, controller.signal);\n\n // The half a one-page assertion cannot reach: a provider that ACCEPTS\n // `after` and ignores it returns page one again, forever, and every\n // shape check above still passes. The second page is therefore fetched\n // and required to be a different, non-empty page.\n //\n // This used to read `if (next === undefined) return;`, which handed the\n // whole half back as a free green to any provider that minted no cursor\n // — see `cursorPagesRequired`, which is now what stands here.\n cursorPagesRequired(result);\n const next = (result as { pageInfo?: { nextCursor?: string } }).pageInfo?.nextCursor;\n if (next === undefined) return; // unreachable past `cursorPagesRequired`; narrows the type\n const second = new AbortController();\n const page2 = (await provider.queries.list(\n {\n ...(descriptor as unknown as Record<string, unknown>),\n pagination: {\n ...((descriptor as unknown as { pagination?: Record<string, unknown> }).pagination ??\n {}),\n after: next,\n },\n } as never,\n context(second.signal),\n )) as { data?: readonly unknown[] };\n expect(\n page2.data,\n 'a minted nextCursor promises a further page; the page it addresses must not be empty',\n ).not.toHaveLength(0);\n expect(\n page2.data?.[0],\n 'the page after a cursor must differ from the page that minted it — a provider ignoring `after` lands here',\n ).not.toEqual((result as { data?: readonly unknown[] }).data?.[0]);\n });\n\n it('opens live plans with the complete descriptor and subscribes with an AbortSignal', async () => {\n const provider = await fixture.create();\n const descriptor = fixture.descriptors.live;\n liveDescriptorRequired(provider.live !== undefined, descriptor);\n if (provider.live === undefined || descriptor === undefined) return;\n const openController = new AbortController();\n const plan = await provider.live.open(descriptor, context(openController.signal));\n observed(fixture, { lifecycle: 'live-open', descriptor }, openController.signal);\n // A null plan used to `return` here, which handed every adapter a free\n // green on the whole live cell: `subscribe` was never reached, so\n // nothing about the plan was ever checked, and the `it` passed for an\n // adapter that had done no live work at all.\n livePlan(plan);\n if (plan === null) return; // unreachable past `livePlan`; narrows the type\n const subscribeController = new AbortController();\n const observer: LiveObserver = {\n next: () => undefined,\n status: () => undefined,\n error: () => undefined,\n complete: () => undefined,\n };\n const unsubscribe = await plan.subscribe(observer, { signal: subscribeController.signal });\n observed(fixture, { lifecycle: 'live-subscribe' }, subscribeController.signal);\n await unsubscribe();\n });\n });\n}\n"],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@burojs/test-helpers",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "license": "MIT",
5
5
  "description": "Buro: typed Playwright Page Objects over the testid convention",
6
6
  "type": "module",
@@ -42,7 +42,7 @@
42
42
  "README.md"
43
43
  ],
44
44
  "dependencies": {
45
- "@burojs/core": "0.4.0"
45
+ "@burojs/core": "0.5.0"
46
46
  },
47
47
  "peerDependencies": {
48
48
  "@playwright/test": "^1.49.0",