diff --git a/docs/qa/platform-checklist/areas/records-forms.json b/docs/qa/platform-checklist/areas/records-forms.json index 11379f0ea84..7691ed80d86 100644 --- a/docs/qa/platform-checklist/areas/records-forms.json +++ b/docs/qa/platform-checklist/areas/records-forms.json @@ -5135,10 +5135,10 @@ }, { "id": "records-forms.field-history-tracking", - "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", + "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 record page's sys_audit_log field-change read and derived history op both ways", "since": "v15", "status": "active", - "revision": 9, + "revision": 10, "priority": "P1", "surface": "mixed", "personas": [ @@ -5148,17 +5148,18 @@ "app": "showcase", "requires": [ "showcase_account: enable.trackHistory:true (account.object.ts) + per-field trackHistory on industry/status — the BOTH-halves fixture", - "showcase_task: per-field trackHistory on status/priority (examples/app-showcase/src/data/objects/task.object.ts#Task: its status and priority fields declare trackHistory) with NO object-level enable block — the natural negative for the tab/op gating" + "showcase_task: per-field trackHistory on status/priority (examples/app-showcase/src/data/objects/task.object.ts#Task: its status and priority fields declare trackHistory) with NO object-level enable block — the natural negative for the audit-read/op gating" ], "knownGaps": [ - "no showcase object declares trackHistory:true on a REFERENCE field, so the referenced-record-title rendering (clause 5) is a unit-pin verdict (audit-lookup-summary.test.ts) unless a scratch tracked lookup is authored — record which the verdict rests on" + "no showcase object declares trackHistory:true on a REFERENCE field, so the referenced-record-title rendering (clause 5) is a unit-pin verdict (audit-lookup-summary.test.ts) unless a scratch tracked lookup is authored — record which the verdict rests on", + "showcase_task's record page is the authored kind:'full' showcase_task_detail page (examples/app-showcase/src/ui/pages/task-detail.page.ts), which draws no timeline stream, and RecordDetailView serves its sys_audit_log read only to a synthesized page (RecordDetailView.recordOpenWaves-13010.test.tsx: 'an assigned page of a trackHistory object never reads the audit log'). So the task's absent read holds with or without enable.trackHistory, and it does not prove the flag half of the gate on its own. No showcase object pairs per-field flags, no enable block and a synthesized page. The flag half rests on the historyEnabled code read unless a scratch object with that shape is authored" ] }, "steps": [ "boot the showcase on its own port and DB; sign in as admin; open a seeded account record", "edit the TRACKED select `industry` (e.g. Technology → Finance); save; read the record's sys_activity rows over the API and open the timeline", "edit an UNTRACKED field (e.g. website) alone; save; re-read sys_activity for the record", - "open the account's History tab; then open a seeded task's detail and look for a History tab", + "record the network log from page load, open the account record and select its timeline stream's Changes chip (变更). Record page v3 has no History tab at any width (#27055). Where the stream sits depends on the record view's width (ui/packages/plugin-detail/src/synth/recordPageColumns.ts#recordPageTierFor), which is the window minus the console sidebar. At 760 px and wider it is resident: the Timeline column, or, while the reader's timeline switch is off, the Activity card at the foot of the related column. The switch is off by default for an object that is not worked, and showcase_account is not one (ui/packages/plugin-detail/src/synth/recordPageColumns.ts#recordTimelineDefaultReason answers null). Narrower, it is the Timeline tab. Measured on this item's re-point: a 1440×900 window gave 1184 px (sidebar expanded) and a 1000×800 window 952 px (sidebar as its icon rail), both columns with no tab strip; only a 700×800 window drew the tabs (Details / Related / Timeline). Then open a seeded task's detail and read the network log again", "GET /api/v1/auth/me/permissions and compare apiOperations for showcase_account vs showcase_task" ], "acceptance": [ @@ -5175,10 +5176,10 @@ "evidence": "the sys_activity read" }, { - "clause": "the History TAB gates on OBJECT-level enable.trackHistory, both ways: the account detail renders the tab (flag true) and the task detail does NOT (no enable block — per-field flags alone do not summon it)", - "oracle": "dom", - "verify": "screenshot both detail pages after settle (the task detail page carries no tablist at all, so corroborate its 'no History tab' with the absent sys_audit_log fetch, QA run #24616); gating ruled at objectui RecordDetailView.tsx (trackHistory must be explicitly true)", - "evidence": "both screenshots" + "clause": "the record page's sys_audit_log field-change history gates on OBJECT-level enable.trackHistory, both ways: the account detail (flag true) reads sys_audit_log and its timeline stream's Changes chip lists the tracked edits as old → new lines with display values; the task detail (no enable block — per-field flags alone do not summon it) makes NO sys_audit_log read", + "oracle": "network", + "verify": "the network log after settle: one sys_audit_log read for the account record (at page load in a column tier; in the tabbed tier when the Timeline tab opens, or at load under ?tab=timeline) and none for the task; screenshot the account's Changes chip showing the industry edit. Gating ruled at ui/packages/app-shell/src/views/RecordDetailView.tsx (historyEnabled: enable.trackHistory explicitly true, sys_audit_log registered, the viewed object not sys_audit_log itself; and the read serves only the synthesized page's stream, never an assigned page); the read's rows reach the stream through RecordFieldChangesContext as field_change items (ui/packages/plugin-detail/src/recordTimelineStream.ts#auditEntriesToFeedItems). The task half is over-determined (knownGaps): record which gate the verdict rests on. ⛔ An empty Changes chip is never an absence oracle: the chip also lists the stream's sys_activity created/updated rows (ACTIVITY_TYPE_TO_FEED_TYPE maps both to field_change). Measured on a seeded account, the chip showed a sys_activity 'Updated Account …' row beside the audit read. ⛔ Nor is a History tab: record page v3 removed it at every width (#27055; buildDefaultPageSchema.test.ts asserts 'no Activity or History tab'), so looking for one fails a working product (QA run #27438)", + "evidence": "the two network logs + the account's Changes screenshot" }, { "clause": "the derived `history` API operation follows get ∧ enable.trackHistory, both ways: present in showcase_account's effective operation vocabulary, absent from showcase_task's", @@ -5194,10 +5195,9 @@ } ], "negative": [ - "an untracked edit producing an old→new diff in the sys_activity summary / Discussion timeline is a FAIL — trackHistory is opt-in per field (spec default false, object.zod.ts family note), and the per-field opt-in governs exactly that surface. ⛔ Not the History tab: it reads sys_audit_log (the compliance audit, objectui RecordDetailView.tsx), which records every field and is not per-field-gated, so 'Website: old → new' there for the untracked field is correct; sys_activity.metadata also carries raw old/new for untracked fields by design — only the rendered summary is gated (QA run #24616)", - "a task detail rendering a History tab is a FAIL — per-field flags without the object-level opt-in must not summon it", - "a tracked select rendering raw option VALUES ('in_progress') instead of labels is a FAIL (#7289 closed exactly that)", - "cross-ref guard: do NOT re-score crud-roundtrip clause 5 (History-tab display quality on the create/update pair) here — this item owns the tracked/untracked boundary and the two gating directions, that one owns the tab's rendering contract" + "an untracked edit producing an old→new diff in the sys_activity summary is a FAIL — trackHistory is opt-in per field (spec default false, object.zod.ts family note), and the per-field opt-in governs exactly that surface. ⛔ Not the timeline stream's audit-log lines (on a trackHistory object they fill the Changes chip, and the stream's sys_activity `updated` rows step aside for them: ui/packages/plugin-detail/src/recordTimelineStream.ts#mergeTimelineStream): they come from sys_audit_log (the compliance audit, ui/packages/app-shell/src/views/RecordDetailView.tsx), which records every field and is not per-field-gated, so 'Website old → new' there for the untracked field is correct; sys_activity.metadata also carries raw old/new for untracked fields by design — only the rendered summary is gated (QA runs #24616, #27438)", + "a sys_audit_log read on the task detail is a FAIL — per-field flags without the object-level opt-in must not summon the field-change history; judged on the network log, never on an empty Changes chip or a missing tab (acceptance[2].verify says why)", + "a tracked select rendering raw option VALUES ('in_progress') instead of labels is a FAIL (#7289 closed exactly that)" ], "traps": [ "hydration-race", @@ -5205,13 +5205,14 @@ ], "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. 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)" + "ref": "packages/plugins/plugin-audit/src/audit-lookup-summary.test.ts (reference titles + read plan); audit-option-label-summary.test.ts (option labels); ui/e2e/live/record-history-display.spec.ts (the record history's display contract, which the cross-ref in source[4] assigns to records-forms.crud-roundtrip. At 1440×900, a column tier whatever the sidebar does, it opens the timeline stream's Changes chip on showcase_account in a browser, the enable.trackHistory object, so it holds acceptance[2]'s flag-true DOM half; it reads no network log and never opens the task detail, so the read gate both ways 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 sys_audit_log read from the task detail) stay live browser legs: they are judged on the network log of 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)", "packages/spec/src/data/object.zod.ts#trackHistory (history derives from get ∧ trackHistory), (opt-in default false); packages/spec/src/data/api-derivation.ts", - "objectui: packages/app-shell/src/views/RecordDetailView.tsx (History tab three-precondition gate)", - "examples/app-showcase/src/data/objects/account.object.ts (the deliberate both-halves fixture + its comment ruling the tab gate), examples/app-showcase/src/data/objects/task.object.ts#Task (its status and priority fields declare trackHistory)" + "ui/packages/app-shell/src/views/RecordDetailView.tsx (historyEnabled, the three-precondition gate on the sys_audit_log read; record page v3 hands its rows to the timeline stream, #27055)", + "examples/app-showcase/src/data/objects/account.object.ts (the deliberate both-halves fixture), examples/app-showcase/src/data/objects/task.object.ts#Task (its status and priority fields declare trackHistory)", + "cross-ref records-forms.crud-roundtrip clause 5 (acceptance[4]) — it owns the record history's display contract on the create/update pair (option labels, localized dates, real actor); this item owns the tracked/untracked boundary and the two gating directions, so do not re-score that contract here" ], "history": [ { @@ -5267,6 +5268,12 @@ "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" + }, + { + "revision": 10, + "date": "2026-10-29", + "change": "acceptance[2], negative[1], steps[3] and the title re-pointed from the History tab to the record page v3 surface, from QA run #27438 (18.3.0 sweep, wave 3). Record page v3 (#27055) removed the History tab at every width, and the gate now lives on the sys_audit_log read: the account's read fills the timeline stream's Changes chip, and the task makes none. acceptance[2]'s oracle moves from dom to network, because the Changes chip also lists sys_activity rows and is no absence oracle. Measured at base 882d4e4a5c on a booted showcase with the console dev server. Account: one audit read at load, no tab strip, at 1440×900 (record view 1184 px) and 1000×800 (952 px, the sidebar as its icon rail). Only 700×800 drew tabs (Details / Related / Timeline, no History), and its read went out when Timeline opened. Task: the authored showcase_task_detail page, no stream, no audit read at any of the three widths. So that half is over-determined (new knownGaps entry). negative[0]'s exemption now names the stream's audit-log lines instead of the History tab. negative[3], a cross-reference that could only score skipped, moved to source[4]. source[2] and automated.ref point at ui/ and at the re-pointed live spec, record-history-display.spec.ts, which now reads the Changes chip at 1440×900 (passed once on that boot)", + "ref": "#27492" } ] }, diff --git a/ui/e2e/live/record-history-display.spec.ts b/ui/e2e/live/record-history-display.spec.ts index 5f31efc9a03..024da829667 100644 --- a/ui/e2e/live/record-history-display.spec.ts +++ b/ui/e2e/live/record-history-display.spec.ts @@ -1,22 +1,26 @@ import { test, expect, type APIRequestContext } from '@playwright/test'; /** - * Live e2e for the record detail History tab's display pipeline - * (app-shell auditHistoryDisplay → record:history → HistoryTimeline). + * Live e2e for the record page's field-change display pipeline + * (app-shell `sys_audit_log` read → `auditEntriesToFeedItems` → + * `RecordFieldChangesContext` → the timeline stream's Changes chip). * - * Regression for the gantt QA report where the tab rendered raw audit - * payloads: ISO datetimes ("2026-08-04T12:00:00.000Z"), raw lookup id - * arrays, raw select values, and "Unknown user" for every entry. + * Regression for the gantt QA report where the record history rendered raw + * audit payloads: ISO datetimes ("2026-08-04T12:00:00.000Z"), raw lookup id + * arrays, raw select values, and "Unknown user" for every entry. Record page + * v3 (objectstack#27055) removed the History tab at every width: the same + * enriched audit entries are now the field_change lines of the ONE timeline + * stream, under its Changes (变更) chip. * * Targets `showcase_account`, the showcase's `enable.trackHistory` object — - * the console gates the History tab on that flag. Its audited field mix: two - * selects (`industry`, and `status` labelled "Lifecycle"), a date - * (`signed_on`, "Customer Since") and the record owner, a user lookup - * (`owner_id`). No history-tracked showcase object carries a boolean or a - * datetime field, so booleans-as-words and datetime localization are not - * exercised here; the unit suite `auditHistoryDisplay.test.ts` in app-shell - * pins both ("localizes booleans through t with Yes/No fallback", "localizes - * datetime values instead of raw ISO strings"). + * the console reads `sys_audit_log` only for an object with that flag. Its + * audited field mix: two selects (`industry`, and `status` labelled + * "Lifecycle"), a date (`signed_on`, "Customer Since") and the record owner, a + * user lookup (`owner_id`). No history-tracked showcase object carries a + * boolean or a datetime field, so booleans-as-words and datetime localization + * are not exercised here; the unit suite `auditHistoryDisplay.test.ts` in + * app-shell pins both ("localizes booleans through t with Yes/No fallback", + * "localizes datetime values instead of raw ISO strings"). */ const API = process.env.LIVE_API_URL || 'http://localhost:3000'; const EMAIL = process.env.LIVE_EMAIL || 'admin@objectos.ai'; @@ -38,16 +42,24 @@ async function seededUsers(request: APIRequestContext): Promise<{ actor: SysUser /** * The synthesized record page lays itself out by the width of the view it - * occupies (objectstack#26678): the tab strip this spec opens exists only - * below the two-column floor (`RECORD_PAGE_TWO_COLUMN_MIN_WIDTH` in - * `@object-ui/plugin-detail`). A 1024 px window with the console sidebar - * expanded is already two columns (objectstack#27241), so the spec pins a - * viewport whose record view stays under that floor once the 256 px sidebar - * takes its share — the page's tabbed tier. + * occupies (objectstack#26678): a column tier at `RECORD_PAGE_TWO_COLUMN_MIN_WIDTH` + * (`@object-ui/plugin-detail`) and wider, the tab strip below it. The stream + * is resident in either column tier — the timeline column, or, while the + * reader's timeline switch is off (the default for showcase_account, which is + * not a worked object), the Activity card at the related column's foot — and + * mounted at open, so the spec needs no tab and no switch. + * + * A 1440 px window is a column tier whatever the console sidebar does: 1184 px + * of record view with the sidebar expanded (objectstack#27241's three-column + * window), more with it collapsed. ⛔ Not a window near 1000 px: whether that + * is the tabbed tier depends on the sidebar's state. Measured on the showcase + * at 1000×800 the sidebar came up as its icon rail and the page drew two + * columns with no tab strip at all, so a spec that waited for a tab there + * timed out. */ -test.use({ viewport: { width: 1000, height: 800 } }); +test.use({ viewport: { width: 1440, height: 900 } }); -test('History tab shows display values (localized dates, option labels, resolved lookups, real actor) instead of raw audit payloads', async ({ page, request }) => { +test('the timeline stream\'s Changes chip shows display values (localized dates, option labels, resolved lookups, real actor) instead of raw audit payloads', async ({ page, request }) => { const { actor, other } = await seededUsers(request); // 1) Seed an account and drive three audited updates through the real API — @@ -71,22 +83,24 @@ test('History tab shows display values (localized dates, option labels, resolved expect(res.ok()).toBeTruthy(); } - // 2) Open the synthesized record page and its History tab. + // 2) Open the synthesized record page and select the timeline stream's + // Changes chip — the field changes alone (the audit entries, plus the + // activity stream's own `created` row). await page.goto(`/apps/showcase_app/showcase_account/record/${id}`); - const historyTab = page.getByRole('tab', { name: /^(History|历史)$/ }); - await historyTab.waitFor({ state: 'visible', timeout: 15_000 }); - await historyTab.click(); - - const panel = page.getByRole('tabpanel', { name: /History|历史/ }); - // The three UPDATE entries render as diff lines; wait for the latest one. - await expect(panel.getByText('Owner', { exact: false }).first()).toBeVisible({ timeout: 15_000 }); - const text = (await panel.innerText()).replace(/\s+/g, ' '); + const stream = page.getByRole('region', { name: /^(Timeline|时间线)$/ }); + const changesChip = stream.locator('[data-stream-filter="changes"]'); + await changesChip.waitFor({ state: 'visible', timeout: 15_000 }); + await changesChip.click(); + await expect(changesChip).toHaveAttribute('aria-pressed', 'true'); + // The three UPDATE entries render as change lines; wait for the latest one. + await expect(stream.locator('[data-stream-change]', { hasText: 'Owner' }).first()).toBeVisible({ timeout: 15_000 }); + const text = (await stream.innerText()).replace(/\s+/g, ' '); // 3) Actor attribution: the audit user resolves to a display name — - // never the raw id, never the unknown-user fallback. + // never the raw id, never the unknown-user fallback (`detail.unknownUser`). expect(text).toContain(actor.name); expect(text).not.toContain(actor.id); - expect(text).not.toMatch(/Unknown user|未知用户/); + expect(text).not.toMatch(/\bUnknown\b|未知用户/); // 4) Select values render as option labels, not raw stored values. expect(text).toMatch(/Prospect/);