From d7613848b0b1442b84b653c545f3fc72323072d1 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 04:57:12 +0000 Subject: [PATCH] test(app-shell): pin the four remaining /layers reads' 404 as "never published" (objectui#11799) The four GET /meta/:type/:name/layers reads objectui#11892 and objectui#11928 left open cannot be skipped with what their editors hold. The framework keeps its 404 for a name with no stored layer, the author included (objectstack-ai/objectstack#22397, answer (a)), and MetadataClient.layered() already resolves that 404 as an envelope with every layer null, so none of these reads raises an error for it. Pins, each over the real editor and a real MetadataClient on an in-memory server answering as the framework does: - the metadata edit page's first load of a draft-only item: no error, no lock banner, no reset/delete control, Layers sheet with no overlay; controls: a published item's layers are used, a 5xx shows the load error; - the embedded item editor: opening sends no read; controls: a published parent's save splices into its layers, a 5xx is the save's error; - the permission matrix's package-door save re-read of a "+ New" set: 404, saved over its own slice, no refusal; controls: a published set keeps other packages' rows, a 5xx still refuses; - the list-view panel, a view that exists nowhere: 404, no failure, the canvas shows the view the first edit creates; control: a 5xx is the load failure. Claude-Session: https://claude.ai/code/session_01CGZy1BGCjdN5cXqL9cnvB8 Co-authored-by: Claude --- .changeset/11799-layers-draft-only.md | 4 + ...beddedItemEditor.layersRead-11799.test.tsx | 184 ++++++++++++++++++ ...nMatrixEditor.layersUnsaved-11799.test.tsx | 92 ++++++++- ...ourceEditPage.layersUnsaved-11799.test.tsx | 89 ++++++++- ...ViewInspector.layersUnsaved-11799.test.tsx | 81 +++++++- 5 files changed, 431 insertions(+), 19 deletions(-) create mode 100644 .changeset/11799-layers-draft-only.md create mode 100644 packages/app-shell/src/views/metadata-admin/EmbeddedItemEditor.layersRead-11799.test.tsx diff --git a/.changeset/11799-layers-draft-only.md b/.changeset/11799-layers-draft-only.md new file mode 100644 index 0000000000..da00b30a56 --- /dev/null +++ b/.changeset/11799-layers-draft-only.md @@ -0,0 +1,4 @@ +--- +--- + +No release: tests only. `@object-ui/app-shell` gains pins for the four `GET /meta/:type/:name/layers` reads objectui#11799 left open: the metadata edit page's first load, the embedded item editor's save read, the permission matrix's package-door save re-read, and the list-view panel's read of a view that exists nowhere yet. None of them can be skipped with what its editor holds. The framework keeps answering 404 for a name with no stored layer, its author included (objectstack-ai/objectstack#22397, answer (a)), and `MetadataClient.layered()` already resolves that 404 as an envelope with every layer null, so none of these reads raises an error for it. The pins hold that in place: a draft-only item opens with no error state, a published item still gets its layers, and a failed read (a 5xx) still shows its error. No executable code changed (objectui#11799). diff --git a/packages/app-shell/src/views/metadata-admin/EmbeddedItemEditor.layersRead-11799.test.tsx b/packages/app-shell/src/views/metadata-admin/EmbeddedItemEditor.layersRead-11799.test.tsx new file mode 100644 index 0000000000..52dfe7302b --- /dev/null +++ b/packages/app-shell/src/views/metadata-admin/EmbeddedItemEditor.layersRead-11799.test.tsx @@ -0,0 +1,184 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectui#11799 — the embedded item editor's one `GET …/layers` read. + * + * `EmbeddedItemEditor` edits an item that lives inside a parent body + * (`object.fields.amount`). It reads the parent's layered view once, when the + * author saves, to splice the item back into the parent's current body. It + * holds only the item snapshot and the parent's name, so nothing in hand says + * whether the parent was ever published. The framework answers `/layers` with + * a 404 for a parent that exists only as a draft, and keeps that answer by + * design (objectstack-ai/objectstack#22397, answer (a)). + * + * The REAL editor over a REAL `MetadataClient`, whose transport is an + * in-memory server answering as the framework does. Requests are counted on + * it. + * + * Pinned: + * - opening an item of a draft-only parent sends no request at all, so it + * cannot show an error: the read belongs to the save, not to the open; + * - CONTROL: a published parent's save reads its layers once and splices the + * item into the effective body it got; + * - CONTROL: a failed `/layers` read (a 5xx) is the save's error, and nothing + * is PUT. + * + * Not covered, said here so it does not read as covered: what a SAVE does for + * a draft-only parent. `MetadataClient.layered()` resolves the 404 as an + * envelope with every layer null, so the read itself raises nothing; the save + * then splices the item into an empty body and PUTs the parent in publish + * mode. That PUT is outside objectui#11799 (its claim excludes it), and it is + * not pinned here, so this file does not hold that body in place. + */ + +import '@testing-library/jest-dom/vitest'; +import * as React from 'react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { MetadataClient } from '@object-ui/data-objectstack'; + +const FIELD_SCHEMA = { + type: 'object', + properties: { + label: { type: 'string', title: 'Label' }, + description: { type: 'string', title: 'Description' }, + }, +} satisfies Record; + +const server = { + active: new Map>(), + drafts: new Map>(), + requests: [] as Array<{ method: string; path: string; search: string; status: number }>, + puts: [] as Array<{ search: string; body: Record }>, + /** Rows whose `/layers` read fails with a 5xx. */ + failLayers: new Set(), +}; + +const json = (status: number, body: unknown) => + new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } }); + +async function serve(input: RequestInfo | URL, init?: RequestInit): Promise { + const method = (init?.method ?? 'GET').toUpperCase(); + const url = new URL(String(input), 'http://console.test'); + const q = url.searchParams; + let status = 200; + let body: unknown = []; + const meta = url.pathname.match(/^\/api\/v1\/meta\/(.+)$/); + if (meta) { + const seg = meta[1].split('/').map(decodeURIComponent); + const key = `${seg[0]}/${seg[1]}`; + if (seg[0] === '_drafts') { + body = { drafts: [...server.drafts.keys()].map((k) => ({ type: k.split('/')[0], name: k.split('/')[1], packageId: null })) }; + } else if (seg.length === 3 && seg[2] === 'layers' && server.failLayers.has(key)) { + [status, body] = [500, { error: { code: 'INTERNAL_ERROR', message: 'metadata store unavailable' } }]; + } else if (seg.length === 3 && seg[2] === 'layers') { + const row = server.active.get(key); + if (row) body = { code: null, overlay: row, overlayScope: 'env', effective: row }; + else [status, body] = [404, { error: { code: 'NOT_FOUND', message: 'absent' } }]; + } else if (seg.length === 2 && method === 'PUT') { + const item = JSON.parse(String(init?.body ?? '{}')) as Record; + server.puts.push({ search: url.search, body: item }); + if (q.get('mode') === 'draft') server.drafts.set(key, item); + else server.active.set(key, item); + body = { type: seg[0], name: seg[1], state: q.get('mode') === 'draft' ? 'draft' : 'active' }; + } else if (seg.length === 2) { + const draftRead = q.get('state') === 'draft'; + const row = (draftRead ? server.drafts : server.active).get(key); + if (row) body = { type: seg[0], name: seg[1], item: draftRead ? { ...row, _draft: true } : row }; + else [status, body] = [404, { error: { code: draftRead ? 'NO_DRAFT' : 'NOT_FOUND', message: 'absent' } }]; + } + } + server.requests.push({ method, path: url.pathname, search: url.search, status }); + return json(status, body); +} + +const client = new MetadataClient({ baseUrl: '', fetch: vi.fn(serve) as unknown as typeof fetch }); + +vi.mock('./useMetadata', () => ({ + useMetadataClient: () => client, + useMetadataTypes: () => ({ + loading: false, + error: null, + entries: [{ type: 'field', label: 'Field', allowOrgOverride: true, schema: FIELD_SCHEMA }], + }), +})); + +import { EmbeddedItemEditor } from './EmbeddedItemEditor'; + +const ORDER = { + name: 'sales_order', + label: 'Sales Order', + fields: { amount: { type: 'number', label: 'Amount' }, region: { type: 'text', label: 'Region' } }, +}; + +beforeEach(() => { + server.active.clear(); + server.drafts.clear(); + server.requests = []; + server.puts = []; + server.failLayers.clear(); +}); +afterEach(cleanup); + +async function settle() { + await act(async () => { + for (let i = 0; i < 20; i++) await new Promise((r) => setTimeout(r, 0)); + }); +} + +function openAmountField() { + render( + , + ); +} + +const saveButton = () => screen.getByRole('button', { name: /save into object/i }); +const layersStatuses = () => + server.requests.filter((r) => r.method === 'GET' && r.path === '/api/v1/meta/object/sales_order/layers').map((r) => r.status); + +describe('EmbeddedItemEditor — the parent\'s /layers read belongs to the save (objectui#11799)', () => { + it('opening an item of a draft-only parent sends no request, and shows no error', async () => { + server.drafts.set('object/sales_order', ORDER); + openAmountField(); + await settle(); + + expect(screen.getByRole('textbox', { name: 'Label' })).toHaveValue('Amount'); + expect(server.requests).toEqual([]); + expect(screen.queryByText(/metadata store unavailable|Validation failed|absent/)).toBeNull(); + }); + + it('CONTROL: a published parent\'s save reads its layers once and splices the item into the body it got', async () => { + server.active.set('object/sales_order', ORDER); + openAmountField(); + fireEvent.change(screen.getByRole('textbox', { name: 'Label' }), { target: { value: 'Order amount' } }); + fireEvent.click(saveButton()); + + expect(await screen.findByText('Saved.')).toBeInTheDocument(); + expect(layersStatuses()).toEqual([200]); + expect(server.puts).toHaveLength(1); + expect(server.puts[0].body).toEqual({ + ...ORDER, + fields: { ...ORDER.fields, amount: { type: 'number', label: 'Order amount' } }, + }); + }); + + it('CONTROL: a failed /layers read (a 5xx) is the save\'s error, and nothing is PUT', async () => { + server.active.set('object/sales_order', ORDER); + server.failLayers.add('object/sales_order'); + openAmountField(); + fireEvent.click(saveButton()); + + expect(await screen.findByText('metadata store unavailable')).toBeInTheDocument(); + await waitFor(() => expect(saveButton()).not.toBeDisabled()); + expect(layersStatuses()).toEqual([500]); + expect(server.puts).toEqual([]); + expect(screen.queryByText('Saved.')).toBeNull(); + }); +}); diff --git a/packages/app-shell/src/views/metadata-admin/PermissionMatrixEditor.layersUnsaved-11799.test.tsx b/packages/app-shell/src/views/metadata-admin/PermissionMatrixEditor.layersUnsaved-11799.test.tsx index d5ea53fd2a..c3de3ec851 100644 --- a/packages/app-shell/src/views/metadata-admin/PermissionMatrixEditor.layersUnsaved-11799.test.tsx +++ b/packages/app-shell/src/views/metadata-admin/PermissionMatrixEditor.layersUnsaved-11799.test.tsx @@ -25,17 +25,23 @@ * - CONTROL: the published set list is read once, by the editor's own read * of it; the load sends no second one. * - * Not covered, said here so it does not read as covered: the package door's - * SAVE still re-reads `/layers` first, and gets the 404 for such a set. That - * read decides what other packages' rows a save must keep, and the draft in - * hand does not say the set was never published; the list read at open is not - * re-asked there. + * The package door's SAVE still re-reads `/layers` first, and gets the 404 for + * such a set. That read decides what other packages' rows a save must keep + * (objectui#9420: only a fresh read keeps that promise), and the draft in hand + * does not say the set was never published; the list read at open is not + * re-asked there. The framework keeps that 404 by design + * (objectstack-ai/objectstack#22397, answer (a)), and `MetadataClient.layered()` + * resolves it as an envelope with every layer null, so the save reads it as + * "never published": the draft is saved over its own slice, with no refusal + * and no error. Pinned in the second describe below, through the real client, + * with two controls: a published set's re-read keeps the other packages' rows, + * and a failed re-read (a 5xx) still refuses the save. */ import '@testing-library/jest-dom/vitest'; import * as React from 'react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { act, cleanup, render, screen } from '@testing-library/react'; +import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; import { MemoryRouter } from 'react-router-dom'; import { MetadataClient } from '@object-ui/data-objectstack'; @@ -45,6 +51,10 @@ const server = { active: new Map>(), drafts: new Map>(), requests: [] as Array<{ method: string; path: string; search: string; status: number }>, + /** Bodies the editor PUT, with their query strings. */ + puts: [] as Array<{ search: string; body: Record }>, + /** Rows whose `/layers` read fails with a 5xx. */ + failLayers: new Set(), }; const json = (status: number, body: unknown) => @@ -66,10 +76,18 @@ async function serve(input: RequestInfo | URL, init?: RequestInit): Promise k.startsWith(`${seg[0]}/`)).map(([, v]) => v) }; + } else if (seg.length === 3 && seg[2] === 'layers' && server.failLayers.has(key)) { + [status, body] = [500, { error: { code: 'INTERNAL_ERROR', message: 'metadata store unavailable' } }]; } else if (seg.length === 3 && seg[2] === 'layers') { const row = server.active.get(key); if (row) body = { code: null, overlay: row, overlayScope: 'env', effective: row }; else [status, body] = [404, { error: { code: 'NOT_FOUND', message: 'absent' } }]; + } else if (seg.length === 2 && method === 'PUT') { + const item = JSON.parse(String(init?.body ?? '{}')) as Record; + server.puts.push({ search: url.search, body: item }); + if (q.get('mode') === 'draft') server.drafts.set(key, item); + else server.active.set(key, item); + body = { type: seg[0], name: seg[1], state: q.get('mode') === 'draft' ? 'draft' : 'active' }; } else if (seg.length === 2) { const draftRead = q.get('state') === 'draft'; const row = (draftRead ? server.drafts : server.active).get(key); @@ -123,6 +141,8 @@ beforeEach(() => { server.active.clear(); server.drafts.clear(); server.requests = []; + server.puts = []; + server.failLayers.clear(); }); afterEach(cleanup); @@ -158,3 +178,63 @@ describe('PermissionMatrixEditPage — a never-published set is not asked for /l expect(granted('Create')).toHaveAttribute('aria-checked', 'true'); }); }); + +const layersStatuses = () => + server.requests.filter((r) => r.method === 'GET' && r.path === '/api/v1/meta/permission/sales_perms/layers').map((r) => r.status); + +/** Distinctive slice of the package door's refusal when its re-read fails (`perm.save.rereadFailed`). */ +const REFUSAL = /could not be re-read/; + +/** + * The package door has no Save button: an edit autosaves to the package draft + * after the shared autosave's pause (objectui#11787), so a wait on a save + * allows for that pause. + */ +const AUTOSAVE = { timeout: 4000 }; + +describe('PermissionMatrixEditPage — the package door\'s SAVE reads a /layers 404 as "never published" (objectui#11799)', () => { + it('a set "+ New" created: the save\'s one /layers answers 404, and the draft is saved over its own slice, with no refusal', async () => { + server.drafts.set('permission/sales_perms', set('Sales (draft)', true)); + await openSet(); + expect(layersStatuses()).toEqual([]); + + fireEvent.click(granted('Create')); + await waitFor(() => expect(server.puts).toHaveLength(1), AUTOSAVE); + + expect(layersStatuses()).toEqual([404]); + expect(server.puts[0].search).toBe(`?mode=draft&package=${PKG}`); + expect(server.puts[0].body.objects).toEqual({ a_account: { allowRead: true, allowCreate: true } }); + expect(screen.queryByText(REFUSAL)).toBeNull(); + }); + + it('CONTROL: a published set\'s save re-reads its layers and keeps the rows another package contributed', async () => { + server.active.set('permission/sales_perms', { + ...set('Sales', true), + objects: { a_account: { allowRead: true, allowCreate: false }, b_order: { allowRead: true } }, + }); + await openSet(); + expect(layersStatuses()).toEqual([200]); + + fireEvent.click(granted('Create')); + await waitFor(() => expect(server.puts).toHaveLength(1), AUTOSAVE); + + expect(layersStatuses()).toEqual([200, 200]); + expect(server.puts[0].body.objects).toEqual({ + a_account: { allowRead: true, allowCreate: true }, + b_order: { allowRead: true }, + }); + expect(screen.queryByText(REFUSAL)).toBeNull(); + }); + + it('CONTROL: a failed re-read (a 5xx) still refuses the save, for a "+ New" set too', async () => { + server.drafts.set('permission/sales_perms', set('Sales (draft)', true)); + server.failLayers.add('permission/sales_perms'); + await openSet(); + + fireEvent.click(granted('Create')); + expect(await screen.findByText(REFUSAL, undefined, AUTOSAVE)).toBeInTheDocument(); + + expect(layersStatuses()).toEqual([500]); + expect(server.puts).toEqual([]); + }); +}); diff --git a/packages/app-shell/src/views/metadata-admin/ResourceEditPage.layersUnsaved-11799.test.tsx b/packages/app-shell/src/views/metadata-admin/ResourceEditPage.layersUnsaved-11799.test.tsx index 7768188d5c..f00b021cec 100644 --- a/packages/app-shell/src/views/metadata-admin/ResourceEditPage.layersUnsaved-11799.test.tsx +++ b/packages/app-shell/src/views/metadata-admin/ResourceEditPage.layersUnsaved-11799.test.tsx @@ -18,11 +18,16 @@ * for `?state=draft` with no draft. Requests are counted on it. The `doc` * type's editor stands in for every type (the flow is the page's own). * - * Not covered, said here so it does not read as covered: the page's LOAD of a - * never-published item still asks `/layers` and gets its 404. Its answer feeds - * the lock banner, the overlay diff and the reset verdict even with a draft, - * and nothing the page holds before that read says the item was never saved - * (the draft read only says a draft is pending). + * The page's LOAD of a never-published item still asks `/layers` and gets its + * 404: nothing the page holds before that read says the item was never saved + * (the draft read, sent beside it, only says a draft is pending). The framework + * keeps that 404 for every caller by design (objectstack-ai/objectstack#22397, + * answer (a)), so the page reads it as "never published": `MetadataClient.layered()` + * resolves the 404 as an envelope with every layer null, and the lock banner, + * the Layers sheet and the reset verdict show their no-published-version form, + * with no error. Pinned in the second describe below, with two controls: a + * published item still gets its layers, and a failed `/layers` read (a 5xx) + * still shows the load error. */ import '@testing-library/jest-dom/vitest'; @@ -42,6 +47,10 @@ const server = { active: new Map>(), drafts: new Map>(), requests: [] as Array<{ method: string; path: string; search: string; status: number }>, + /** Protection fields a stored row's `/layers` answer carries beyond the defaults. */ + layerExtras: new Map>(), + /** Rows whose `/layers` read fails with a 5xx. */ + failLayers: new Set(), }; const json = (status: number, body: unknown) => @@ -61,9 +70,11 @@ async function serve(input: RequestInfo | URL, init?: RequestInit): Promise ({ type: k.split('/')[0], name: k.split('/')[1], packageId: null })) }; } else if (seg.length === 1) { body = { items: seg[0] === 'book' ? [BOOK] : [] }; + } else if (seg.length === 3 && seg[2] === 'layers' && server.failLayers.has(key)) { + [status, body] = [500, { error: { code: 'INTERNAL_ERROR', message: 'metadata store unavailable' } }]; } else if (seg.length === 3 && seg[2] === 'layers') { const row = server.active.get(key); - if (row) body = { code: null, overlay: row, overlayScope: 'env', effective: row, editable: true, deletable: true, resettable: false, lock: 'none' }; + if (row) body = { code: null, overlay: row, overlayScope: 'env', effective: row, editable: true, deletable: true, resettable: false, lock: 'none', ...server.layerExtras.get(key) }; else [status, body] = [404, { error: { code: 'NOT_FOUND', message: 'absent' } }]; } else if (seg.length === 3 && seg[2] === 'references') { body = []; @@ -128,6 +139,8 @@ beforeEach(() => { server.active.clear(); server.drafts.clear(); server.requests = []; + server.layerExtras.clear(); + server.failLayers.clear(); window.localStorage.setItem('metadata-admin:autosave', '0'); Object.defineProperty(window, 'confirm', { configurable: true, writable: true, value: vi.fn(() => true) }); }); @@ -266,3 +279,67 @@ describe('MetadataResourceEditPage — no /layers re-read for an item known to h expect(await source()).toHaveValue('# Live'); }); }); + +/** The item's `/layers` reads, in order, as the server answered them. */ +const layersStatuses = (name: string) => + server.requests.filter((r) => r.method === 'GET' && r.path === `/api/v1/meta/doc/${name}/layers`).map((r) => r.status); + +/** Opens the Layers sheet and returns the Overlay tab, whose badge names the overlay layer. */ +async function overlayTab() { + fireEvent.click(screen.getByTitle('Layers')); + return screen.findByRole('tab', { name: /Overlay/ }); +} + +describe('MetadataResourceEditPage — the LOAD reads a /layers 404 as "never published", with no error (objectui#11799)', () => { + it('a draft-only item opens on its draft: one /layers, its 404, and no error, lock banner or reset/delete control', async () => { + server.drafts.set('doc/fresh_guide', doc('fresh_guide', '# Draft')); + renderEdit('fresh_guide'); + expect(await source()).toHaveValue('# Draft'); + await settle(); + + expect(layersStatuses('fresh_guide')).toEqual([404]); + expect(screen.queryByText(/Failed to load/)).toBeNull(); + // The draft is pending, and nothing says it is published, locked or resettable. + expect(screen.getByText(/Pending changes/)).toBeInTheDocument(); + expect(screen.queryByTestId('lock-banner-title')).toBeNull(); + expect(screen.queryByTestId('reset-or-delete-button')).toBeNull(); + // The Layers sheet: no overlay layer, and no code layer to diff against. + expect(await overlayTab()).toHaveTextContent('none'); + expect(screen.getByText(/No code-level artifact to compare against/)).toBeInTheDocument(); + }); + + it('CONTROL: a published item gets its layers, and the page uses them: the delete control and the overlay layer', async () => { + server.active.set('doc/live_guide', doc('live_guide', '# Live')); + renderEdit('live_guide'); + expect(await source()).toHaveValue('# Live'); + await settle(); + + expect(layersStatuses('live_guide')).toEqual([200]); + expect(screen.queryByText(/Failed to load/)).toBeNull(); + expect(screen.getByTestId('reset-or-delete-button')).toBeInTheDocument(); + expect(await overlayTab()).toHaveTextContent('env'); + }); + + it('CONTROL: a published item locked by its layers shows the lock banner from that answer', async () => { + server.active.set('doc/live_guide', doc('live_guide', '# Live')); + server.layerExtras.set('doc/live_guide', { lock: 'no-delete', deletable: false, lockReason: 'Shipped with the manual' }); + renderEdit('live_guide'); + expect(await source()).toHaveValue('# Live'); + await settle(); + + expect(layersStatuses('live_guide')).toEqual([200]); + expect(screen.getByTestId('lock-banner-title')).toHaveTextContent('This item is locked and cannot be deleted.'); + expect(screen.getByText('Shipped with the manual')).toBeInTheDocument(); + expect(screen.queryByTestId('reset-or-delete-button')).toBeNull(); + }); + + it('CONTROL: a failed /layers read (a 5xx) still shows the load error, even with a draft in hand', async () => { + server.drafts.set('doc/fresh_guide', doc('fresh_guide', '# Draft')); + server.failLayers.add('doc/fresh_guide'); + renderEdit('fresh_guide'); + + expect(await screen.findByText('Failed to load doc/fresh_guide: metadata store unavailable', undefined, { timeout: 8000 })).toBeInTheDocument(); + expect(layersStatuses('fresh_guide')).toEqual([500]); + expect(screen.queryByText(/Pending changes/)).toBeNull(); + }); +}); diff --git a/packages/app-shell/src/views/studio-design/ObjectListViewInspector.layersUnsaved-11799.test.tsx b/packages/app-shell/src/views/studio-design/ObjectListViewInspector.layersUnsaved-11799.test.tsx index 1da98b1d63..5a5e4bedb3 100644 --- a/packages/app-shell/src/views/studio-design/ObjectListViewInspector.layersUnsaved-11799.test.tsx +++ b/packages/app-shell/src/views/studio-design/ObjectListViewInspector.layersUnsaved-11799.test.tsx @@ -21,13 +21,17 @@ * - CONTROL: a published view with no draft sends exactly one `/layers`, and * the buffer holds its published layer, as before. * - * Not covered, said here so it does not read as covered: a view that exists - * nowhere yet (no draft, no layer: the default list view of an object that has - * none) still asks `/layers` and gets its 404. Nothing the hook holds says the - * view was never saved: its draft read says only whether a draft is pending, - * and the object's `listViews` from the metadata cache is not read as that - * answer here (the 11823 pillar suite serves a default view its object - * definition does not list). + * A view that exists nowhere yet (no draft, no layer: the default list view of + * an object that has none) still asks `/layers` and gets its 404. Nothing the + * hook holds says the view was never saved: its draft read says only whether a + * draft is pending, and the object's `listViews` from the metadata cache is not + * read as that answer here (the 11823 pillar suite serves a default view its + * object definition does not list). The framework keeps that 404 by design + * (objectstack-ai/objectstack#22397, answer (a)), and `MetadataClient.layered()` + * resolves it as an envelope with every layer null, so the buffer reads it as + * "not created yet": no row, no failure, and the canvas shows the view the first + * edit will create. Pinned in the second describe below, with a control: a + * failed `/layers` read (a 5xx) is still the buffer's load failure. */ import '@testing-library/jest-dom/vitest'; @@ -64,6 +68,8 @@ const server = { active: new Map>(), drafts: new Map>(), requests: [] as Array<{ method: string; path: string; search: string; status: number }>, + /** Rows whose `/layers` read fails with a 5xx. */ + failLayers: new Set(), }; const json = (status: number, body: unknown) => @@ -80,6 +86,8 @@ async function serve(input: RequestInfo | URL, init?: RequestInit): Promise ({ type: k.split('/')[0], name: k.split('/')[1], packageId: PKG })) }; + } else if (seg.length === 3 && seg[2] === 'layers' && server.failLayers.has(`${seg[0]}/${seg[1]}`)) { + [status, body] = [500, { error: { code: 'INTERNAL_ERROR', message: 'metadata store unavailable' } }]; } else if (seg.length === 3 && seg[2] === 'layers') { const row = server.active.get(`${seg[0]}/${seg[1]}`); if (row) body = { code: null, overlay: row, overlayScope: 'env', effective: row }; @@ -149,6 +157,7 @@ beforeEach(() => { server.active.clear(); server.drafts.clear(); server.requests = []; + server.failLayers.clear(); }); afterEach(cleanup); @@ -176,3 +185,61 @@ describe('useObjectListViewDraft — an unpublished view is not asked for /layer expect(probe()).toEqual({ loadedFor: `listView:${VIEW}`, row: viewItem(['title']), hasDraft: false, failure: null }); }); }); + +/** What the buffer holds, plus the columns the canvas shows for it. */ +function CanvasProbe() { + const d = useObjectListViewDraft({ client, packageId: PKG, leaf: LEAF, publishNonce: 0 }); + const view = d.canvas?.view as { columns?: unknown } | undefined; + return ( +
+      {JSON.stringify({
+        loadedFor: d.loadedFor,
+        row: d.row,
+        hasDraft: d.hasDraft,
+        failure: d.failure ? { during: d.failure.during, error: String(d.failure.error) } : null,
+        canvasColumns: view?.columns ?? null,
+      })}
+    
+ ); +} + +async function openLeafWithCanvas() { + render( + + + , + ); + await act(async () => { + for (let i = 0; i < 20; i++) await new Promise((r) => setTimeout(r, 0)); + }); + return JSON.parse(screen.getByTestId('canvas-probe').textContent ?? '{}') as Record; +} + +const layersStatuses = () => + server.requests.filter((r) => r.method === 'GET' && r.path === `/api/v1/meta/view/${VIEW}/layers`).map((r) => r.status); + +describe('useObjectListViewDraft — a view that exists nowhere reads its /layers 404 as "not created yet" (objectui#11799)', () => { + it('no draft and no layer: one /layers, its 404, no failure, and the canvas shows the view the first edit creates', async () => { + const seen = await openLeafWithCanvas(); + + expect(layersStatuses()).toEqual([404]); + expect(seen).toEqual({ + loadedFor: `listView:${VIEW}`, + row: null, + hasDraft: false, + failure: null, + // `defaultListColumnsFromObject` over the object's two fields. + canvasColumns: ['title', 'status'], + }); + }); + + it('CONTROL: a failed /layers read (a 5xx) is still the buffer\'s load failure', async () => { + server.failLayers.add(`view/${VIEW}`); + const seen = await openLeafWithCanvas(); + + expect(layersStatuses()).toEqual([500]); + expect(seen.loadedFor).toBe(''); + expect(seen.canvasColumns).toBeNull(); + expect(seen.failure).toEqual({ during: 'load', error: expect.stringContaining('metadata store unavailable') }); + }); +});