diff --git a/docs/qa/platform-checklist/areas/records-forms.json b/docs/qa/platform-checklist/areas/records-forms.json index efec752f81c..c27e5833250 100644 --- a/docs/qa/platform-checklist/areas/records-forms.json +++ b/docs/qa/platform-checklist/areas/records-forms.json @@ -625,7 +625,7 @@ "title": "View-type gallery: every ListViewSchema visualization renders seeded records", "since": "v15", "status": "active", - "revision": 2, + "revision": 3, "priority": "P1", "surface": "browser", "personas": [ @@ -711,7 +711,7 @@ ], "automated": { "kind": "e2e", - "ref": "examples/app-showcase/e2e/showcase-smoke.spec.ts (page-level render for the view surfaces)" + "ref": "examples/app-showcase/e2e/showcase-smoke.spec.ts (page-level render for the view surfaces; acceptance[5]). packages/qa/dogfood/test/view-type-gallery-data-halves.dogfood.test.ts pins the API halves on the showcase as shipped: acceptance[1] (GET /api/v1/meta/view serves the kanban view grouped by status and summarizing estimate_hours; each of the five status columns holds seeded tasks; POST /data/showcase_task/query grouping by status with sum(estimate_hours) answers one row per column, each equal to the raw rows grouped client-side) and acceptance[2] (the calendar view names due_date, the timeline created_at, the gantt start_date → end_date with progressField progress; the seeded tasks carry those values and no gantt bar ends before it starts). acceptance[0] and negative[0] (each variant's real visualization, not a grid fallback), the on-canvas placement half of acceptance[1] and [2], acceptance[3] with negative[1] (map markers, the chart's plotted marks) and acceptance[4] (the business-unit tree) stay live browser legs: they are judged on screenshots and the rendered DOM, which no HTTP pin reaches, and the card's ruling adds no Playwright lane (#26573)" }, "source": [ "packages/spec/src/ui/view.zod.ts#ListViewSchema (ListViewSchema type enum: grid|kanban|gallery|calendar|timeline|gantt|map|chart|tree)", @@ -732,6 +732,12 @@ "date": "2026-10-26", "change": "acceptance[5].verify says its pin is a Playwright spec needing a built console and a live stack, so it scores only when the run executes it (Tier 1 run #25815 (6679644a, pin b41ed7f68956)). No clause changed", "ref": "#25846" + }, + { + "revision": 3, + "date": "2026-10-27", + "change": "automated.ref names the dogfood pin for the API halves of acceptance[1] and [2] (#26573 slice 4), names acceptance[5] as the smoke spec's, and says why the screenshot and dom clauses stay live browser legs. No clause changed", + "ref": "#26573" } ] }, @@ -854,7 +860,7 @@ "title": "Header-level conditional rules: visibleWhen / requiredWhen / readonlyWhen on invoice fields, enforced on BOTH ends", "since": "v16", "status": "active", - "revision": 6, + "revision": 7, "priority": "P1", "surface": "mixed", "personas": [ @@ -930,7 +936,7 @@ ], "automated": { "kind": "e2e", - "ref": "objectui: e2e/live/required-when-submit.spec.ts (acceptance[0]: a requiredWhen-required Issued On blocks submit with its field error) + e2e/live/field-conditional-rules.spec.ts (acceptance[0]'s required marker and acceptance[3], on the New Invoice dialog as Status flips: Issued On's required marker, and paid_on mounted, and required, only at paid). Neither pins acceptance[2]'s client lock: on that New dialog the spec asserts the OPPOSITE at paid, tax_rate enabled and no read-only field group, because the server exempts INSERT from readonlyWhen and the create form follows it (objectui#13436); the lock lives on an edit form of a paid invoice, which no pin drives. Both read the DOM through Playwright locators with no screenshot step: their wait for visibility is what stands in for the render-first order acceptance[0] and [3]'s dom oracle asks for, so a run citing them says so. packages/qa/dogfood/test/invoice-conditional-rules-server.dogfood.test.ts, at the REST data door of the showcase as shipped, pins acceptance[1] and negative[0] (a sent invoice with no issued_on is 400 VALIDATION_FAILED with exactly issued_on required and nothing is stored; the draft twin and a dated sent invoice are 201), acceptance[2]'s server half and negative[1] (a tax_rate PATCH on the seeded paid INV-1003 answers 200 and the re-read is unchanged; the same PATCH on a draft persists) and acceptance[5]'s API half. No pin covers acceptance[2]'s client lock, acceptance[4] or negative[2]; the row-action spec cited here until revision 3 was dropped (history, revision 3)" + "ref": "objectui: e2e/live/required-when-submit.spec.ts (acceptance[0]: a requiredWhen-required Issued On blocks submit with its field error) + e2e/live/field-conditional-rules.spec.ts (acceptance[0]'s required marker and acceptance[3], on the New Invoice dialog as Status flips: Issued On's required marker, and paid_on mounted, and required, only at paid). Neither pins acceptance[2]'s client lock: on that New dialog the spec asserts the OPPOSITE at paid, tax_rate enabled and no read-only field group, because the server exempts INSERT from readonlyWhen and the create form follows it (objectui#13436); the lock lives on an edit form of a paid invoice, which no pin drives. Both read the DOM through Playwright locators with no screenshot step: their wait for visibility is what stands in for the render-first order acceptance[0] and [3]'s dom oracle asks for, so a run citing them says so. packages/qa/dogfood/test/invoice-conditional-rules-server.dogfood.test.ts, at the REST data door of the showcase as shipped, pins acceptance[1] and negative[0] (a sent invoice with no issued_on is 400 VALIDATION_FAILED with exactly issued_on required and nothing is stored; the draft twin and a dated sent invoice are 201), acceptance[2]'s server half and negative[1] (a tax_rate PATCH on the seeded paid INV-1003 answers 200 and the re-read is unchanged; the same PATCH on a draft persists) and acceptance[5]'s API half. acceptance[2]'s client lock (an edit form of a paid invoice), acceptance[4] and negative[2] (the row-action menu) stay live browser legs with no pin, which no HTTP pin reaches; the row-action spec cited here until revision 3 was dropped (history, revision 3)" }, "source": [ "examples/app-showcase/src/data/objects/invoice.object.ts#userActions (the B2 rules + userActions gating, with server-semantics comments)", @@ -973,6 +979,12 @@ "date": "2026-10-27", "change": "automated.ref names the dogfood pin for acceptance[1], acceptance[2]'s server half, acceptance[5]'s API half and negative[0]–[1] at the REST data door (#26573 slice 2). The client lock, acceptance[4] and negative[2] stay browser legs. No clause changed", "ref": "#26573" + }, + { + "revision": 7, + "date": "2026-10-27", + "change": "automated.ref says acceptance[2]'s client lock, acceptance[4] and negative[2] stay live browser legs, and why (#26573 Done-when). No clause changed", + "ref": "#26573" } ] }, @@ -1386,7 +1398,7 @@ "title": "Related lists derive from the relationship and page on the server — never fetch every child row — and are read-gated on both ends", "since": "v16", "status": "active", - "revision": 8, + "revision": 9, "priority": "P1", "surface": "browser", "personas": [ @@ -1465,7 +1477,7 @@ ], "automated": { "kind": "e2e", - "ref": "objectui: e2e/live/detail-related-list.spec.ts — acceptance[2] ONLY (the relationship-derived Projects related list on the Account detail page: Northwind's detail renders a Projects tab whose panel shows the projects' status labels). No pin covers the paging, pager-total, relatedList-config, row-navigation or read-gate clauses (acceptance[0], [1], [3], [4], [5])" + "ref": "objectui: e2e/live/detail-related-list.spec.ts — acceptance[2] ONLY (the relationship-derived Projects related list on the Account detail page: Northwind's detail renders a Projects tab whose panel shows the projects' status labels). packages/qa/dogfood/test/related-list-server-paging-and-gate.dogfood.test.ts, at the REST data door the related list reads through (GET /api/v1/data/showcase_contact scoped by the Northwind filter) on the showcase as shipped, pins the server halves: acceptance[1] and negative[1] (the filtered read holds the 26 seeded contacts; $top=10 pages at $skip 0, 10 and 20 each answer total 26 and return 10, 10 and 6 rows with no id repeated or skipped), acceptance[4] (each served child re-reads by its own id with the same row) and acceptance[5] with negative[2] (a member staffed contributor is refused the identical query 403 with no rows; a member staffed manager reads it 200 with rows). acceptance[0] and negative[0] (the console's own top/skip on every child request), the pager text, acceptance[3] (the Invoices tab's authored title and columns) and the Contacts tab's absence for the contributor stay live browser legs: they are judged on the console's requests and rendered detail page, which no HTTP pin reaches, and the card's ruling adds no Playwright lane (#26573)" }, "source": [ "#3358 §4 (evidence run captured exactly this trace)", @@ -1522,6 +1534,12 @@ "date": "2026-10-26", "change": "fixtures.knownGaps[1], the objectui-pinned-automation note, said the objectui specs live in a separate repo and none exist here; they are under ui/ in this checkout since the import, so it says where they resolve and how they run (devx seat 2's direction, carried for #26035). Re-read on main cc378e93. No clause changed", "ref": "#25846" + }, + { + "revision": 9, + "date": "2026-10-27", + "change": "automated.ref names the dogfood pin for the server halves of acceptance[1], [4], [5] and negative[1], [2] (#26573 slice 4), and says why acceptance[0], [3], negative[0] and the rendered halves stay live browser legs. No clause changed", + "ref": "#26573" } ] }, @@ -2345,7 +2363,7 @@ "title": "Dirty forms guard ACCIDENTAL closes: the X / Escape / backdrop prompt on a dirty form, the footer Cancel discards without a prompt by design, save proceeds, nothing is lost silently", "since": "v15", "status": "active", - "revision": 4, + "revision": 5, "priority": "P2", "surface": "browser", "personas": [ @@ -2393,7 +2411,7 @@ ], "automated": { "kind": "unit", - "ref": "objectui: packages/plugin-form/src/discardGuard.test.tsx" + "ref": "objectui: packages/plugin-form/src/discardGuard.test.tsx (acceptance[2]; the X close, the footer Cancel and the pristine paths, which hold acceptance[0] and negative[0] in part). acceptance[0] and negative[0]'s Escape and backdrop legs, which the suite does not drive, and acceptance[1] (the server re-read after each of the guard's two exits) stay live browser legs: every clause is judged on a close or a save the user makes in the console, so the re-read has nothing to read until a browser drives the exit, and the card's ruling adds no Playwright lane (#26573)" }, "source": [ "objectui: packages/plugin-form/src/{ModalForm,DrawerForm}.tsx (beforeunload/guard wiring; handleCancel — the footer Cancel is an intentional discard and closes with no prompt, while the guard rides the Radix onOpenChange close paths)", @@ -2423,6 +2441,12 @@ "date": "2026-10-26", "change": "acceptance[2].verify, from Tier 1 run #25815 (6679644a, pin b41ed7f68956), re-read at ui/packages/plugin-form/src/discardGuard.test.tsx on main cc378e93. (1) It ran the suite from an objectui checkout at the retired .objectui-sha pin; it now runs from ui/ in this checkout. (2) The '14/14' from QA run #23163 does not match this tree, which holds 11 it() cases; the verify names the count and its tree. (3) The suite covers the X close, Cancel and pristine paths but no Escape or backdrop click, so those legs of acceptance[0] and negative[0] are named as hand-driven. fixtures.knownGaps[0], the objectui-pinned-automation note, said the objectui specs live in a separate repo and none exist here; they are under ui/ in this checkout since the import, so it says where they resolve and how they run (devx seat 2's direction, carried for #26035). Re-read on main cc378e93. No clause changed", "ref": "#25846" + }, + { + "revision": 5, + "date": "2026-10-27", + "change": "automated.ref says what the unit suite holds and why the rest stays a live leg: every clause needs a browser to drive the guard's exit, acceptance[1]'s re-read included (#26573 Done-when). No clause changed", + "ref": "#26573" } ] }, @@ -2493,7 +2517,7 @@ "title": "User-built FilterBuilder/SortBuilder toolbar merges with the view filter, restores across nav, and reproduces rows from the URL", "since": "v16", "status": "active", - "revision": 7, + "revision": 8, "priority": "P1", "surface": "browser", "personas": [ @@ -2565,7 +2589,7 @@ ], "automated": { "kind": "e2e", - "ref": "objectui: e2e/live/user-filters.spec.ts — acceptance[4] ONLY: its dropdown-selection case pins the uf_ half (a uf_priority selection survives a reload), and its filter-tabs case, now on the Task Triage interface page, pins the uf__tab half (picking the urgent tab writes uf__tab=urgent, and it survives a reload). Both cases reproduce the filter on page.reload() and read only the URL param and the record-count bar's '2': neither captures the data request acceptance[4]'s network oracle names, nor compares row identity, so the same-request and same-rows half is driven by hand. No pin covers acceptance[0]–[3]; the saved-view spec cited here until revision 5 was dropped (history, revision 5)" + "ref": "objectui: e2e/live/user-filters.spec.ts — acceptance[4] ONLY: its dropdown-selection case pins the uf_ half (a uf_priority selection survives a reload), and its filter-tabs case, now on the Task Triage interface page, pins the uf__tab half (picking the urgent tab writes uf__tab=urgent, and it survives a reload). Both cases reproduce the filter on page.reload() and read only the URL param and the record-count bar's '2': neither captures the data request acceptance[4]'s network oracle names, nor compares row identity, so the same-request and same-rows half is driven by hand. No pin covers acceptance[0]–[3]; the saved-view spec cited here until revision 5 was dropped (history, revision 5). acceptance[0]–[3], acceptance[4]'s same-request half and negative[0]–[3] stay live browser legs: each is judged on the console's own data request (its filter and sort params), on the list a reload or an in-app navigation restores, or on what a second account sees in the same browser, which no HTTP-only pin reaches, and the card's ruling adds no Playwright lane (#26573)" }, "source": [ "objectui: packages/plugin-list/src/ListView.tsx (buildEffectiveFilter, convertFilterGroupToAST, mergeFilterNodes; $filter + $orderby assembly)", @@ -2616,6 +2640,12 @@ "date": "2026-10-26", "change": "automated.ref, from Tier 1 run #25815 (6679644a, pin b41ed7f68956), re-read at ui/e2e/live/user-filters.spec.ts on main cc378e93. (1) acceptance[4]'s two user-filters cases reproduce the filter on page.reload() and check the URL param and the record-count bar's '2' only. They never capture the data request the clause's network oracle names, nor compare row identity, so the ref now says that half is driven by hand. (2) The ref no longer names the saved-view spec it dropped at revision 5, which a ref parser read as a pin. fixtures.knownGaps[0], the objectui-pinned-automation note, said the objectui specs live in a separate repo and none exist here; they are under ui/ in this checkout since the import, so it says where they resolve and how they run (devx seat 2's direction, carried for #26035). Re-read on main cc378e93. No clause changed", "ref": "#25846" + }, + { + "revision": 8, + "date": "2026-10-27", + "change": "automated.ref says why the unpinned clauses stay live legs: every one is browser-only, and the card adds no Playwright lane (#26573 Done-when). No clause changed", + "ref": "#26573" } ] }, @@ -2850,7 +2880,7 @@ "title": "Record discussion: an @mention comment reconciles optimistically, persists to sys_comment, interleaves with activity, pings the mentioned user's bell — and the feed filter shows exactly each mode's subset without dropping author-extended activity", "since": "v16", "status": "active", - "revision": 8, + "revision": 9, "priority": "P1", "surface": "browser", "personas": [ @@ -2991,7 +3021,7 @@ ], "automated": { "kind": "api", - "ref": "The FEEDS_DISABLED clauses (6, 7) have engine/API pins that predate this item's coverage of them: packages/plugins/plugin-audit/src/capability-gate-update-verb.test.ts (describe '[#10170] enable.feeds is asked on the UPDATE verb too' — by-id and predicate re-thread onto `lead_walled`, plus the insert-verb control and the absent-thread_id boundary) and packages/qa/dogfood/test/comments-permission-matrix.dogfood.test.ts (the create side over real REST against the `cmt_nofeeds` fixture, packages/qa/dogfood/test/fixtures/comments-fixture.ts: its '(f) enable.feeds:false still answers FEEDS_DISABLED' case asserts the 403 and the code, and never reads the thread's zero rows after the refusal, so acceptance[5]'s zero-row half is the run's). ⚠️ Those pins run against their OWN fixtures and the objectql/REST seams — they do NOT reach the console half of the negative (panel hidden, sys_comment fetch skipped), and they never exercised the showcase surface, which is why clauses 6 and 7 still carry their own steps and the qa-feeds-disabled recipe. The remaining clauses (1-5: optimistic reconcile, activity interleave, mention bell) are browser-only and have no pin. The recipient read gate (acceptance[9]) is pinned by packages/plugins/plugin-audit/src/mention-recipient-gate.integration.test.ts (#24794: isolated, group and single cases, id and email spellings, author exclusion, the withheld-recipient log line), which reaches the ENGINE layer with plugin-security's read layer played by a middleware (knownGaps[2]); it scores that clause partial at most under RUNNER rule 6. acceptance[8]'s fallback oracle, where the milestone channel is unavailable, is objectui packages/plugin-detail/src/renderers/__tests__/recordActivityFeed.test.ts (the activity → feed map's superset pin and its UNMAPPED_ACTIVITY_FEED_TYPE fallback), a unit pin the run executes in the objectui tree." + "ref": "The FEEDS_DISABLED clauses (6, 7) have engine/API pins that predate this item's coverage of them: packages/plugins/plugin-audit/src/capability-gate-update-verb.test.ts (describe '[#10170] enable.feeds is asked on the UPDATE verb too' — by-id and predicate re-thread onto `lead_walled`, plus the insert-verb control and the absent-thread_id boundary) and packages/qa/dogfood/test/comments-permission-matrix.dogfood.test.ts (the create side over real REST against the `cmt_nofeeds` fixture, packages/qa/dogfood/test/fixtures/comments-fixture.ts: its '(f) enable.feeds:false still answers FEEDS_DISABLED' case asserts the 403 and the code, and never reads the thread's zero rows after the refusal, so acceptance[5]'s zero-row half is the run's). ⚠️ Those pins run against their OWN fixtures and the objectql/REST seams — they do NOT reach the console half of the negative (panel hidden, sys_comment fetch skipped), and they never exercised the showcase surface, which is why clauses 6 and 7 still carry their own steps and the qa-feeds-disabled recipe. The remaining clauses (1-5: optimistic reconcile, activity interleave, mention bell) are browser-only and have no pin. The recipient read gate (acceptance[9]) is pinned by packages/plugins/plugin-audit/src/mention-recipient-gate.integration.test.ts (#24794: isolated, group and single cases, id and email spellings, author exclusion, the withheld-recipient log line), which reaches the ENGINE layer with plugin-security's read layer played by a middleware (knownGaps[2]); it scores that clause partial at most under RUNNER rule 6. acceptance[8]'s fallback oracle, where the milestone channel is unavailable, is objectui packages/plugin-detail/src/renderers/__tests__/recordActivityFeed.test.ts (the activity → feed map's superset pin and its UNMAPPED_ACTIVITY_FEED_TYPE fallback), a unit pin the run executes in the objectui tree. A REST-door pin for acceptance[9] waits on #26625, the walled two-organization recipe: until it lands, acceptance[9]'s cross-organization leg stays a live leg on a walled boot (fixtures.knownGaps)." }, "history": [ { @@ -3041,6 +3071,12 @@ "date": "2026-10-27", "change": "The mention recipient gate now also asks each mentioned user's own read of the comment (fix landed in #26497 for #26453). personas[1] states that the @mention target must hold read on sys_comment as well as the record; acceptance[9] and negative[6] name both reads. No pin changed.", "ref": "#26497" + }, + { + "revision": 9, + "date": "2026-10-27", + "change": "automated.ref says acceptance[9]'s REST-door pin waits on #26625 and its cross-organization leg stays a live leg until then (#26573 Done-when). No clause changed", + "ref": "#26573" } ] }, @@ -4870,7 +4906,7 @@ "title": "Per-field trackHistory: a tracked edit lands the ADR-0052 §5b old→new timeline summary with display values, an untracked edit does not, and object-level enable.trackHistory gates the History tab and derived history op both ways", "since": "v15", "status": "active", - "revision": 8, + "revision": 9, "priority": "P1", "surface": "mixed", "personas": [ @@ -4937,7 +4973,7 @@ ], "automated": { "kind": "unit", - "ref": "packages/plugins/plugin-audit/src/audit-lookup-summary.test.ts (reference titles + read plan); audit-option-label-summary.test.ts (option labels); objectui e2e/live/record-history-display.spec.ts (the History tab's display contract, which negative[3] assigns to records-forms.crud-roundtrip. It opens showcase_account's History tab in a browser, the enable.trackHistory object, so it holds acceptance[2]'s flag-true half; it never reads the task detail, so the absence half has no pin and is driven by hand). packages/qa/dogfood/test/field-history-tracking-activity.dogfood.test.ts, at the REST doors of the showcase with AuditPlugin added as objectstack serve composes it: acceptance[0] and negative[2] (PATCH industry retail → finance lands a sys_activity row whose summary is exactly 'Industry: Retail → Finance', message key fieldChanges), acceptance[1] and negative[0] (a website-only PATCH lands the generic frame naming the Website label with no arrow and neither URL, message key activityUpdatedFields) and acceptance[3] (GET /api/v1/auth/me/permissions: showcase_account's apiOperations carry history, showcase_task's carry get and not history). acceptance[4] stays the unit pin's" + "ref": "packages/plugins/plugin-audit/src/audit-lookup-summary.test.ts (reference titles + read plan); audit-option-label-summary.test.ts (option labels); objectui e2e/live/record-history-display.spec.ts (the History tab's display contract, which negative[3] assigns to records-forms.crud-roundtrip. It opens showcase_account's History tab in a browser, the enable.trackHistory object, so it holds acceptance[2]'s flag-true half; it never reads the task detail, so the absence half has no pin and is driven by hand). packages/qa/dogfood/test/field-history-tracking-activity.dogfood.test.ts, at the REST doors of the showcase with AuditPlugin added as objectstack serve composes it: acceptance[0] and negative[2] (PATCH industry retail → finance lands a sys_activity row whose summary is exactly 'Industry: Retail → Finance', message key fieldChanges), acceptance[1] and negative[0] (a website-only PATCH lands the generic frame naming the Website label with no arrow and neither URL, message key activityUpdatedFields) and acceptance[3] (GET /api/v1/auth/me/permissions: showcase_account's apiOperations carry history, showcase_task's carry get and not history). acceptance[4] stays the unit pin's. acceptance[2]'s absence half and negative[1] (no History tab on the task detail) stay live browser legs: they are judged on the rendered detail page, which no HTTP pin reaches, and the card's ruling adds no Playwright lane (#26573)" }, "source": [ "packages/plugins/plugin-audit/src/audit-writers.ts#planTrackedLookupReads (planTrackedLookupReads), (renderTrackedChangeSummary — ADR-0052 §5b, #7230/#7289 localization), (zero-reads-by-default plan)", @@ -4993,6 +5029,12 @@ "date": "2026-10-27", "change": "automated.ref names the dogfood pin for acceptance[0], [1], [3] and negative[0], [2] over REST (#26573 slice 2). Measured: the untracked edit's row is the #25787 generic frame, which names the changed field's label and never its values, so acceptance[1]'s 'no field-diff summary naming website' holds as 'no old → new for website'. acceptance[2]'s absence half and negative[1] stay browser legs. No clause changed", "ref": "#26573" + }, + { + "revision": 9, + "date": "2026-10-27", + "change": "automated.ref says negative[1] and acceptance[2]'s absence half stay live browser legs, and why (#26573 Done-when). No clause changed", + "ref": "#26573" } ] }, @@ -5688,7 +5730,7 @@ "title": "Defaults that changed meaning on v18 (ADR-0087 D3 18.* entries): an omitted list-view page size is 50 (was 25), an omitted editInline means editable in place, an autonumber with no `unique` is unique per organization, and a field-heavy form opens as a bounded modal on desktop", "since": "v18", "status": "active", - "revision": 3, + "revision": 4, "priority": "P2", "surface": "mixed", "personas": [ @@ -5745,7 +5787,7 @@ ], "automated": { "kind": "unit", - "ref": "packages/spec/src/data/field-autonumber-default-unique.test.ts + packages/spec/src/ui/view.test.ts + packages/spec/src/data/record-surface.test.ts" + "ref": "packages/spec/src/data/field-autonumber-default-unique.test.ts + packages/spec/src/ui/view.test.ts + packages/spec/src/data/record-surface.test.ts (acceptance[0], the defaults at the spec layer). acceptance[1]–[3] stay live browser legs: the 50-row first page, the inline-edit switch and the bounded or full-screen create form are judged on the rendered console (dom and screenshot oracles), which no HTTP pin reaches, and the card's ruling adds no Playwright lane (#26573)" }, "source": [ "packages/spec/src/data/record-surface.ts#deriveRecordFlowSurface", @@ -5771,6 +5813,12 @@ "date": "2026-10-26", "change": "fixtures.knownGaps[0] named the .objectui-sha pin as live; it now records what runs #24558 and #24616 read at that pin and says the console builds from ui/ with pnpm console:build. Carried for #26035. No clause changed", "ref": "#25846" + }, + { + "revision": 4, + "date": "2026-10-27", + "change": "automated.ref says the spec pins hold acceptance[0] and that acceptance[1]–[3] stay live browser legs, and why (#26573 Done-when). No clause changed", + "ref": "#26573" } ] } diff --git a/packages/qa/dogfood/test/related-list-server-paging-and-gate.dogfood.test.ts b/packages/qa/dogfood/test/related-list-server-paging-and-gate.dogfood.test.ts new file mode 100644 index 00000000000..4fcc306bbf3 --- /dev/null +++ b/packages/qa/dogfood/test/related-list-server-paging-and-gate.dogfood.test.ts @@ -0,0 +1,121 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// Checklist item `records-forms.related-list-server-pagination` (#26573): the +// server half of its clauses, at the REST data door the related list reads +// through — `GET /api/v1/data/showcase_contact` scoped by the parent filter — +// on the showcase as shipped (Northwind carries 26 seeded contacts). +// +// acceptance[1]'s server half, negative[1] — the parent-scoped list answers +// the filtered total (26), and $top/$skip pages partition the set: no id +// repeated or skipped across the pages, and their union is the filtered +// set the unpaged count reads. +// acceptance[4] — each child the related list serves re-reads by its own +// API id with the same row. +// acceptance[5]'s server half, negative[2]'s server half — the child read is +// gated at the server on both ends: a member staffed `contributor` (whose +// set omits showcase_contact read) is refused the identical parent-scoped +// query 403, and a member staffed `manager` reads it 200 with rows. +// +// The console's request shape (acceptance[0], negative[0]: `top`/`skip` on +// every child request), the pager text, the derived tabs and the relatedList +// config (acceptance[2], [3]) and the tab's absence for the contributor stay +// network/dom legs. + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { type VerifyStack } from '@objectstack/verify'; +import { bootShowcase } from './showcase-boot.js'; + +const SYS = { context: { isSystem: true } } as const; +const CONTACT = 'showcase_contact'; + +type Answer = { status: number; json: any; text: string }; + +describe('records-forms.related-list-server-pagination — the parent-scoped child read over REST', () => { + let stack: VerifyStack; + let ql: any; + let adminTok = ''; + let contributorTok = ''; + let managerTok = ''; + let northwindId = ''; + + const call = async (token: string, path: string): Promise => { + const res = await stack.apiAs(token, 'GET', path); + const text = await res.text(); + let json: any = {}; + try { json = JSON.parse(text); } catch { /* not JSON */ } + return { status: res.status, json, text }; + }; + const rowsOf = (json: any): any[] => json.records ?? json.data ?? json.value ?? []; + const childPath = (extra = '') => + `/data/${CONTACT}?$filter=${encodeURIComponent(JSON.stringify([['account', '=', northwindId]]))}${extra}`; + + /** Sign a member up and staff one showcase position on their own membership. */ + const staffed = async (email: string, position: string): Promise => { + const token = await stack.signUp(email, 'Rel!Pass12345', `Related list ${position}`); + const [user] = await ql.find('sys_user', { where: { email }, limit: 1 }, SYS); + let membership: any; + for (let i = 0; i < 40 && !membership; i++) { + [membership] = await ql.find('sys_member', { where: { user_id: user.id }, limit: 1 }, SYS); + if (!membership) await new Promise((r) => setTimeout(r, 250)); + } + expect(membership, `a sys_member row for ${email}`).toBeTruthy(); + await ql.insert('sys_user_position', { user_id: user.id, position, organization_id: membership.organization_id }, SYS); + expect((await stack.contextFor(token)).positions ?? []).toContain(position); + return token; + }; + + beforeAll(async () => { + stack = await bootShowcase(); + adminTok = await stack.signIn(); + ql = await stack.kernel.getServiceAsync('objectql'); + const [northwind] = await stack.rows('showcase_account', { name: 'Northwind' }); + northwindId = String(northwind.id); + contributorTok = await staffed('rel-contributor@verify.test', 'contributor'); + managerTok = await staffed('rel-manager@verify.test', 'manager'); + }, 180_000); + + afterAll(async () => { + await stack?.stop(); + }); + + it('acceptance[1], negative[1]: the filtered total is 26 and $top/$skip pages partition it', async () => { + const all = await call(adminTok, childPath('&$top=200')); + expect(all.status, all.text).toBe(200); + const allIds = rowsOf(all.json).map((r) => String(r.id)); + expect(allIds, 'Northwind carries the 26 seeded contacts').toHaveLength(26); + + const pageIds: string[][] = []; + for (const skip of [0, 10, 20]) { + const page = await call(adminTok, childPath(`&$top=10&$skip=${skip}&$orderby=name&$count=true`)); + expect(page.status, page.text).toBe(200); + expect(page.json).toMatchObject({ total: 26, hasMore: skip < 20 }); + pageIds.push(rowsOf(page.json).map((r) => String(r.id))); + } + expect(pageIds.map((p) => p.length)).toEqual([10, 10, 6]); + const union = pageIds.flat(); + expect(new Set(union).size, 'no id repeated across pages').toBe(union.length); + expect([...union].sort(), 'no id skipped').toEqual([...allIds].sort()); + }); + + it('acceptance[4]: each served child re-reads by its own API id with the same row', async () => { + const page = await call(adminTok, childPath('&$top=5&$orderby=name')); + expect(page.status, page.text).toBe(200); + const served = rowsOf(page.json); + expect(served).toHaveLength(5); + for (const row of served) { + const read = await call(adminTok, `/data/${CONTACT}/${row.id}`); + expect(read.status, read.text).toBe(200); + expect(read.json.record ?? read.json).toMatchObject({ id: row.id, name: row.name, email: row.email, account: northwindId }); + } + }); + + it('acceptance[5], negative[2]: the contributor is refused the parent-scoped child query 403; the manager reads it 200 with rows', async () => { + const refused = await call(contributorTok, childPath('&$top=10')); + expect(refused.status, refused.text).toBe(403); + expect(rowsOf(refused.json)).toHaveLength(0); + + const served = await call(managerTok, childPath('&$top=10')); + expect(served.status, served.text).toBe(200); + expect(rowsOf(served.json).length).toBeGreaterThan(0); + }); +}); diff --git a/packages/qa/dogfood/test/view-type-gallery-data-halves.dogfood.test.ts b/packages/qa/dogfood/test/view-type-gallery-data-halves.dogfood.test.ts new file mode 100644 index 00000000000..adaa776a1df --- /dev/null +++ b/packages/qa/dogfood/test/view-type-gallery-data-halves.dogfood.test.ts @@ -0,0 +1,99 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// Checklist item `records-forms.view-type-gallery` (#26573): the API half of +// its two api clauses, on the showcase as shipped — what the views declare +// (`GET /api/v1/meta/view?object=showcase_task`) and what the data door +// answers for the fields they name. +// +// acceptance[1] — the kanban view groups by status and summarizes +// estimate_hours; every one of the five status columns holds seeded tasks; +// and the server aggregate (`POST /data/showcase_task/query`, group by +// status, sum estimate_hours) agrees, column by column, with the raw rows +// grouped client-side. +// acceptance[2] — the temporal views name their true date fields (calendar +// due_date, timeline created_at, gantt start_date → end_date with +// progressField progress), and the seeded tasks carry those values over +// the API (gantt bars never end before they start). +// +// Placement on the canvas, the per-variant screenshots (acceptance[0], +// negative[0]), map and chart (acceptance[3], negative[1]) and the tree +// (acceptance[4]) stay browser legs; acceptance[5] stays the smoke spec's. + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { type VerifyStack } from '@objectstack/verify'; +import { bootShowcase } from './showcase-boot.js'; + +const TASK = 'showcase_task'; +const COLUMNS = ['backlog', 'todo', 'in_progress', 'in_review', 'done']; + +describe('records-forms.view-type-gallery — the view declarations and the data they read', () => { + let stack: VerifyStack; + let token = ''; + let views: any[] = []; + let tasks: any[] = []; + + const call = async (method: string, path: string, body?: unknown) => { + const res = await stack.apiAs(token, method, path, body); + const text = await res.text(); + let json: any = {}; + try { json = JSON.parse(text); } catch { /* not JSON */ } + return { status: res.status, json, text }; + }; + const viewOfType = (type: string) => { + const hit = views.filter((v) => v.type === type); + expect(hit, `exactly one showcase_task ${type} view`).toHaveLength(1); + return hit[0]; + }; + + beforeAll(async () => { + stack = await bootShowcase(); + token = await stack.signIn(); + const meta = await call('GET', `/meta/view?object=${TASK}`); + expect(meta.status, meta.text).toBe(200); + views = (meta.json.items as any[]).map((item) => item.config); + const list = await call('GET', `/data/${TASK}?$top=500`); + expect(list.status, list.text).toBe(200); + tasks = list.json.records ?? list.json.data ?? list.json.value ?? []; + }, 180_000); + + afterAll(async () => { + await stack?.stop(); + }); + + it('acceptance[1]: the kanban groups by status, every column holds seeded tasks, and the server aggregate agrees per column', async () => { + const board = viewOfType('kanban'); + expect(board.kanban).toMatchObject({ groupByField: 'status', summarizeField: 'estimate_hours' }); + + const byStatus = new Map(); + for (const t of tasks) byStatus.set(t.status, (byStatus.get(t.status) ?? 0) + Number(t.estimate_hours ?? 0)); + for (const column of COLUMNS) { + expect(tasks.filter((t) => t.status === column).length, `column ${column} is populated`).toBeGreaterThan(0); + } + + const agg = await call('POST', `/data/${TASK}/query`, { + groupBy: ['status'], aggregations: [{ function: 'sum', field: 'estimate_hours', alias: 'hours' }], + }); + expect(agg.status, agg.text).toBe(200); + const rows: any[] = agg.json.records; + expect(rows.map((r) => r.status).sort(), 'one aggregate row per column').toEqual([...COLUMNS].sort()); + const server = new Map(rows.map((r) => [r.status, Number(r.hours ?? 0)])); + for (const column of COLUMNS) { + expect(server.get(column) ?? 0, `column ${column}`).toBeCloseTo(byStatus.get(column) ?? 0, 4); + } + }); + + it('acceptance[2]: the temporal views name their true date fields, and the seeded tasks carry them', async () => { + expect(viewOfType('calendar').calendar).toMatchObject({ startDateField: 'due_date' }); + expect(viewOfType('timeline').timeline).toMatchObject({ startDateField: 'created_at' }); + expect(viewOfType('gantt').gantt).toMatchObject({ startDateField: 'start_date', endDateField: 'end_date', progressField: 'progress' }); + + expect(tasks.filter((t) => t.due_date).length, 'tasks the calendar can place').toBeGreaterThanOrEqual(2); + expect(tasks.every((t) => t.created_at), 'every task carries created_at for the timeline').toBe(true); + const bars = tasks.filter((t) => t.start_date && t.end_date); + expect(bars.length, 'tasks the gantt can draw').toBeGreaterThanOrEqual(2); + for (const t of bars) { + expect(String(t.end_date) >= String(t.start_date), `${t.title}: ${t.start_date} → ${t.end_date}`).toBe(true); + expect(typeof t.progress === 'number', `${t.title}: progress`).toBe(true); + } + }); +});