From b9149f1122b63b2cf96687a10a34e267c8fcbf67 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 24 Aug 2026 17:28:59 +0000 Subject: [PATCH 1/3] feat(app-shell,data-objectstack): the designer states its package on the publish step Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01CSoz9uGhaaSgiq3hshtN7L --- ...rceEditPage.publishPackageBinding.test.tsx | 201 ++++++++++++++++++ .../views/metadata-admin/ResourceEditPage.tsx | 63 +++++- ...adata-client.publishPackageBinding.test.ts | 132 ++++++++++++ .../data-objectstack/src/metadata-client.ts | 18 +- 4 files changed, 403 insertions(+), 11 deletions(-) create mode 100644 packages/app-shell/src/views/metadata-admin/ResourceEditPage.publishPackageBinding.test.tsx create mode 100644 packages/data-objectstack/src/metadata-client.publishPackageBinding.test.ts diff --git a/packages/app-shell/src/views/metadata-admin/ResourceEditPage.publishPackageBinding.test.tsx b/packages/app-shell/src/views/metadata-admin/ResourceEditPage.publishPackageBinding.test.tsx new file mode 100644 index 0000000000..bb38b40fa7 --- /dev/null +++ b/packages/app-shell/src/views/metadata-admin/ResourceEditPage.publishPackageBinding.test.tsx @@ -0,0 +1,201 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The designer's save->publish loop states ONE package, in BOTH steps — + * objectui#5420, the consumer half of objectstack#10354. + * + * ## The loop this pins + * + * `MetadataResourceEditPage` is the designer whose Save writes a draft + * (`PUT ?mode=draft&package=`) and whose Publish seals it + * (`POST .../publish`). Before this card the second call named no package at + * all, so #9612's package-closure narrowing at the runtime publish gate could + * never fire on an HTTP-driven promotion. Now both steps read the binding from + * `readActivePackageBinding()` — one derivation, so the two calls of one loop + * cannot drift apart. + * + * ## The acceptance criterion, restated + * + * "The designer states the binding it already knows, so the narrowing is + * REACHABLE." Explicitly NOT "publishing got faster": narrowing has a second, + * independent gate this does not touch (`narrowObjectsToPackageClosure` keeps + * every object carrying no `_packageId` provenance, unconditionally, and a + * tenant-authored overlay corpus carries none), so on such a corpus stating the + * package narrows nothing. Nothing here asserts a latency claim. + * + * ## Which assertions survive a revert, and why the pair is needed + * + * The BOUND case fails on a revert — reverted, `doPublish` calls + * `client.publish(type, name)` with no third argument at all, so both the + * "options is an object" and the "packageId equals the save's value" pins go + * red. + * + * The UNBOUND case's key-absence pin (`not.toHaveProperty('packageId')`) would + * ALSO pass on a revert — absence is exactly what the old door did, and no + * absence assertion can distinguish those two worlds by itself. It is not + * aimed at the revert: it is the counter-probe for the other failure mode, the + * one a lone "publish now sends the package" test is trivially satisfiable by, + * namely always sending it. Its revert-sensitive companion sits beside it in + * the same case: `expect(options).toBeTypeOf('object')` is red on a revert + * (undefined) and green on both correct and always-send, so the two together + * separate all three worlds. Both directions run in the same file, as the card + * requires. + */ + +import '@testing-library/jest-dom/vitest'; +import * as React from 'react'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, fireEvent, cleanup, waitFor } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; + +const PAGE = { + name: 'home', + label: 'Home', + type: 'home', + template: 'default', + regions: [{ name: 'main', components: [{ type: 'text', id: 'b1' }] }], +}; + +const mockClient = { + list: vi.fn(async () => []), + listDrafts: vi.fn(async () => []), + layered: vi.fn(async () => ({ effective: PAGE, code: PAGE, editable: true })), + // A pending draft is what makes the Publish button exist at all. + getDraft: vi.fn(async () => ({ item: PAGE })), + get: vi.fn(async () => null), + save: vi.fn(async () => ({})), + publish: vi.fn(async () => ({ success: true, version: 4 })), + reset: vi.fn(async () => ({})), + references: vi.fn(async () => []), +}; + +vi.mock('./useMetadata', async (importOriginal) => { + const mod = await importOriginal(); + return { + ...mod, + useMetadataClient: () => mockClient, + useMetadataTypes: () => ({ + entries: [{ type: 'page', name: 'page', label: 'Page', allowOrgOverride: true }], + }), + }; +}); + +import { MetadataResourceEditPage } from './ResourceEditPage'; +import { registerMetadataPreview, getMetadataPreview } from './preview-registry'; + +/** + * Canvas stand-in. Only job: hand the test a way to dirty the draft, which is + * what arms the real save door. Turning a canvas gesture into a patch is + * `PageBlockCanvas`'s own concern and is tested there. + */ +function StubPageCanvas({ onPatch }: { onPatch?: (patch: Record) => void }) { + return ( + + ); +} + +const realPagePreview = getMetadataPreview('page'); + +/** Put the package scope on the real URL — the same place the loop reads it. */ +function atPackageScope(search: string) { + window.history.replaceState(null, '', `/metadata/page/home${search}`); + return `/metadata/page/home${search}`; +} + +beforeEach(() => { + for (const fn of Object.values(mockClient)) (fn as { mockClear: () => void }).mockClear(); + registerMetadataPreview('page', StubPageCanvas as never); +}); + +afterEach(() => { + cleanup(); + if (realPagePreview) registerMetadataPreview('page', realPagePreview); + window.history.replaceState(null, '', '/'); +}); + +function renderAt(entry: string) { + render( + + + , + ); +} + +/** + * Two doors call the SAME `doPublish` — the toolbar button and the + * "pending changes" banner button. Both are asserted present so a future + * refactor cannot quietly leave one of them on a second publish path. + */ +function publishButtons() { + const all = screen.getAllByRole('button', { name: /^Publish$/ }); + expect(all.length).toBe(2); + return all; +} +const publishButton = () => publishButtons()[0]!; + +/** Dirty the draft and let the real save door fire (autosave, 1500 ms). */ +async function saveOnce() { + fireEvent.click(await screen.findByRole('button', { name: 'patch the draft' })); + await waitFor(() => expect(mockClient.save).toHaveBeenCalled(), { timeout: 8000 }); +} + +describe('MetadataResourceEditPage — save and publish state ONE package (#5420)', () => { + it('bound: publish states the SAME id the save states, from the same source', async () => { + renderAt(atPackageScope('?package=com.example.showcase')); + await waitFor(() => expect(publishButton()).toBeInTheDocument(), { timeout: 8000 }); + + await saveOnce(); + const saveOpts = mockClient.save.mock.calls[0]![3] as Record; + expect(saveOpts).toMatchObject({ mode: 'draft', packageId: 'com.example.showcase' }); + + await waitFor(() => expect(publishButton()).toBeEnabled(), { timeout: 8000 }); + fireEvent.click(publishButton()); + await waitFor(() => expect(mockClient.publish).toHaveBeenCalled(), { timeout: 8000 }); + + const [type, name, publishOpts] = mockClient.publish.mock.calls[0] as unknown as [ + string, + string, + Record, + ]; + expect([type, name]).toEqual(['page', 'home']); + // One value, one spelling: byte-identical to what the save stated. + expect(publishOpts).toEqual({ packageId: 'com.example.showcase' }); + expect(publishOpts.packageId).toBe(saveOpts.packageId); + }); + + it('unbound: no package on the URL — the key is ABSENT on the publish, not empty', async () => { + renderAt(atPackageScope('')); + await waitFor(() => expect(publishButton()).toBeInTheDocument(), { timeout: 8000 }); + await waitFor(() => expect(publishButton()).toBeEnabled(), { timeout: 8000 }); + + fireEvent.click(publishButton()); + await waitFor(() => expect(mockClient.publish).toHaveBeenCalled(), { timeout: 8000 }); + + const publishOpts = mockClient.publish.mock.calls[0]![2] as Record | undefined; + // Revert-sensitive half: reverted, there is no third argument at all. + expect(publishOpts).toBeTypeOf('object'); + // Always-send-sensitive half: the key must be absent, never `''`. + expect(publishOpts).not.toHaveProperty('packageId'); + expect(Object.keys(publishOpts!)).toEqual([]); + }); + + it("unbound: `?package=all` is the show-everything scope, not a package id", async () => { + renderAt(atPackageScope('?package=all')); + await waitFor(() => expect(publishButton()).toBeInTheDocument(), { timeout: 8000 }); + + // The save folds `all` away too — pin both halves of the fold in one run so + // the two calls cannot disagree about what `all` means. + await saveOnce(); + expect(mockClient.save.mock.calls[0]![3]).not.toHaveProperty('packageId'); + + await waitFor(() => expect(publishButton()).toBeEnabled(), { timeout: 8000 }); + fireEvent.click(publishButton()); + await waitFor(() => expect(mockClient.publish).toHaveBeenCalled(), { timeout: 8000 }); + + const publishOpts = mockClient.publish.mock.calls[0]![2] as Record | undefined; + expect(publishOpts).toBeTypeOf('object'); + expect(publishOpts).not.toHaveProperty('packageId'); + }); +}); diff --git a/packages/app-shell/src/views/metadata-admin/ResourceEditPage.tsx b/packages/app-shell/src/views/metadata-admin/ResourceEditPage.tsx index 542e3993e5..1124661597 100644 --- a/packages/app-shell/src/views/metadata-admin/ResourceEditPage.tsx +++ b/packages/app-shell/src/views/metadata-admin/ResourceEditPage.tsx @@ -229,6 +229,49 @@ function extractDraftBody( : null; } +/** + * The software-package binding this editor is authoring under, read from the + * ONE place the save->publish loop states it: `?package=` on the editor URL. + * + * ## Why this is a function and not two inline reads + * + * Both steps of the loop send this value — `doSave` binds the draft row to the + * package (`PUT ?package=`), and since objectstack#10354 `doPublish` states the + * same package on the promotion (`POST .../publish?package=`) so #9612's + * package-closure narrowing at the runtime publish gate is reachable from an + * HTTP-driven promotion at all. One value, one spelling, both steps — which + * means one derivation too. A second inline copy in the publish path would be + * free to drift from the save path (most easily on the `'all'` fold below), + * and the two calls would then disagree about which package the edit belongs + * to while both looking correct in isolation. + * + * ## The `'all'` fold + * + * `?package=all` is the metadata list's "show everything" scope, NOT a package + * literally named `all`; the framework's normaliser folds `all` and the empty + * value together to mean "env-local overlay, no package". Folded here to + * `undefined` so both callers OMIT the parameter rather than sending it empty. + * The two are the same to that normaliser today, so this is not a behaviour + * difference against the current server — omit-when-unbound is simply the + * shape this door already had, and the loop's two calls must not disagree. + * + * Read at call time rather than per render because the editor URL's package + * scope can move under the component (`setSearchParams`), and the value that + * must be stated is the one in force when the request is issued. + * + * Deliberately NOT `ownerPackageId` (the router-read `?package=` used to scope + * layered/draft READS): that one does not fold `'all'`, so reusing it here + * would send `package=all` as if it were a package id. + */ +function readActivePackageBinding(): string | undefined { + try { + const p = new URLSearchParams(window.location.search).get('package'); + return p && p !== 'all' ? p : undefined; + } catch { + return undefined; + } +} + /** * Decide whether the validation-diagnostics banner should render at all. * @@ -1302,14 +1345,7 @@ function MetadataResourceEditPageImpl({ // real package scope is carried in the URL (`?package=`). The backend // stamps it on create and preserves an existing binding on update, so // env-local overlays (no `?package=`) are unaffected. - const activePackage = (() => { - try { - const p = new URLSearchParams(window.location.search).get('package'); - return p && p !== 'all' ? p : undefined; - } catch { - return undefined; - } - })(); + const activePackage = readActivePackageBinding(); await client.save(type, savedName, itemToSave, { force, mode: 'draft', @@ -1461,7 +1497,16 @@ function MetadataResourceEditPageImpl({ setPublishing(true); setError(null); try { - await client.publish(type, name); + // State the SAME package the save step already stated — read from the + // same single source, so the two calls of one loop can never disagree. + // Absent (not empty) when the designer holds no binding: the framework + // branches on the KEY BEING PRESENT downstream, where a present-but-null + // package pins the draft lookup to unbound rows and a packaged draft + // stops being found (`no_draft`) — see objectstack#10354's own warning. + const activePackage = readActivePackageBinding(); + await client.publish(type, name, { + ...(activePackage ? { packageId: activePackage } : {}), + }); const [lay, draftResp] = await Promise.all([ client.layered(type, name), client.getDraft(type, name).catch(() => null), diff --git a/packages/data-objectstack/src/metadata-client.publishPackageBinding.test.ts b/packages/data-objectstack/src/metadata-client.publishPackageBinding.test.ts new file mode 100644 index 0000000000..ea35fbf797 --- /dev/null +++ b/packages/data-objectstack/src/metadata-client.publishPackageBinding.test.ts @@ -0,0 +1,132 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * `publish()` states the package it is promoting — objectui#5420, the consumer + * half of objectstack#10354 (`@objectstack/rest` 17.2.0, whose CHANGELOG reads + * "**Additive:** `POST /meta/:type/:name/publish` now accepts `?package=`"). + * + * ## What is actually pinned here + * + * REQUEST BYTES, both directions, in one run: + * + * - bound -> `?package=`, the same wire spelling and the same + * `encodeURIComponent` treatment `save()` gives the value one door over; + * - unbound -> the parameter is **ABSENT**, not empty. + * + * The second is asserted as absence of the `package` key on a parsed query + * string, NOT as `package === ''`. Those two are the same to the framework's + * normaliser today (`all` and the empty value both mean "env-local overlay, no + * package") and different on the wire, and the wire is what this client owns. + * + * ## The acceptance criterion this suite does and does not encode + * + * It encodes "the binding is STATED, so #9612's package-closure narrowing is + * reachable from an HTTP-driven promotion". It deliberately encodes NO latency + * claim: `narrowObjectsToPackageClosure` keeps any object carrying no + * `_packageId` provenance unconditionally, so on a tenant-authored overlay + * corpus stating the package narrows nothing at all. A test asserting a + * speed-up would be measuring a target this change cannot hit. + * + * ## Which of these would still pass if the change were reverted + * + * Only the absent-direction cases — absence of `?package=` is exactly what the + * pre-change door did, so no assertion about absence can distinguish the two + * states of the world by itself. They are the counter-probe for the OTHER + * failure mode, the one a lone "it now sends `?package=X`" test is trivially + * satisfiable by: always sending it. The bound-direction cases are the ones + * that fail on a revert, and they run in the same file as their counter-probe + * so neither mistake can pass alone. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { MetadataClient } from './metadata-client'; + +function record() { + const seen: { url: string; method?: string }[] = []; + const client = new MetadataClient({ + baseUrl: 'http://localhost:3000', + fetch: vi.fn(async (url: string, init?: RequestInit) => { + seen.push({ url, method: init?.method }); + return new Response(JSON.stringify({ success: true, version: 3 }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + }) as unknown as typeof fetch, + }); + return { seen, client }; +} + +/** The query string of the one request the call made, parsed. */ +function queryOf(url: string): URLSearchParams { + const q = url.indexOf('?'); + return new URLSearchParams(q === -1 ? '' : url.slice(q + 1)); +} + +describe('MetadataClient.publish — package binding on the promotion (#5420)', () => { + it('states ?package= when the caller has a binding', async () => { + const { seen, client } = record(); + await client.publish('page', 'home', { packageId: 'com.example.showcase' }); + + expect(seen).toHaveLength(1); + expect(seen[0]?.method).toBe('POST'); + expect(seen[0]?.url).toBe( + 'http://localhost:3000/api/v1/meta/page/home/publish?package=com.example.showcase', + ); + expect(queryOf(seen[0]!.url).get('package')).toBe('com.example.showcase'); + }); + + it('omits the parameter entirely when there is no binding — absent, not empty', async () => { + const { seen, client } = record(); + await client.publish('page', 'home'); + + expect(seen).toHaveLength(1); + // No query string at all: `.../publish`, never `.../publish?package=`. + expect(seen[0]?.url).toBe('http://localhost:3000/api/v1/meta/page/home/publish'); + expect(queryOf(seen[0]!.url).has('package')).toBe(false); + }); + + it('omits it for an options object that carries no packageId, and for an empty id', async () => { + // The call site spreads the key in conditionally, so `{}` is the shape the + // unbound designer produces. An empty string is the accident this guards. + for (const options of [{}, { packageId: '' }, { message: 'ship it' }] as const) { + const { seen, client } = record(); + await client.publish('page', 'home', options); + expect(queryOf(seen[0]!.url).has('package')).toBe(false); + expect(seen[0]?.url.includes('package=')).toBe(false); + } + }); + + it('percent-encodes the id the same way the save door does', async () => { + const bound = record(); + await bound.client.publish('page', 'home', { packageId: 'com.acme/a b' }); + + const saved = record(); + await saved.client.save('page', 'home', {}, { mode: 'draft', packageId: 'com.acme/a b' }); + + // One value, one spelling: the encoded form on the publish door is + // byte-identical to the encoded form the save door already emits. + const publishPkg = /[?&]package=([^&]*)/.exec(bound.seen[0]!.url)?.[1]; + const savePkg = /[?&]package=([^&]*)/.exec(saved.seen[0]!.url)?.[1]; + expect(publishPkg).toBe('com.acme%2Fa%20b'); + expect(publishPkg).toBe(savePkg); + }); + + it('keeps the `message` body while stating the package', async () => { + const seen: RequestInit[] = []; + const client = new MetadataClient({ + baseUrl: 'http://localhost:3000', + fetch: vi.fn(async (_url: string, init?: RequestInit) => { + seen.push(init!); + return new Response('{}', { status: 200, headers: { 'content-type': 'application/json' } }); + }) as unknown as typeof fetch, + }); + await client.publish('page', 'home', { message: 'ship it', packageId: 'com.example.showcase' }); + expect(JSON.parse(String(seen[0]?.body))).toEqual({ message: 'ship it' }); + }); +}); diff --git a/packages/data-objectstack/src/metadata-client.ts b/packages/data-objectstack/src/metadata-client.ts index 902c2d3f22..2bc096aa17 100644 --- a/packages/data-objectstack/src/metadata-client.ts +++ b/packages/data-objectstack/src/metadata-client.ts @@ -1155,9 +1155,23 @@ export class MetadataClient { async publish( type: string, name: string, - options: { message?: string } = {}, + options: { message?: string; packageId?: string } = {}, ): Promise { - const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}/publish`; + // objectstack#10354 (`@objectstack/rest` 17.2.0) — this door accepts + // `?package=` and forwards it as the promotion's package binding, so + // #9612's package-closure narrowing at the runtime publish gate is + // reachable from an HTTP-driven promotion at all. Deliberately the SAME + // wire spelling and the same conditional as `save()` a few hundred lines + // up: ONE value, ONE spelling, both steps of the save->publish loop. + // + // The parameter is OMITTED, never sent empty, when there is no binding. + // `?package=` with an empty value and no `package` key at all are folded + // together by the framework's normaliser today (`all` and the empty value + // both mean "env-local overlay, no package"), so this is not a behaviour + // difference on the current server — it is the shape the save door already + // follows, and the two calls of one loop must not disagree about it. + const qs = options.packageId ? `?package=${encodeURIComponent(options.packageId)}` : ''; + const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}/publish${qs}`; const headers: Record = { ...this.headers, 'Content-Type': 'application/json', From b208a38b173128540444653d1612025a0515da46 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 24 Aug 2026 17:29:34 +0000 Subject: [PATCH 2/3] chore: changeset for the designer publish package binding Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01CSoz9uGhaaSgiq3hshtN7L --- .../designer-publish-package-binding.md | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 .changeset/designer-publish-package-binding.md diff --git a/.changeset/designer-publish-package-binding.md b/.changeset/designer-publish-package-binding.md new file mode 100644 index 0000000000..4bba3134eb --- /dev/null +++ b/.changeset/designer-publish-package-binding.md @@ -0,0 +1,29 @@ +--- +'@object-ui/data-objectstack': minor +'@object-ui/app-shell': minor +--- + +The metadata designer states its package on the publish step, not only on the save (#5420) + +Studio's designer save→publish loop bound the draft to a software package on the +save (`PUT ?mode=draft&package=`) and then sealed it with a publish that named +no package at all. `objectstack#10354` (shipped in `@objectstack/rest` 17.2.0) taught +`POST /meta/:type/:name/publish` to accept `?package=`, so the second call can now +state the same binding the first one already states. + +- `MetadataClient.publish()` accepts `packageId` and sends `?package=`, the same + wire spelling and the same `encodeURIComponent` treatment `save()` gives it. +- `MetadataResourceEditPage` reads the binding for BOTH steps from one derivation + (`readActivePackageBinding`), so the two calls of one loop cannot drift apart. The + `?package=all` "show everything" scope keeps folding to "no package". + +The parameter is **omitted**, never sent empty, when the designer holds no binding. +Empty and absent are the same to the framework's normaliser today, but absent is the +shape the save door already followed, and the framework's promotion path branches on +the key being present downstream. + +What this buys is **reachability**, not speed: it lets `#9612`'s package-closure +narrowing at the runtime publish gate fire on an HTTP-driven promotion at all. That +narrowing has a second, independent gate this does not touch — objects carrying no +`_packageId` provenance are kept unconditionally — so on a tenant-authored overlay +corpus stating the package still narrows nothing. From c3e51ceb3b7bf692a6b18ec814bef0124049dbbf Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 24 Aug 2026 17:53:12 +0000 Subject: [PATCH 3/3] test(app-shell): type the designer publish mocks so tsc reads mock.calls Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01CSoz9uGhaaSgiq3hshtN7L --- ...rceEditPage.publishPackageBinding.test.tsx | 40 ++++++++++++------- 1 file changed, 26 insertions(+), 14 deletions(-) diff --git a/packages/app-shell/src/views/metadata-admin/ResourceEditPage.publishPackageBinding.test.tsx b/packages/app-shell/src/views/metadata-admin/ResourceEditPage.publishPackageBinding.test.tsx index bb38b40fa7..9ac9aeeb27 100644 --- a/packages/app-shell/src/views/metadata-admin/ResourceEditPage.publishPackageBinding.test.tsx +++ b/packages/app-shell/src/views/metadata-admin/ResourceEditPage.publishPackageBinding.test.tsx @@ -56,15 +56,31 @@ const PAGE = { regions: [{ name: 'main', components: [{ type: 'text', id: 'b1' }] }], }; +/** + * The two option bags this suite reads. Spelled out (rather than letting + * `vi.fn(async () => ...)` infer a zero-argument mock) because the assertions + * index into `mock.calls[0]` — an inferred zero-arg mock types that as `[]`, + * and every index into it is a compile error the vitest run would never show. + */ +type SaveOpts = { force?: boolean; mode?: string; packageId?: string }; +type PublishOpts = { message?: string; packageId?: string }; + const mockClient = { list: vi.fn(async () => []), listDrafts: vi.fn(async () => []), - layered: vi.fn(async () => ({ effective: PAGE, code: PAGE, editable: true })), + layered: vi.fn(async (_type: string, _name: string, _opts?: { packageId?: string }) => ({ + effective: PAGE, + code: PAGE, + editable: true, + })), // A pending draft is what makes the Publish button exist at all. - getDraft: vi.fn(async () => ({ item: PAGE })), + getDraft: vi.fn(async (_type: string, _name: string, _opts?: { packageId?: string }) => ({ item: PAGE })), get: vi.fn(async () => null), - save: vi.fn(async () => ({})), - publish: vi.fn(async () => ({ success: true, version: 4 })), + save: vi.fn(async (_type: string, _name: string, _item: unknown, _opts?: SaveOpts) => ({})), + publish: vi.fn(async (_type: string, _name: string, _opts?: PublishOpts) => ({ + success: true, + version: 4, + })), reset: vi.fn(async () => ({})), references: vi.fn(async () => []), }; @@ -105,7 +121,7 @@ function atPackageScope(search: string) { } beforeEach(() => { - for (const fn of Object.values(mockClient)) (fn as { mockClear: () => void }).mockClear(); + for (const fn of Object.values(mockClient)) (fn as unknown as { mockClear: () => void }).mockClear(); registerMetadataPreview('page', StubPageCanvas as never); }); @@ -147,22 +163,18 @@ describe('MetadataResourceEditPage — save and publish state ONE package (#5420 await waitFor(() => expect(publishButton()).toBeInTheDocument(), { timeout: 8000 }); await saveOnce(); - const saveOpts = mockClient.save.mock.calls[0]![3] as Record; + const saveOpts = mockClient.save.mock.calls[0]![3]; expect(saveOpts).toMatchObject({ mode: 'draft', packageId: 'com.example.showcase' }); await waitFor(() => expect(publishButton()).toBeEnabled(), { timeout: 8000 }); fireEvent.click(publishButton()); await waitFor(() => expect(mockClient.publish).toHaveBeenCalled(), { timeout: 8000 }); - const [type, name, publishOpts] = mockClient.publish.mock.calls[0] as unknown as [ - string, - string, - Record, - ]; + const [type, name, publishOpts] = mockClient.publish.mock.calls[0]!; expect([type, name]).toEqual(['page', 'home']); // One value, one spelling: byte-identical to what the save stated. expect(publishOpts).toEqual({ packageId: 'com.example.showcase' }); - expect(publishOpts.packageId).toBe(saveOpts.packageId); + expect(publishOpts?.packageId).toBe(saveOpts?.packageId); }); it('unbound: no package on the URL — the key is ABSENT on the publish, not empty', async () => { @@ -173,7 +185,7 @@ describe('MetadataResourceEditPage — save and publish state ONE package (#5420 fireEvent.click(publishButton()); await waitFor(() => expect(mockClient.publish).toHaveBeenCalled(), { timeout: 8000 }); - const publishOpts = mockClient.publish.mock.calls[0]![2] as Record | undefined; + const publishOpts = mockClient.publish.mock.calls[0]![2]; // Revert-sensitive half: reverted, there is no third argument at all. expect(publishOpts).toBeTypeOf('object'); // Always-send-sensitive half: the key must be absent, never `''`. @@ -194,7 +206,7 @@ describe('MetadataResourceEditPage — save and publish state ONE package (#5420 fireEvent.click(publishButton()); await waitFor(() => expect(mockClient.publish).toHaveBeenCalled(), { timeout: 8000 }); - const publishOpts = mockClient.publish.mock.calls[0]![2] as Record | undefined; + const publishOpts = mockClient.publish.mock.calls[0]![2]; expect(publishOpts).toBeTypeOf('object'); expect(publishOpts).not.toHaveProperty('packageId'); });