From fc764690e1d74776efffa926c11713dd038052c0 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 11 Aug 2026 23:18:34 +0000 Subject: [PATCH 1/2] test(core,app-shell): type-check the whole test trees, retire both narrow typetests projects (#4040) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `@object-ui/core` and `@object-ui/app-shell` — the last two large TEST_DEBT packages — each gain a `tsconfig.test.json` chained from `type-check`, so their 81 and 350 test files are compiled by something for the first time. Measured at the branch point: core 132 raw / 56 code-tier, app-shell 221 raw / 62 code-tier, against registry entries declaring exactly 56 and 62 — the first tranche where the declared numbers held, because they were the tranche-4 remeasurement rather than the unreliable #2911-era sweep. Config tier: `paths: {}` kills 39 + 121 TS6059 source-leaks; core needs `types: ["node"]` (22 TS2591 + 13 TS2304 for `node:fs` / `__dirname` / `global`); app-shell needs `lib: ES2022` (38 TS2550 for `calls.at(-1)`) plus `vite/client` + `@testing-library/jest-dom`, raised in the TEST project only so the package source keeps compiling against the baseline it ships to. Three source declarations were narrower than their implementation, and only the tests could see it. `ConsoleActionRuntime.actionProviderProps` hand-restated ActionProvider's props and had drifted twice — it omitted `onModal`, which the implementation returns, and typed `handlers` values as one-parameter functions; it is now `Required>`, an explicit key list with derived types. `apiHandler` declares the `context` parameter it has always taken. `AiChatPage` imported the plugin barrel's minimal legacy `ChatMessage` while producing the enhanced shape — the mismatch survived only because the extra keys are spread conditionally, which defeats excess-property checking. Substantive test fixes: `TransactionManager.test.ts` authored its fixtures in the deprecated `crud.ts` `ActionSchema` while the manager takes `UIActionSchema` (17 of core's 56); `schema-builder.test.ts` spelled columns `{ name, label }`, a dialect `TableColumn` does not have, so the assertion round-tripped its own input; eight app-shell preview suites spread `props as never`, a cast that had never been checked; twelve mocks across both packages carried `ReturnType` / zero arity, whose `mock.calls` is the empty tuple. Per #4291's ratchet both narrow `tsconfig.typetests.json` projects retire here: `--listFiles` shows the full projects read all 2 + 5 files they named, and a provably-false `Assert` appended to each parity test turns the full project red. app-shell's orphan `type-check:typetests` script goes with it. TEST_DEBT is down to `@object-ui/plugin-dashboard` alone; the gate now reports 39/40 packages compiling their tests and 0 narrow type-assertion projects. Refs #4040, #4291, #3181, #3009, #3968. --- .../core-app-shell-type-check-their-tests.md | 16 ++++ packages/app-shell/package.json | 3 +- .../app-shell/src/console/ai/AiChatPage.tsx | 12 ++- .../ai/__tests__/deriveBoundPackageId.test.ts | 23 +++-- .../__tests__/FavoritesProvider.test.tsx | 6 +- ...ronmentListToolbar.deepLinkArming.test.tsx | 8 +- ...nmentListToolbar.deepLinkOverflow.test.tsx | 7 +- .../__tests__/useChatConversation.test.tsx | 16 +++- .../src/hooks/useConsoleActionRuntime.tsx | 45 +++++++--- .../ContextSelectors.scopeKey.test.tsx | 7 +- .../src/providers/writeWarningToast.test.ts | 14 ++- .../MetadataService.saveAdvisories.test.ts | 7 +- .../src/utils/decisionOutputParams.test.ts | 7 +- ...lveActionParams.optionVisibleWhen.test.tsx | 16 +++- .../ObjectView.defaultViewIdentity.test.tsx | 10 ++- ...ObjectView.setDefaultViewIdentity.test.tsx | 16 +++- .../src/views/RecordFormPage.i18n.test.tsx | 12 +-- .../__tests__/FlowRunner.visibleWhen.test.tsx | 10 ++- .../MetadataTypeActions.test.tsx | 12 ++- .../SchemaForm.unresolvedPredicate.test.tsx | 7 +- .../inspectors/FlowNodeInspector.test.tsx | 4 +- .../FlowReferenceField.sources.test.tsx | 7 +- ...ObjectFieldInspector.draft-locale.test.tsx | 8 +- .../PageBlockInspector.visibleWhen.test.tsx | 18 +++- .../inspectors/__tests__/nav-target.test.ts | 7 +- .../inspectors/notify-node.dogfood.test.tsx | 4 +- .../views/metadata-admin/predicate.test.ts | 8 +- .../previews/AgentPreview.test.tsx | 2 +- .../previews/AppPreview.test.tsx | 4 +- .../previews/DatasourcePreview.test.tsx | 2 +- .../previews/FlowCanvas.test.tsx | 8 +- .../previews/PagePreview.test.tsx | 15 ++-- .../previews/PermissionPreview.test.tsx | 2 +- .../previews/SkillPreview.test.tsx | 2 +- .../previews/ToolPreview.test.tsx | 2 +- .../previews/ValidationPreview.test.tsx | 3 +- packages/app-shell/tsconfig.test.json | 50 +++++++++++ packages/app-shell/tsconfig.typetests.json | 88 ------------------- packages/core/package.json | 2 +- .../__tests__/ActionEngine.visibility.test.ts | 2 +- .../__tests__/ActionRunner.bodyExtra.test.ts | 8 +- .../ActionRunner.resultDialog.test.ts | 18 +++- .../ActionRunner.scriptAwait.test.ts | 9 +- .../actions/__tests__/ActionRunner.test.ts | 24 +++-- .../ActionRunner.urlParamsRetirement.test.ts | 17 ++-- .../__tests__/TransactionManager.test.ts | 16 +++- .../builder/__tests__/schema-builder.test.ts | 35 ++++++-- .../__tests__/listConditional.test.ts | 12 ++- .../registry/__tests__/PluginSystem.test.ts | 9 ++ .../__tests__/normalize-list-view.test.ts | 4 +- packages/core/tsconfig.test.json | 41 +++++++++ packages/core/tsconfig.typetests.json | 38 -------- scripts/check-type-check-coverage.mjs | 17 ++-- 53 files changed, 484 insertions(+), 256 deletions(-) create mode 100644 .changeset/core-app-shell-type-check-their-tests.md create mode 100644 packages/app-shell/tsconfig.test.json delete mode 100644 packages/app-shell/tsconfig.typetests.json create mode 100644 packages/core/tsconfig.test.json delete mode 100644 packages/core/tsconfig.typetests.json diff --git a/.changeset/core-app-shell-type-check-their-tests.md b/.changeset/core-app-shell-type-check-their-tests.md new file mode 100644 index 0000000000..2f44c0a801 --- /dev/null +++ b/.changeset/core-app-shell-type-check-their-tests.md @@ -0,0 +1,16 @@ +--- +--- + +chore(core,app-shell): `@object-ui/core` and `@object-ui/app-shell` type-check their whole test trees. + +No published behaviour moves. Each package gains a `tsconfig.test.json` chained +from `type-check`, their 56 and 62 code-tier test errors are fixed, and the +narrow `tsconfig.typetests.json` rescue hatches — for packages still in +`TEST_DEBT` — are retired now that the full projects compile the same files +(objectui#4040 tranche 5, under objectui#4291's ratchet). Three small source +corrections ride along, each one a declaration that was narrower than the +implementation it described: `ConsoleActionRuntime.actionProviderProps` is now +derived from `ActionProviderProps` instead of restating it (the restatement had +dropped `onModal` and declared one-parameter handlers), `apiHandler` declares the +`context` parameter it has always taken, and `AiChatPage` imports the enhanced +chat-message type it actually produces rather than the minimal legacy one. diff --git a/packages/app-shell/package.json b/packages/app-shell/package.json index 99d94c4087..5703cce123 100644 --- a/packages/app-shell/package.json +++ b/packages/app-shell/package.json @@ -27,8 +27,7 @@ "scripts": { "build": "tsc", "test": "vitest run", - "type-check": "tsc --noEmit && tsc -p tsconfig.typetests.json", - "type-check:typetests": "tsc -p tsconfig.typetests.json", + "type-check": "tsc --noEmit && tsc -p tsconfig.test.json", "lint": "eslint ." }, "dependencies": { diff --git a/packages/app-shell/src/console/ai/AiChatPage.tsx b/packages/app-shell/src/console/ai/AiChatPage.tsx index 845206ebbf..94af2beaf2 100644 --- a/packages/app-shell/src/console/ai/AiChatPage.tsx +++ b/packages/app-shell/src/console/ai/AiChatPage.tsx @@ -75,7 +75,17 @@ import { buildProgressFromDraftReview, type AgentDescriptor, type ChatbotEnhancedToolInvocation, - type ChatMessage, + // The ENHANCED message shape — the one `` renders and the + // one this file actually produces (`toolInvocations`, `buildProgress`). + // `@object-ui/plugin-chatbot` ALSO exports a minimal legacy `ChatMessage` + // from its own barrel module (id/role/content/timestamp/avatar only), and + // that is what this import used to resolve to. The mismatch compiled because + // every construction site spreads the extra keys conditionally + // (`...(x ? { toolInvocations } : {})`), which defeats excess-property + // checking — so the declared type was narrower than every value flowing + // through it, and `AiChatPage.hydration.test.ts` could not read + // `toolInvocations` off its own function's return (objectui#4040). + type ChatbotEnhancedMessage as ChatMessage, } from '@object-ui/plugin-chatbot'; import { AppHeader } from '../../layout/AppHeader'; diff --git a/packages/app-shell/src/console/ai/__tests__/deriveBoundPackageId.test.ts b/packages/app-shell/src/console/ai/__tests__/deriveBoundPackageId.test.ts index d55bb67984..912a0a8217 100644 --- a/packages/app-shell/src/console/ai/__tests__/deriveBoundPackageId.test.ts +++ b/packages/app-shell/src/console/ai/__tests__/deriveBoundPackageId.test.ts @@ -9,10 +9,20 @@ */ import { describe, it, expect } from 'vitest'; import { deriveBoundPackageId, isPlatformBuiltinApp } from '../AiChatPage'; -import type { ChatMessage } from '@object-ui/plugin-chatbot'; -const msg = (toolInvocations: unknown[]): ChatMessage => - ({ id: 'm', role: 'assistant', content: '', toolInvocations } as unknown as ChatMessage); +/** + * DERIVED from the function under test, not restated: `deriveBoundPackageId` + * declares a structural minimum (messages whose `toolInvocations` may carry + * `draftReview` / `builderHandoff`), never `ChatMessage`. This file used to + * author a `ChatMessage` and cast to it — and the legacy `ChatMessage` the + * chatbot barrel exports has NO properties in common with that minimum, so the + * cast was asserting between unrelated shapes and the fixtures could have + * drifted arbitrarily far from what the function reads (objectui#4040). + */ +type PackageBearingMessage = Parameters[0][number]; +type PackageBearingTool = NonNullable[number]; + +const msg = (toolInvocations: readonly PackageBearingTool[]): PackageBearingMessage => ({ toolInvocations }); describe('deriveBoundPackageId', () => { it('prefers the explicit editPackageId (Edit-with-AI) over anything in messages', () => { @@ -22,14 +32,17 @@ describe('deriveBoundPackageId', () => { it('unbound while nothing has been built → undefined ("New app")', () => { expect(deriveBoundPackageId([], undefined)).toBeUndefined(); - expect(deriveBoundPackageId([msg([{ someOther: true }])], undefined)).toBeUndefined(); + // A tool invocation carrying NEITHER binding key — the case this covers. + // (It used to spell that as `{ someOther: true }`; the derivation reads only + // `draftReview` / `builderHandoff`, so the two are the same path.) + expect(deriveBoundPackageId([msg([{}])], undefined)).toBeUndefined(); }); it('binds to the package a build/draft produced (draftReview or builderHandoff)', () => { expect(deriveBoundPackageId([msg([{ draftReview: { packageId: 'app.inventory' } }])], undefined)).toBe( 'app.inventory', ); - expect(deriveBoundPackageId([msg([{ builderHandoff: { prompt: 'x', packageId: 'app.crm' } }])], undefined)).toBe( + expect(deriveBoundPackageId([msg([{ builderHandoff: { packageId: 'app.crm' } }])], undefined)).toBe( 'app.crm', ); }); diff --git a/packages/app-shell/src/context/__tests__/FavoritesProvider.test.tsx b/packages/app-shell/src/context/__tests__/FavoritesProvider.test.tsx index 668184b5d7..5868deb7dc 100644 --- a/packages/app-shell/src/context/__tests__/FavoritesProvider.test.tsx +++ b/packages/app-shell/src/context/__tests__/FavoritesProvider.test.tsx @@ -164,7 +164,11 @@ describe('FavoritesProvider', () => { { id: 'ok', label: 'OK', href: '/ok', type: 'object', favoritedAt: 't' }, // @ts-expect-error testing runtime sanitization { label: 'no-id' }, - null as any, + // `as unknown as FavoriteItem`, not `as any`: an `any` element collapses + // the whole array literal's element type to `any`, which made the + // `@ts-expect-error` above suppress nothing (TS2578) — the malformed item + // it is documenting was no longer a type error at all (objectui#4040). + null as unknown as FavoriteItem, ]); const { result } = renderHook(() => useTestHarness(), { wrapper }); diff --git a/packages/app-shell/src/environment/__tests__/EnvironmentListToolbar.deepLinkArming.test.tsx b/packages/app-shell/src/environment/__tests__/EnvironmentListToolbar.deepLinkArming.test.tsx index 5010c27b71..b37f2c2868 100644 --- a/packages/app-shell/src/environment/__tests__/EnvironmentListToolbar.deepLinkArming.test.tsx +++ b/packages/app-shell/src/environment/__tests__/EnvironmentListToolbar.deepLinkArming.test.tsx @@ -36,6 +36,7 @@ import { ActionProvider } from '@object-ui/react'; import { I18nProvider } from '@object-ui/i18n'; import { EnvironmentListToolbar } from '../EnvironmentListToolbar'; import type { EnvironmentEntitlementsState } from '../entitlements'; +import type { ActionContext, ActionDef } from '@object-ui/core'; const CREATE = { name: 'create_environment', @@ -96,7 +97,12 @@ function mountStack(opts: { held?: string[]; onUpgrade?: (spec: any) => void; }) { - const execute = vi.fn(async () => { + // The implementation spells out the `(action, ctx)` parameters `handlers` + // entries are called with, even though it reads neither: a zero-arity + // `vi.fn` infers `Mock<() => …>`, whose `mock.calls` is the EMPTY tuple — so + // the `execute.mock.calls[0][0]` assertions below were reading element 0 of + // an empty tuple as far as the compiler was concerned (objectui#4040). + const execute = vi.fn(async (_action: ActionDef, _ctx?: ActionContext) => { events.push('runner:execute'); return { success: true }; }); diff --git a/packages/app-shell/src/environment/__tests__/EnvironmentListToolbar.deepLinkOverflow.test.tsx b/packages/app-shell/src/environment/__tests__/EnvironmentListToolbar.deepLinkOverflow.test.tsx index 5ee910a606..7ced127d42 100644 --- a/packages/app-shell/src/environment/__tests__/EnvironmentListToolbar.deepLinkOverflow.test.tsx +++ b/packages/app-shell/src/environment/__tests__/EnvironmentListToolbar.deepLinkOverflow.test.tsx @@ -46,6 +46,7 @@ import { ActionProvider } from '@object-ui/react'; import { I18nProvider } from '@object-ui/i18n'; import { EnvironmentListToolbar } from '../EnvironmentListToolbar'; import type { EnvironmentEntitlementsState } from '../entitlements'; +import type { ActionContext, ActionDef } from '@object-ui/core'; /** * The card's shape: a create action that declares neither `order` nor @@ -99,7 +100,11 @@ afterEach(() => { }); function mountStack(actions: any[]) { - const execute = vi.fn(async () => ({ success: true })); + // Parameters spelled out even though the body reads neither: a zero-arity + // `vi.fn` infers `Mock<() => …>`, whose `mock.calls` is the EMPTY tuple, so + // the `execute.mock.calls[0][0]` assertions below were reading element 0 of + // an empty tuple as far as the compiler was concerned (objectui#4040). + const execute = vi.fn(async (_action: ActionDef, _ctx?: ActionContext) => ({ success: true })); const view = render( diff --git a/packages/app-shell/src/hooks/__tests__/useChatConversation.test.tsx b/packages/app-shell/src/hooks/__tests__/useChatConversation.test.tsx index 5a52f436b2..89a9fb834d 100644 --- a/packages/app-shell/src/hooks/__tests__/useChatConversation.test.tsx +++ b/packages/app-shell/src/hooks/__tests__/useChatConversation.test.tsx @@ -19,6 +19,7 @@ import { toUIMessages, useChatConversation, writeConversationMessagesCache, + type HydratedUIMessage, } from '../useChatConversation'; const API_BASE = 'http://ai.test/api/v1/ai'; @@ -755,9 +756,18 @@ describe('useChatConversation — A1.b rekeyScope + legacy-scope fallback', () = }, ]); fetchMock.mockResolvedValueOnce(jsonResponse({ id: 'conv-legacy', messages: [] })); - const adoptLegacy = vi.fn( - (messages: { parts: Array<{ output?: { packageId?: string } }> }[]) => - messages.some((m) => m.parts.some((p) => p.output?.packageId === 'crm')), + // Typed with the option's own parameter type. The hand-written structural + // stand-in (`{ parts: Array<{ output?: { packageId?: string } }> }[]`) is + // NOT `HydratedUIMessage[]` — a `HydratedUIMessagePart` shares no declared + // property with it — so the mock was unassignable to `adoptLegacy` the + // moment anything compiled this file (objectui#4040). `output` rides in on + // the part's catch-all, so it still needs narrowing at the read. + const adoptLegacy = vi.fn((messages: HydratedUIMessage[]) => + messages.some((m) => + m.parts.some( + (p) => (p.output as { packageId?: string } | undefined)?.packageId === 'crm', + ), + ), ); const { result } = renderHook(() => diff --git a/packages/app-shell/src/hooks/useConsoleActionRuntime.tsx b/packages/app-shell/src/hooks/useConsoleActionRuntime.tsx index 42172dc367..ad8a9e4c4f 100644 --- a/packages/app-shell/src/hooks/useConsoleActionRuntime.tsx +++ b/packages/app-shell/src/hooks/useConsoleActionRuntime.tsx @@ -26,7 +26,7 @@ import { useNavigate } from 'react-router-dom'; import { useAuth, createAuthenticatedFetch } from '@object-ui/auth'; import { usePermissions } from '@object-ui/permissions'; import { useObjectLabel, useObjectTranslation } from '@object-ui/i18n'; -import { ActionProvider, useGlobalUndo } from '@object-ui/react'; +import { ActionProvider, useGlobalUndo, type ActionProviderProps } from '@object-ui/react'; import { toast } from 'sonner'; import type { ActionContext, @@ -79,7 +79,12 @@ export interface ConsoleActionRuntime { navigateHandler: NavigationHandler; paramCollectionHandler: ParamCollectionHandler; resultDialogHandler: ResultDialogHandler; - apiHandler: (action: ActionDef) => Promise; + // Two parameters, like its three siblings below — the implementation has + // always been `(action, context?)` (it reads `context.pageVariables` to + // resolve `{{page.}}` tokens). The one-parameter declaration was a + // narrower restatement that nothing could catch while the tests calling it + // with two arguments were unchecked (objectui#4040). + apiHandler: (action: ActionDef, context?: ActionContext) => Promise; flowHandler: (action: ActionDef, context?: ActionContext) => Promise; serverActionHandler: (action: ActionDef, context?: ActionContext) => Promise; /** `type: 'modal'` — opens `target` as a page/object form, else runs the action server-side. */ @@ -88,16 +93,32 @@ export interface ConsoleActionRuntime { authFetch: ReturnType; /** Open the shared environment entitlement (upgrade / limit) dialog. */ openEntitlementDialog: (spec: EntitlementDialogSpec) => void; - /** Props to spread onto ``. */ - actionProviderProps: { - context: Record; - onConfirm: ConfirmationHandler; - onToast: ToastHandler; - onNavigate: NavigationHandler; - onParamCollection: ParamCollectionHandler; - onResultDialog: ResultDialogHandler; - handlers: Record Promise>; - }; + /** + * Props to spread onto ``. + * + * DERIVED from that component's own props (`ActionProviderProps`) rather than + * restated. The key list stays explicit — it states which props this hook + * owns — but every TYPE comes from the consumer, so the two cannot drift. + * They had: the restatement omitted `onModal` (which the implementation has + * returned all along) and declared `handlers` values as one-parameter + * functions where `` passes `(action, ctx)`. Neither was + * visible while this package's tests were not type-checked — the suite next + * door asserts `typeof props.onModal === 'function'` and was reading a key + * the interface said did not exist (objectui#4040). + */ + actionProviderProps: Required< + Pick< + ActionProviderProps, + | 'context' + | 'onConfirm' + | 'onToast' + | 'onModal' + | 'onNavigate' + | 'onParamCollection' + | 'onResultDialog' + | 'handlers' + > + >; /** Confirm / param / result / paused-flow dialogs — render inside the provider. */ dialogs: React.ReactNode; } diff --git a/packages/app-shell/src/layout/__tests__/ContextSelectors.scopeKey.test.tsx b/packages/app-shell/src/layout/__tests__/ContextSelectors.scopeKey.test.tsx index 8a3eaf6626..3bf1a25928 100644 --- a/packages/app-shell/src/layout/__tests__/ContextSelectors.scopeKey.test.tsx +++ b/packages/app-shell/src/layout/__tests__/ContextSelectors.scopeKey.test.tsx @@ -59,7 +59,12 @@ vi.mock('@object-ui/components', async () => { }; }); -import { resolveHref } from '@object-ui/layout/NavigationRenderer'; +// The package root, not the deep `@object-ui/layout/NavigationRenderer` path: +// `@object-ui/layout` declares a single `.` export, so that subpath resolves +// through no `exports` entry and had no types at all (TS2307). `resolveHref` is +// re-exported from the barrel (`export * from './NavigationRenderer'`), so this +// is the same symbol by its public name (objectui#4040). +import { resolveHref } from '@object-ui/layout'; import { useAppContextSelectors, contextSelectorQueryKey, diff --git a/packages/app-shell/src/providers/writeWarningToast.test.ts b/packages/app-shell/src/providers/writeWarningToast.test.ts index 8cbe6750f7..ce6651b1b2 100644 --- a/packages/app-shell/src/providers/writeWarningToast.test.ts +++ b/packages/app-shell/src/providers/writeWarningToast.test.ts @@ -41,14 +41,24 @@ const t = (_key: string, opts?: Record) => { /** Identity field-label resolver (no translation bundle loaded). */ const identityLabel = (_o: string, _f: string, fallback: string) => fallback; -const ANDON_SCHEMA = { +/** + * The label lookup reads `fields[].label` and falls back to the key when + * the entry is absent — so the schema shape is an OPEN map of field entries, not + * this fixture's two specific keys. Annotated as such: without it `vi.fn` infers + * the resolved type from this literal alone, and the `{ fields: {} }` case below + * (the whole point of the "falls back to the API key" test) is rejected for + * missing `type` / `source_method` (objectui#4040). + */ +type ObjectSchemaShape = { fields: Record }; + +const ANDON_SCHEMA: ObjectSchemaShape = { fields: { type: { label: 'Andon type' }, source_method: { label: 'Source method' }, }, }; -const adapter = { getObjectSchema: vi.fn(async () => ANDON_SCHEMA) }; +const adapter = { getObjectSchema: vi.fn(async (): Promise => ANDON_SCHEMA) }; const EVENT: WriteWarningEvent = { operation: 'update', diff --git a/packages/app-shell/src/services/MetadataService.saveAdvisories.test.ts b/packages/app-shell/src/services/MetadataService.saveAdvisories.test.ts index a1bc4587ae..1f28e4defc 100644 --- a/packages/app-shell/src/services/MetadataService.saveAdvisories.test.ts +++ b/packages/app-shell/src/services/MetadataService.saveAdvisories.test.ts @@ -93,7 +93,12 @@ function makeWiredAdapter(body: unknown) { return { adapter, sink, events }; } -const ACCOUNT: ObjectDefinition = { name: 'account', label: 'Account', fields: [] } as ObjectDefinition; +// `id` is required by `ObjectDefinition` and the fixture never carried it, while +// `fields` is not a key of `ObjectDefinition` at all (`saveObject` takes the +// field list as its SECOND parameter). The `as ObjectDefinition` asserted past +// both, and once compiled the assertion itself is rejected as a non-overlap +// (objectui#4040). Declared properly instead of widening the cast. +const ACCOUNT: ObjectDefinition = { id: 'account', name: 'account', label: 'Account' }; describe('MetadataService saves reach the shell advisory surface (#4237)', () => { it('renders the gate findings for a save that succeeded', async () => { diff --git a/packages/app-shell/src/utils/decisionOutputParams.test.ts b/packages/app-shell/src/utils/decisionOutputParams.test.ts index afec456bbb..27d4398621 100644 --- a/packages/app-shell/src/utils/decisionOutputParams.test.ts +++ b/packages/app-shell/src/utils/decisionOutputParams.test.ts @@ -122,11 +122,14 @@ describe('decisionOutputParams — declaration → param', () => { }); describe('decisionOutputParams — the params survive resolution (objectui#2955)', () => { + // `as const` keeps the first column at its literal type: `decisionOutputParams` + // takes the closed `'text' | 'position' | 'user' | 'department' | 'team'` + // union, and a bare table widens it to `string` (objectui#4040). it.each([ ['department', 'sys_business_unit'], ['position', 'sys_position'], ['team', 'sys_team'], - ])('a %s output reaches the widget as a %s picker', (type, referenceTo) => { + ] as const)('a %s output reaches the widget as a %s picker', (type, referenceTo) => { const [param] = decisionOutputParams([{ key: 'k', type, multiple: true }], t); // The spec spelling — `referenceTo` is dropped by the resolver. expect(param).toMatchObject({ type: 'lookup', reference: referenceTo }); @@ -161,7 +164,7 @@ describe('decisionOutputParams — the params survive resolution (objectui#2955) it('never degrades a typed picker to a record-id text box', () => { // The #2955 symptom, stated as the invariant: no declared record kind may // arrive at the widget as `text` (that is the "paste a UUID" fallback). - for (const type of ['user', 'department', 'position', 'team']) { + for (const type of ['user', 'department', 'position', 'team'] as const) { const [param] = decisionOutputParams([{ key: 'k', type }], t); expect(widgetFor(param).type).not.toBe('text'); } diff --git a/packages/app-shell/src/utils/resolveActionParams.optionVisibleWhen.test.tsx b/packages/app-shell/src/utils/resolveActionParams.optionVisibleWhen.test.tsx index 04bb2b42f3..2032d242d0 100644 --- a/packages/app-shell/src/utils/resolveActionParams.optionVisibleWhen.test.tsx +++ b/packages/app-shell/src/utils/resolveActionParams.optionVisibleWhen.test.tsx @@ -40,12 +40,22 @@ import { render, screen } from '@testing-library/react'; import '@testing-library/jest-dom'; import { PredicateScopeProvider } from '@object-ui/react'; import { SelectField } from '@object-ui/fields'; +import type { ActionParamOption } from '@object-ui/core'; import { resolveActionParams, type ResolveActionParamsContext } from './resolveActionParams'; import { paramToField } from './paramToField'; -/** An object whose `tier` field gates one option on the viewer's positions. */ +/** + * An object whose `tier` field gates one option on the viewer's positions. + * + * `options` carries the element type the runtime field declares + * (`ActionParamOption | string`) rather than `Record< string, unknown > | + * string`: the latter is wider than the field it is fed to — it admits an + * option with no `label` / `value` at all — and only went unnoticed while this + * file was not type-checked (objectui#4040). Every fixture below already + * carries both keys; `visibleWhen` and friends ride along on the catch-all. + */ const accountCtx = ( - options: Array | string>, + options: Array, ): ResolveActionParamsContext => ({ objectName: 'account', objects: [{ name: 'account', fields: { tier: { type: 'select', label: 'Tier', options } } }], @@ -63,7 +73,7 @@ const ROLE_GATED = [ * the widget as `field` with `id={param.name}`. */ function renderInheritedSelect( - options: Array | string>, + options: Array, positions: string[], value?: string, ) { diff --git a/packages/app-shell/src/views/ObjectView.defaultViewIdentity.test.tsx b/packages/app-shell/src/views/ObjectView.defaultViewIdentity.test.tsx index 52bd0c6506..556c399b9c 100644 --- a/packages/app-shell/src/views/ObjectView.defaultViewIdentity.test.tsx +++ b/packages/app-shell/src/views/ObjectView.defaultViewIdentity.test.tsx @@ -111,11 +111,13 @@ const ZH_PAYLOAD_RETIRED_KEY = { }; const wrapper = ({ children }: { children: React.ReactNode }) => - React.createElement( - I18nProvider, - { config: { defaultLanguage: 'zh', detectBrowserLanguage: false } }, + // `children` in the PROPS object, not the third argument: `I18nProviderProps` + // declares it required, and the third-argument form does not satisfy that + // overload (objectui#4040, same fix as packages/react's tranche). + React.createElement(I18nProvider, { + config: { defaultLanguage: 'zh', detectBrowserLanguage: false }, children, - ); + }); /** Mount the real resolver over a server payload, exactly as the console does. */ function withServerBundle(payload: Record) { diff --git a/packages/app-shell/src/views/ObjectView.setDefaultViewIdentity.test.tsx b/packages/app-shell/src/views/ObjectView.setDefaultViewIdentity.test.tsx index 6e9692bb44..bee0933c7f 100644 --- a/packages/app-shell/src/views/ObjectView.setDefaultViewIdentity.test.tsx +++ b/packages/app-shell/src/views/ObjectView.setDefaultViewIdentity.test.tsx @@ -239,7 +239,13 @@ describe('divergence A — a stored override row carrying a foreign `id` (#4211) fallbackTab, }); const tab = tabs.find(t => t.label === 'My View')!; - const updateView = vi.fn(async () => ({})); + // Parameters spelled out to match the adapter call this stands in for: a + // zero-arity `vi.fn` infers `Mock<() => …>`, and calling it with three + // arguments is a compile error the untyped test tree could not report + // (objectui#4040). + const updateView = vi.fn( + async (_objectName: string, _viewId: string, _patch: { isDefault: boolean }) => ({}), + ); for (const { viewId, patch } of setDefaultViewPatches(savedViews, tab.id)) { await updateView(OBJECT_NAME, viewId, patch); } @@ -313,7 +319,13 @@ describe('divergence B — a duplicated view whose `config` carries its source ` it('the write reaches the adapter with `{ isDefault: true }`', async () => { const tab = tabs().find(t => t.label === 'My View')!; - const updateView = vi.fn(async () => ({})); + // Parameters spelled out to match the adapter call this stands in for: a + // zero-arity `vi.fn` infers `Mock<() => …>`, and calling it with three + // arguments is a compile error the untyped test tree could not report + // (objectui#4040). + const updateView = vi.fn( + async (_objectName: string, _viewId: string, _patch: { isDefault: boolean }) => ({}), + ); for (const { viewId, patch } of setDefaultViewPatches(savedViews, tab.id)) { await updateView(OBJECT_NAME, viewId, patch); } diff --git a/packages/app-shell/src/views/RecordFormPage.i18n.test.tsx b/packages/app-shell/src/views/RecordFormPage.i18n.test.tsx index 524815ebb6..c00ad339c0 100644 --- a/packages/app-shell/src/views/RecordFormPage.i18n.test.tsx +++ b/packages/app-shell/src/views/RecordFormPage.i18n.test.tsx @@ -128,10 +128,12 @@ function renderPage( : '/apps/:appName/:objectName/record/:recordId/edit'; return render( - h( - I18nProvider, - { config: { defaultLanguage: language, detectBrowserLanguage: false } }, - h( + // `children` rides in the PROPS object: `I18nProviderProps` declares it + // required, and `createElement`'s third-argument form does not satisfy that + // overload (objectui#4040, same fix as packages/react's tranche). + h(I18nProvider, { + config: { defaultLanguage: language, detectBrowserLanguage: false }, + children: h( MemoryRouter, { initialEntries: [path] }, h( @@ -140,7 +142,7 @@ function renderPage( h(Route, { path: route, element: h(RecordFormPage, { mode }) }), ), ), - ), + }), ); } diff --git a/packages/app-shell/src/views/__tests__/FlowRunner.visibleWhen.test.tsx b/packages/app-shell/src/views/__tests__/FlowRunner.visibleWhen.test.tsx index 9e6f695860..8705787599 100644 --- a/packages/app-shell/src/views/__tests__/FlowRunner.visibleWhen.test.tsx +++ b/packages/app-shell/src/views/__tests__/FlowRunner.visibleWhen.test.tsx @@ -56,7 +56,15 @@ function setup(state: ScreenFlowState, authFetch: (url: string, init?: RequestIn return { onClose, onComplete }; } -const okResume = () => vi.fn(async () => jsonResponse({ success: true, data: { success: true } })); +// The implementation spells out `FlowRunner`'s `authFetch` parameters even +// though it reads neither: a zero-arity `vi.fn` infers `Mock<() => …>`, whose +// `mock.calls` is the EMPTY tuple — so the `calls[0][0]` / `calls[0][1]` +// assertions below were reading elements of an empty tuple as far as the +// compiler was concerned (objectui#4040). +const okResume = () => + vi.fn(async (_url: string, _init?: RequestInit) => + jsonResponse({ success: true, data: { success: true } }), + ); beforeEach(() => vi.restoreAllMocks()); diff --git a/packages/app-shell/src/views/metadata-admin/MetadataTypeActions.test.tsx b/packages/app-shell/src/views/metadata-admin/MetadataTypeActions.test.tsx index c5198cdd3b..299857d020 100644 --- a/packages/app-shell/src/views/metadata-admin/MetadataTypeActions.test.tsx +++ b/packages/app-shell/src/views/metadata-admin/MetadataTypeActions.test.tsx @@ -4,8 +4,12 @@ import * as React from 'react'; import { describe, it, expect, vi, beforeEach } from 'vitest'; import { render, screen, fireEvent, waitFor } from '@testing-library/react'; -// Capture the authenticated fetch so we can assert request bodies. -const mockFetch = vi.fn(async () => ({ +// Capture the authenticated fetch so we can assert request bodies. The +// implementation spells out the `(url, init)` parameters it never reads: a +// zero-arity `vi.fn` infers `Mock<() => …>`, whose `mock.calls` is the EMPTY +// tuple — so the `mock.calls[0]` destructurings below were reading elements of +// an empty tuple as far as the compiler was concerned (objectui#4040). +const mockFetch = vi.fn(async (_url: string, _init?: RequestInit) => ({ ok: true, status: 200, statusText: 'OK', @@ -108,8 +112,8 @@ describe('MetadataTypeActions', () => { expect(mockFetch).not.toHaveBeenCalled(); fireEvent.click(submit); await waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(1)); - const [, init] = mockFetch.mock.calls[0] as [string, RequestInit]; - expect(JSON.parse(String(init.body))).toEqual({ reason: 'because' }); + const [, init] = mockFetch.mock.calls[0]; + expect(JSON.parse(String(init?.body))).toEqual({ reason: 'because' }); }); it('shows the result dialog when the action declares resultDialog', async () => { diff --git a/packages/app-shell/src/views/metadata-admin/SchemaForm.unresolvedPredicate.test.tsx b/packages/app-shell/src/views/metadata-admin/SchemaForm.unresolvedPredicate.test.tsx index 61d4e561ec..0e2d59f3b9 100644 --- a/packages/app-shell/src/views/metadata-admin/SchemaForm.unresolvedPredicate.test.tsx +++ b/packages/app-shell/src/views/metadata-admin/SchemaForm.unresolvedPredicate.test.tsx @@ -28,12 +28,15 @@ * `predicate.test.ts` instead. */ -import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach, vi, type MockInstance } from 'vitest'; import { render, screen, cleanup, fireEvent } from '@testing-library/react'; import { SchemaForm } from './SchemaForm'; import { resetPredicateWarnings } from './predicate'; -let warn: ReturnType; +// See the note in `predicate.test.ts`: `ReturnType` is the +// un-instantiated `MockInstance`, whose `mock.calls` +// carries no argument types (objectui#4040). +let warn: MockInstance; beforeEach(() => { resetPredicateWarnings(); diff --git a/packages/app-shell/src/views/metadata-admin/inspectors/FlowNodeInspector.test.tsx b/packages/app-shell/src/views/metadata-admin/inspectors/FlowNodeInspector.test.tsx index 8a2255f732..6137233f98 100644 --- a/packages/app-shell/src/views/metadata-admin/inspectors/FlowNodeInspector.test.tsx +++ b/packages/app-shell/src/views/metadata-admin/inspectors/FlowNodeInspector.test.tsx @@ -69,6 +69,8 @@ function makeDraft() { }; } +// `locale` below is `SupportedLocale` (`'en-US' | 'zh-CN'`); this file spelled it +// `"en"`, which was never one of them — it just went unchecked (objectui#4040). function renderInspector(selection: MetadataSelection, draft: Record = makeDraft()) { const onPatch = vi.fn(); const onClearSelection = vi.fn(); @@ -81,7 +83,7 @@ function renderInspector(selection: MetadataSelection, draft: Record, ); return { onPatch, onClearSelection, ...utils }; diff --git a/packages/app-shell/src/views/metadata-admin/inspectors/FlowReferenceField.sources.test.tsx b/packages/app-shell/src/views/metadata-admin/inspectors/FlowReferenceField.sources.test.tsx index 5f47664a2a..c3ce237a01 100644 --- a/packages/app-shell/src/views/metadata-admin/inspectors/FlowReferenceField.sources.test.tsx +++ b/packages/app-shell/src/views/metadata-admin/inspectors/FlowReferenceField.sources.test.tsx @@ -26,6 +26,7 @@ import { describe, it, expect } from 'vitest'; import { recordLookupFor, resolveRefKind, KIND_TO_RECORD_LOOKUP } from './FlowReferenceField'; +import type { FlowReferenceSpec } from './flow-node-config'; describe('recordLookupFor — published source vs local mirror (#3508 follow-up)', () => { it('prefers the schema’s object and committed column over the local table', () => { @@ -70,7 +71,11 @@ describe('recordLookupFor — published source vs local mirror (#3508 follow-up) }); describe('resolveRefKind — sources are keyed by the discriminator (#3508 follow-up)', () => { - const ref = { + // Annotated with the spec type the resolver takes, so `map`'s values are + // checked against `ReferenceKind` instead of widening to `string` — which is + // what made this fixture unassignable the moment anything compiled it + // (objectui#4040). + const ref: FlowReferenceSpec = { kindFrom: 'type', map: { user: 'user', role: 'org-membership-level', org_membership_level: 'org-membership-level' }, sources: { diff --git a/packages/app-shell/src/views/metadata-admin/inspectors/ObjectFieldInspector.draft-locale.test.tsx b/packages/app-shell/src/views/metadata-admin/inspectors/ObjectFieldInspector.draft-locale.test.tsx index 2d62008434..15be36413d 100644 --- a/packages/app-shell/src/views/metadata-admin/inspectors/ObjectFieldInspector.draft-locale.test.tsx +++ b/packages/app-shell/src/views/metadata-admin/inspectors/ObjectFieldInspector.draft-locale.test.tsx @@ -19,11 +19,15 @@ vi.mock('../previews/useObjectFields', () => ({ })); import { ObjectFieldInspector } from './ObjectFieldInspector'; +// The inspector's `locale` prop is the closed `'en-US' | 'zh-CN'` union, not a +// bare string — the loop below iterates exactly those two, so annotating the +// helper keeps the array's element type from widening back to `string`. +import type { SupportedLocale } from '../i18n'; afterEach(cleanup); /** The lookup branch is the only one that renders the object picker. */ -function renderLookup(locale: string) { +function renderLookup(locale: SupportedLocale) { return render( { }); it('leaves published objects unsuffixed in both locales', async () => { - for (const locale of ['en-US', 'zh-CN']) { + for (const locale of ['en-US', 'zh-CN'] as const) { const { container, unmount } = renderLookup(locale); const opt = await waitFor(() => { const found = [...container.querySelectorAll('option')].find( diff --git a/packages/app-shell/src/views/metadata-admin/inspectors/PageBlockInspector.visibleWhen.test.tsx b/packages/app-shell/src/views/metadata-admin/inspectors/PageBlockInspector.visibleWhen.test.tsx index 5dc01b10d6..e57a556fdd 100644 --- a/packages/app-shell/src/views/metadata-admin/inspectors/PageBlockInspector.visibleWhen.test.tsx +++ b/packages/app-shell/src/views/metadata-admin/inspectors/PageBlockInspector.visibleWhen.test.tsx @@ -26,12 +26,14 @@ * which normalizes a bare string into `{ dialect: 'cel', source }`. */ -import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest'; +import { describe, it, expect, vi, afterEach, beforeEach, type Mock } from 'vitest'; import { render, screen, fireEvent, cleanup } from '@testing-library/react'; import { PageSchema } from '@objectstack/spec/ui'; import { ComponentRegistry } from '@object-ui/core'; import { SchemaRenderer, PredicateScopeProvider } from '@object-ui/react'; import { PageBlockInspector } from './PageBlockInspector'; +import type { MetadataInspectorProps } from '../inspector-registry'; +import type { SupportedLocale } from '../i18n'; afterEach(cleanup); @@ -54,7 +56,17 @@ function pageDraft(block: Record): Record { function renderInspector( draft: Record, - { locale = 'en-US', onPatch = vi.fn() }: { locale?: string; onPatch?: ReturnType } = {}, + // `onPatch` carries the inspector prop's own signature: `ReturnType` is the un-instantiated `Mock`, which the + // prop does not accept (objectui#4040). `locale` likewise takes the closed + // union instead of `string`, which is what forced the `as never` below. + { + locale = 'en-US', + onPatch = vi.fn(), + }: { + locale?: SupportedLocale; + onPatch?: Mock; + } = {}, ) { render( {}} readOnly={false} - locale={locale as never} + locale={locale} />, ); return onPatch; diff --git a/packages/app-shell/src/views/metadata-admin/inspectors/__tests__/nav-target.test.ts b/packages/app-shell/src/views/metadata-admin/inspectors/__tests__/nav-target.test.ts index 21f49b7a5f..e2a4a9b4d4 100644 --- a/packages/app-shell/src/views/metadata-admin/inspectors/__tests__/nav-target.test.ts +++ b/packages/app-shell/src/views/metadata-admin/inspectors/__tests__/nav-target.test.ts @@ -101,6 +101,11 @@ describe('isStaticPageOption — page picker excludes record-detail pages (#2333 }); it('keeps rows missing both discriminators (only confirmed record pages excluded)', () => { expect(isStaticPageOption({})).toBe(true); - expect(isStaticPageOption({ name: 'x' })).toBe(true); + // A real metadata row carries `name` / `label` too. The predicate declares + // only the two discriminators it reads, so a FRESH literal with `name` trips + // excess-property checking; deriving the parameter type and intersecting the + // extra key states the case without casting it away (objectui#4040). + const namedRow: Parameters[0] & { name: string } = { name: 'x' }; + expect(isStaticPageOption(namedRow)).toBe(true); }); }); diff --git a/packages/app-shell/src/views/metadata-admin/inspectors/notify-node.dogfood.test.tsx b/packages/app-shell/src/views/metadata-admin/inspectors/notify-node.dogfood.test.tsx index 5f63dcb6f4..41ef1c68bb 100644 --- a/packages/app-shell/src/views/metadata-admin/inspectors/notify-node.dogfood.test.tsx +++ b/packages/app-shell/src/views/metadata-admin/inspectors/notify-node.dogfood.test.tsx @@ -74,7 +74,7 @@ describe('notify node — inspector renders its config fields', () => { onPatch={vi.fn()} onClearSelection={vi.fn()} readOnly={false} - locale="en" + locale="en-US" />, ); @@ -97,7 +97,7 @@ describe('notify node — inspector renders its config fields', () => { onPatch={onPatch} onClearSelection={vi.fn()} readOnly={false} - locale="en" + locale="en-US" />, ); diff --git a/packages/app-shell/src/views/metadata-admin/predicate.test.ts b/packages/app-shell/src/views/metadata-admin/predicate.test.ts index c031a18e21..93f3b19c20 100644 --- a/packages/app-shell/src/views/metadata-admin/predicate.test.ts +++ b/packages/app-shell/src/views/metadata-admin/predicate.test.ts @@ -21,7 +21,7 @@ * restores the silent-hide bug. Both directions are asserted here. */ -import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach, vi, type MockInstance } from 'vitest'; import { objectForm } from '@objectstack/spec/data'; import { evaluatePredicate, resetPredicateWarnings } from './predicate'; @@ -30,7 +30,11 @@ import { evaluatePredicate, resetPredicateWarnings } from './predicate'; * warn-once memo outlives a single test file. Reset before every test or the * second assertion on a given (path, predicate) pair sees no warning. */ -let warn: ReturnType; +// `MockInstance`, not `ReturnType`: the +// latter is the un-instantiated `MockInstance`, whose +// `mock.calls` carries no argument types, so `warnings()` below took an implicit +// `any` (objectui#4040). +let warn: MockInstance; beforeEach(() => { resetPredicateWarnings(); diff --git a/packages/app-shell/src/views/metadata-admin/previews/AgentPreview.test.tsx b/packages/app-shell/src/views/metadata-admin/previews/AgentPreview.test.tsx index 1da8241a5f..2fa0f793ce 100644 --- a/packages/app-shell/src/views/metadata-admin/previews/AgentPreview.test.tsx +++ b/packages/app-shell/src/views/metadata-admin/previews/AgentPreview.test.tsx @@ -51,7 +51,7 @@ const STALE_DRAFT = { function renderPreview(draft: Record) { return render( - , + , ); } diff --git a/packages/app-shell/src/views/metadata-admin/previews/AppPreview.test.tsx b/packages/app-shell/src/views/metadata-admin/previews/AppPreview.test.tsx index c67c290fda..388e9cf7df 100644 --- a/packages/app-shell/src/views/metadata-admin/previews/AppPreview.test.tsx +++ b/packages/app-shell/src/views/metadata-admin/previews/AppPreview.test.tsx @@ -75,7 +75,7 @@ const STALE_DRAFT = { /** Read-only preview mode: no `onSelectionChange` ⇒ the NavRow list, not the canvas. */ function renderPreview(draft: Record) { - return render(); + return render(); } describe('AppPreview reads the navigation discriminated union', () => { @@ -186,7 +186,7 @@ describe('AppNavCanvas (design mode) reads the same union', () => { function renderCanvas(draft: Record) { return render( ) { return render( - , + , ); } diff --git a/packages/app-shell/src/views/metadata-admin/previews/FlowCanvas.test.tsx b/packages/app-shell/src/views/metadata-admin/previews/FlowCanvas.test.tsx index 9965d1739d..fb94dfb8f0 100644 --- a/packages/app-shell/src/views/metadata-admin/previews/FlowCanvas.test.tsx +++ b/packages/app-shell/src/views/metadata-admin/previews/FlowCanvas.test.tsx @@ -1,6 +1,7 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. -import { describe, it, expect, afterEach, vi } from 'vitest'; +import * as React from 'react'; +import { describe, it, expect, afterEach, vi, type Mock } from 'vitest'; import { render, screen, cleanup, fireEvent } from '@testing-library/react'; import { FlowCanvas } from './FlowCanvas'; import { extractRegions, NODE_H } from './flow-canvas-layout'; @@ -386,8 +387,11 @@ describe('FlowCanvas — geometry writes are spec-canonical `position` (#3172)', fireEvent.pointerUp(card, { clientX: dx, clientY: dy }); }; + // `Mock<…>` with the prop's own signature, not `ReturnType` — + // the latter is the un-instantiated `Mock`, which + // `onPatch` does not accept (objectui#4040). const renderCanvas = ( - onPatch: ReturnType, + onPatch: Mock['onPatch']>>, { nodes = LEGACY_NODES, selectedId = null }: { nodes?: typeof LEGACY_NODES; selectedId?: string | null } = {}, ) => render( diff --git a/packages/app-shell/src/views/metadata-admin/previews/PagePreview.test.tsx b/packages/app-shell/src/views/metadata-admin/previews/PagePreview.test.tsx index 2d88587f27..4e1e0cc4d1 100644 --- a/packages/app-shell/src/views/metadata-admin/previews/PagePreview.test.tsx +++ b/packages/app-shell/src/views/metadata-admin/previews/PagePreview.test.tsx @@ -43,7 +43,10 @@ const regionDraft = { describe('PagePreview — interface-page routing (ADR-0047)', () => { it('renders the runtime InterfaceListPage for an interface page in preview mode', () => { // preview mode = no onSelectionChange (not editing the canvas) - render(); + // `type` / `name` are the host-owned half of `MetadataPreviewProps` (the + // metadata type and the item's primary key); every render site below has to + // supply them now that this file is type-checked (objectui#4040). + render(); expect(screen.getByTestId('mock-interface-list')).toBeInTheDocument(); expect(screen.queryByTestId('mock-schema-renderer')).not.toBeInTheDocument(); }); @@ -51,6 +54,8 @@ describe('PagePreview — interface-page routing (ADR-0047)', () => { it('also renders the live InterfaceListPage in design mode (no canvas hint)', () => { render( {}} @@ -64,7 +69,7 @@ describe('PagePreview — interface-page routing (ADR-0047)', () => { }); it('renders the generic SchemaRenderer for a region-composed page (not an interface page)', () => { - render(); + render(); expect(screen.queryByTestId('mock-interface-list')).not.toBeInTheDocument(); expect(screen.getByTestId('mock-schema-renderer')).toBeInTheDocument(); }); @@ -95,7 +100,7 @@ describe('PagePreview — slotted record page synthesis', () => { }; it('renders synthesized default regions (not the empty draft) so the preview is not blank', async () => { - render(); + render(); await waitFor(() => expect(schemaSpy).toHaveBeenCalled()); const rendered = schemaSpy.mock.calls.at(-1)![0]; @@ -108,7 +113,7 @@ describe('PagePreview — slotted record page synthesis', () => { }); it('fills omitted slots with synthesized defaults and applies authored overrides', async () => { - render(); + render(); await waitFor(() => expect(schemaSpy).toHaveBeenCalled()); const rendered = schemaSpy.mock.calls.at(-1)![0]; const main = rendered.regions.find((r: any) => r.name === 'main'); @@ -137,7 +142,7 @@ describe('PagePreview — slotted record page synthesis', () => { kind: 'full', regions: [{ name: 'main', components: [{ type: 'record:details' }] }], }; - render(); + render(); await waitFor(() => expect(schemaSpy).toHaveBeenCalled()); const rendered = schemaSpy.mock.calls.at(-1)![0]; // No synthesis — the authored single-region layout passes through as-is. diff --git a/packages/app-shell/src/views/metadata-admin/previews/PermissionPreview.test.tsx b/packages/app-shell/src/views/metadata-admin/previews/PermissionPreview.test.tsx index e6debff4ae..3fe6592302 100644 --- a/packages/app-shell/src/views/metadata-admin/previews/PermissionPreview.test.tsx +++ b/packages/app-shell/src/views/metadata-admin/previews/PermissionPreview.test.tsx @@ -26,7 +26,7 @@ const baseProps = { type: 'permission', name: 'sales_rep', locale: 'en-US' as co function renderPreview(objects: Record) { return render( , ); diff --git a/packages/app-shell/src/views/metadata-admin/previews/SkillPreview.test.tsx b/packages/app-shell/src/views/metadata-admin/previews/SkillPreview.test.tsx index ad94079381..ac4f4ca794 100644 --- a/packages/app-shell/src/views/metadata-admin/previews/SkillPreview.test.tsx +++ b/packages/app-shell/src/views/metadata-admin/previews/SkillPreview.test.tsx @@ -49,7 +49,7 @@ const STALE_DRAFT = { function renderPreview(draft: Record) { return render( - , + , ); } diff --git a/packages/app-shell/src/views/metadata-admin/previews/ToolPreview.test.tsx b/packages/app-shell/src/views/metadata-admin/previews/ToolPreview.test.tsx index 3ebf48cc81..8c53a083a0 100644 --- a/packages/app-shell/src/views/metadata-admin/previews/ToolPreview.test.tsx +++ b/packages/app-shell/src/views/metadata-admin/previews/ToolPreview.test.tsx @@ -50,7 +50,7 @@ const STALE_DRAFT = { function renderPreview(draft: Record) { return render( - , + , ); } diff --git a/packages/app-shell/src/views/metadata-admin/previews/ValidationPreview.test.tsx b/packages/app-shell/src/views/metadata-admin/previews/ValidationPreview.test.tsx index 3438c8f647..fed1ac9f67 100644 --- a/packages/app-shell/src/views/metadata-admin/previews/ValidationPreview.test.tsx +++ b/packages/app-shell/src/views/metadata-admin/previews/ValidationPreview.test.tsx @@ -53,7 +53,8 @@ const BASE = { function renderPreview(draft: Record) { return render( , ); diff --git a/packages/app-shell/tsconfig.test.json b/packages/app-shell/tsconfig.test.json new file mode 100644 index 0000000000..f75031cce2 --- /dev/null +++ b/packages/app-shell/tsconfig.test.json @@ -0,0 +1,50 @@ +{ + // Type-checks this package's TESTS, which `tsconfig.json` excludes. + // See `packages/types/tsconfig.test.json` for why that exclusion was a hole: + // the build correctly keeps tests out of `dist`, but nothing else compiled + // them, so a test could assert a contract the compiler never checked — and + // this package had a large backlog of exactly that, including an + // `actionProviderProps` restatement that omitted a prop the implementation + // has always returned (objectui#4040). + // + // This project is now the ONLY one that compiles the five files whose whole + // value is compile-time assertions — `src/__tests__/spec-symbol-parity.test.ts`, + // `src/utils/resolveActionParams.test.ts`, the two flow-edge `.types.test.ts` + // pins and `InspectorComboField.naming.types.test.tsx`. Their assertions are + // erased at runtime, so vitest proves nothing about them and `tsc` is the only + // thing that can (objectui#3181). A narrow `tsconfig.typetests.json` next door + // used to name those five while the rest of the tree was in TEST_DEBT; the + // package graduated in objectui#4040 and the narrow project was retired with + // it, after `--listFiles` showed this project reads all five (of 3624 files) + // and a provably-false `Assert` appended to the parity test turned this + // project red. + // + // Chained from this package's `type-check` script, which is what the CI + // `Type Check` job runs; scripts/check-type-check-coverage.mjs enforces the + // chaining — a config nothing runs is the objectui#3009 failure itself. + "extends": "../../tsconfig.json", + "compilerOptions": { + // A checking project, never an emitting one. + "noEmit": true, + // The package build emits `dist`; this project emits nothing, so it must + // not inherit `composite` / `declaration` from the build config. + "composite": false, + "declaration": false, + "jsx": "react-jsx", + // One notch above the package build's ES2020: these suites read the last + // emitted call with `calls.at(-1)` and use `Object.hasOwn` / `Array.at`, + // all ES2022. Raised HERE and not in `tsconfig.json`, so the package + // SOURCE keeps compiling against the ES2020 baseline it ships to. + "lib": ["ES2022", "DOM", "DOM.Iterable"], + // Naming `types` at all switches off automatic `@types/*` inclusion. + // `node` and `vite/client` mirror the build config (the suites touch + // `import.meta.env` and node globals); `@testing-library/jest-dom` is + // listed for the matchers the DOM suites use. + "types": ["node", "vite/client", "@testing-library/jest-dom"], + // Drop the root tsconfig's source-tree `paths` so `@object-ui/*` and + // `@objectstack/spec` resolve through the workspace dependency's built + // `.d.ts` instead of pulling sibling sources in as program inputs (TS6059). + "paths": {} + }, + "include": ["src/**/*.test.ts", "src/**/*.test.tsx"] +} diff --git a/packages/app-shell/tsconfig.typetests.json b/packages/app-shell/tsconfig.typetests.json deleted file mode 100644 index d4087f4ffe..0000000000 --- a/packages/app-shell/tsconfig.typetests.json +++ /dev/null @@ -1,88 +0,0 @@ -{ - // Compiles the test files whose ENTIRE value is compile-time type assertions, - // so that those assertions are actually checked by CI (objectui#3181). - // - // Why this exists as a THIRD project rather than as `tsconfig.test.json`: - // - // - `tsconfig.json` is the package BUILD (`tsc` -> dist, "rootDir": "src", - // "composite", "declaration"). It excludes `**/*.test.ts` correctly — test - // files would otherwise emit into the published dist. - // - `tsconfig.test.json` is the repo's name for "this package compiles ALL of - // its tests" (see packages/types). app-shell cannot claim that yet: its - // test tree still has a large pre-existing error backlog, declared as - // TEST_DEBT in scripts/check-type-check-coverage.mjs. Naming this file - // `tsconfig.test.json` would tell that guard the debt is paid and make it - // demand the entry be deleted — trading one false "checked" claim for - // another. - // - // So: a narrow, explicitly-listed project that compiles only files which are - // ALREADY clean and whose assertions are load-bearing. It is chained from the - // package's `type-check` script, which is what the CI `Type Check` job runs - // (`pnpm type-check` -> `turbo run type-check`), and that chaining is enforced - // by scripts/check-type-check-coverage.mjs — a config nothing runs is exactly - // the objectui#3009 / objectui#3181 failure this file is fixing. - // - // Adding a file here is a one-line change; the bar is that it compiles clean - // today. Do NOT switch this to a glob: a glob would sweep in the backlog and - // the first agent to hit it would "fix" that by deleting the whole project. - "extends": "../../tsconfig.json", - "compilerOptions": { - // A checking project, never an emitting one — and explicitly not part of - // the build graph, so it cannot leak test output into dist. - "noEmit": true, - "composite": false, - "declaration": false, - "lib": ["ES2020", "DOM"], - // `spec-symbol-parity.test.ts` resolves `@objectstack/spec`'s own `.d.ts` - // files off disk (createRequire / readFileSync / node:path) to read the - // spec's export names through the TypeScript checker. - "types": ["node"], - // Same reason as tsconfig.json: drop the root tsconfig's source-tree `paths` - // so `@objectstack/spec` and the `@object-ui/*` workspace deps resolve - // through the real dependency graph rather than through sibling `src/`. - "paths": {} - }, - // Explicit list, not a glob. Every entry is a file whose type assertions are - // the point of the file. - // - // `utils/resolveActionParams.test.ts` earns its place through objectui#3174: - // its last block authors params through `@object-ui/types`' PUBLIC - // `ActionParam` and follows one to the field the widgets consume. The reason - // that defect went unseen for so long is that every other test in the file - // authors the resolver's OWN local `RawActionParam`, so the resolver only - // ever agreed with itself — an `ActionParam` annotation that no `tsc` run - // reads would reproduce exactly that blind spot. - // - // `views/metadata-admin/previews/flow-designer-edge.types.test.ts` earns its - // place through objectui#3202: it pins the flow designer's edge guard to the - // spec's `ExpressionInput`, and what it guards against — an envelope missing - // the required `dialect` — is a TYPE-level regression only. No runtime path - // observes it (the inspector commits bare strings), so unchecked assertions - // would leave that tightening declared and unenforced. - // - // `views/metadata-admin/previews/simulator/__tests__/flow-sim-edge.types.test.ts` - // is the same pin on the SIMULATOR's edge (objectui#3216) — the last copy of - // that restatement. Its RUNTIME consequence is covered next door in - // `flow-simulator.test.ts` (the simulator skipped envelope guards); what - // stays type-only here is the other half of the same mistake, the direction - // in which the hand-written shape was too NARROW — excess-property checking - // rejected the canonical `{ dialect, source }` literal outright. No test that - // vitest runs can observe that, so unlisted it would be commentary. - // `views/metadata-admin/inspectors/InspectorComboField.naming.types.test.tsx` - // earns its place through objectui#3997: the combo's naming contract (exactly - // one of `label` / `ariaLabel` / `id`) is enforced only by its props union, and - // neither violation has a runtime symptom the component could report — an - // unnamed combobox renders and commits perfectly, it is just anonymous to - // assistive tech. So the whole check is compile-time, and unlisted it would be - // decoration: the first draft put the `@ts-expect-error` cases in - // `_shared.labels.test.tsx`, and making naming optional again type-checked - // completely green. It is also the first `.tsx` entry here — JSX compiles - // because the root tsconfig sets `"jsx": "react-jsx"`. - "include": [ - "src/__tests__/spec-symbol-parity.test.ts", - "src/utils/resolveActionParams.test.ts", - "src/views/metadata-admin/inspectors/InspectorComboField.naming.types.test.tsx", - "src/views/metadata-admin/previews/flow-designer-edge.types.test.ts", - "src/views/metadata-admin/previews/simulator/__tests__/flow-sim-edge.types.test.ts" - ] -} diff --git a/packages/core/package.json b/packages/core/package.json index 3efa776478..4f556704cb 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -27,7 +27,7 @@ "scripts": { "build": "tsc", "test": "vitest run", - "type-check": "tsc --noEmit && tsc -p tsconfig.typetests.json", + "type-check": "tsc --noEmit && tsc -p tsconfig.test.json", "lint": "eslint ." }, "dependencies": { diff --git a/packages/core/src/actions/__tests__/ActionEngine.visibility.test.ts b/packages/core/src/actions/__tests__/ActionEngine.visibility.test.ts index 699612c246..91806f2cb8 100644 --- a/packages/core/src/actions/__tests__/ActionEngine.visibility.test.ts +++ b/packages/core/src/actions/__tests__/ActionEngine.visibility.test.ts @@ -30,7 +30,7 @@ * unchanged by it. */ -import { describe, it, expect, beforeEach } from 'vitest'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; import { ActionEngine } from '../ActionEngine'; import { hasDeclaredPredicate } from '../../evaluator/declaredPredicate'; import type { ActionDef } from '../ActionRunner'; diff --git a/packages/core/src/actions/__tests__/ActionRunner.bodyExtra.test.ts b/packages/core/src/actions/__tests__/ActionRunner.bodyExtra.test.ts index e8db36283c..1f5bc6b69c 100644 --- a/packages/core/src/actions/__tests__/ActionRunner.bodyExtra.test.ts +++ b/packages/core/src/actions/__tests__/ActionRunner.bodyExtra.test.ts @@ -27,7 +27,7 @@ * stash would fire on nearly every declared-action click. */ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterEach, type MockInstance } from 'vitest'; import { ActionRunner } from '../ActionRunner'; import { resetActionKeyWarnings } from '../actionKeys'; @@ -118,7 +118,11 @@ describe('ActionRunner.executeAPI — bodyExtra', () => { }); describe('ActionRunner — object-form params deprecation window (#5777)', () => { - let warn: ReturnType; + // `MockInstance`, not `ReturnType`: + // the latter is the un-instantiated `MockInstance`, + // whose `mock.calls` carries no argument types — so the `(c) => c.join(' ')` + // callbacks below were implicit `any` (objectui#4040). + let warn: MockInstance; beforeEach(() => { resetActionKeyWarnings(); diff --git a/packages/core/src/actions/__tests__/ActionRunner.resultDialog.test.ts b/packages/core/src/actions/__tests__/ActionRunner.resultDialog.test.ts index 408badf789..afb089571e 100644 --- a/packages/core/src/actions/__tests__/ActionRunner.resultDialog.test.ts +++ b/packages/core/src/actions/__tests__/ActionRunner.resultDialog.test.ts @@ -17,13 +17,23 @@ * - `target` interpolation handles `${param.X}` and `${ctx.X}`, applies * `encodeURIComponent`, and degrades missing keys to empty string. */ -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { ActionRunner, type ActionDef } from '../ActionRunner'; +import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest'; +import { + ActionRunner, + type ActionDef, + type ResultDialogHandler, + type ToastHandler, +} from '../ActionRunner'; describe('ActionRunner - resultDialog', () => { let runner: ActionRunner; - let toast: ReturnType; - let resultDialog: ReturnType; + // Typed with the signature each setter declares, not `ReturnType` — that resolves to the un-instantiated `Mock`, which no handler slot accepts and whose `mock.calls[0]` is + // the EMPTY tuple, so every `calls[0][0]` assertion below was reading element + // 0 of an empty tuple as far as the compiler was concerned (objectui#4040). + let toast: Mock; + let resultDialog: Mock; beforeEach(() => { runner = new ActionRunner({}); diff --git a/packages/core/src/actions/__tests__/ActionRunner.scriptAwait.test.ts b/packages/core/src/actions/__tests__/ActionRunner.scriptAwait.test.ts index 760e7c053e..22487012c1 100644 --- a/packages/core/src/actions/__tests__/ActionRunner.scriptAwait.test.ts +++ b/packages/core/src/actions/__tests__/ActionRunner.scriptAwait.test.ts @@ -12,12 +12,15 @@ * the underlying write actually completing rather than firing as soon as the * (synchronous) expression evaluation returned a still-pending Promise. */ -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { ActionRunner, type ActionDef } from '../ActionRunner'; +import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest'; +import { ActionRunner, type ActionDef, type ToastHandler } from '../ActionRunner'; describe('ActionRunner - script action awaits a Promise-returning formula', () => { let runner: ActionRunner; - let toast: ReturnType; + // Typed with `setToastHandler`'s own signature — see the note in + // `ActionRunner.resultDialog.test.ts` for what `ReturnType` + // degrades to (objectui#4040). + let toast: Mock; beforeEach(() => { runner = new ActionRunner({}); diff --git a/packages/core/src/actions/__tests__/ActionRunner.test.ts b/packages/core/src/actions/__tests__/ActionRunner.test.ts index 18afe1f424..d7ed201711 100644 --- a/packages/core/src/actions/__tests__/ActionRunner.test.ts +++ b/packages/core/src/actions/__tests__/ActionRunner.test.ts @@ -6,7 +6,7 @@ * LICENSE file in the root directory of this source tree. */ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterEach, type Mock } from 'vitest'; import { ActionRunner, executeAction, @@ -264,12 +264,21 @@ describe('ActionRunner', () => { // `execute` was removed in @objectstack/spec 17 (#3855) — the parser now // rejects it outright, so no parsed action can carry it and the runner has // exactly one handler slot (#3856). Pinned as a test because the failure - // mode of re-adding `target || execute` is invisible: it type-checks ( - // ActionDef is open-ended) and it runs, it just resurrects the two-slot - // ambiguity that had one action running different scripts on each side of - // the wire (#3713). + // mode of re-adding `target || execute` used to be invisible: it ran, and + // it type-checked while `ActionDef` was open-ended, so it could quietly + // resurrect the two-slot ambiguity that had one action running different + // scripts on each side of the wire (#3713). + // + // `ActionDef` has since been CLOSED (objectstack#4075 step 3, pinned next + // door in `actionDef-closed-surface.test.ts`), so authoring `execute` is + // now a compile error as well — a fact this file could not state until it + // was type-checked (objectui#4040). `@ts-expect-error` rather than a cast: + // it pins the refusal in BOTH directions, since re-opening the surface + // would make the unused suppression itself an error, while the runtime + // assertions below keep pinning the runner's own refusal message. const result = await runner.execute({ type: 'script', + // @ts-expect-error -- `execute` is not a key of the closed `ActionDef` execute: 'record.id + 100', }); expect(result.success).toBe(false); @@ -746,7 +755,10 @@ describe('ActionRunner', () => { // which is exactly why these two branches had no coverage. Stub the minimum // the navigator touches and put it back afterwards. type MaybeWindow = { window?: unknown }; - let href: ReturnType; + // Typed with the setter's own signature: `ReturnType` is the + // un-instantiated `Mock`, which `tsc` reports as + // not callable at the `href(v)` call site below (objectui#4040). + let href: Mock<(url: string) => void>; let hadWindow: boolean; let previousWindow: unknown; diff --git a/packages/core/src/actions/__tests__/ActionRunner.urlParamsRetirement.test.ts b/packages/core/src/actions/__tests__/ActionRunner.urlParamsRetirement.test.ts index ab062e013b..a7f35d08c8 100644 --- a/packages/core/src/actions/__tests__/ActionRunner.urlParamsRetirement.test.ts +++ b/packages/core/src/actions/__tests__/ActionRunner.urlParamsRetirement.test.ts @@ -33,12 +33,19 @@ * spec has always refused at parse time — so the read could only ever fire on a * stack that never validated. */ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { ActionRunner } from '../ActionRunner'; +import { describe, it, expect, vi, beforeEach, afterEach, type Mock } from 'vitest'; +import { ActionRunner, type NavigationHandler } from '../ActionRunner'; + +// Every `navHandler` below is typed with `setNavigationHandler`'s own signature. +// `ReturnType` resolves to the un-instantiated `Mock`, which the setter does not accept and whose `mock.calls[0]` +// is the EMPTY tuple — so the `calls[0][0]` assertions in this file were reading +// element 0 of an empty tuple as far as the compiler was concerned +// (objectui#4040). describe('url action: `openIn` is the sanctioned new-tab spelling (objectstack#6828)', () => { let runner: ActionRunner; - let navHandler: ReturnType; + let navHandler: Mock; beforeEach(() => { runner = new ActionRunner({}); @@ -125,7 +132,7 @@ describe('url action: `openIn` is the sanctioned new-tab spelling (objectstack#6 describe('url action: `${param.X}` still interpolates the COLLECTED dialog values', () => { let runner: ActionRunner; - let navHandler: ReturnType; + let navHandler: Mock; beforeEach(() => { runner = new ActionRunner({}); @@ -169,7 +176,7 @@ describe('url action: `${param.X}` still interpolates the COLLECTED dialog value describe('url action: `${ctx.X}` is untouched by the retirement (control)', () => { let runner: ActionRunner; - let navHandler: ReturnType; + let navHandler: Mock; beforeEach(() => { navHandler = vi.fn(); diff --git a/packages/core/src/actions/__tests__/TransactionManager.test.ts b/packages/core/src/actions/__tests__/TransactionManager.test.ts index 1093c6e829..0e2d29c8d4 100644 --- a/packages/core/src/actions/__tests__/TransactionManager.test.ts +++ b/packages/core/src/actions/__tests__/TransactionManager.test.ts @@ -7,7 +7,13 @@ */ import { describe, it, expect, vi, beforeEach } from 'vitest'; -import type { DataSource, ActionResult, ActionSchema } from '@object-ui/types'; +// `UIActionSchema` — NOT the legacy `ActionSchema` from `crud.ts`, which is +// deprecated, requires `type: 'action'`, and inherits an OPTIONAL `name` from +// `BaseSchema`. `TransactionManager.executeTransaction` takes a +// `(action: UIActionSchema) => …` executor, so the fixtures below have to be +// authored in that vocabulary or they are asserting against a type the +// implementation never sees. +import type { DataSource, ActionResult, UIActionSchema } from '@object-ui/types'; import { TransactionManager, type TransactionProgressEvent, @@ -28,11 +34,11 @@ function createMockDataSource(): DataSource { }; } -function makeAction(name: string): ActionSchema { +function makeAction(name: string): UIActionSchema { return { name, label: name, type: 'script' }; } -function makeExecutor(results: Record): (action: ActionSchema) => Promise { +function makeExecutor(results: Record): (action: UIActionSchema) => Promise { return async (action) => results[action.name] || { success: true }; } @@ -351,7 +357,9 @@ describe('TransactionManager', () => { describe('Rollback Operations', () => { it('should rollback created records on transaction failure', async () => { let step = 0; - const executor = async (action: ActionSchema) => { + // Underscore-prefixed: this executor sequences on the call counter rather + // than on which action it was handed, and `noUnusedParameters` is on. + const executor = async (_action: UIActionSchema) => { step++; if (step === 1) { // First action succeeds and records an operation diff --git a/packages/core/src/builder/__tests__/schema-builder.test.ts b/packages/core/src/builder/__tests__/schema-builder.test.ts index 0ffcfba620..4fba57a36a 100644 --- a/packages/core/src/builder/__tests__/schema-builder.test.ts +++ b/packages/core/src/builder/__tests__/schema-builder.test.ts @@ -1,6 +1,21 @@ import { describe, it, expect } from 'vitest'; +import type { CRUDOperation, CRUDSchema } from '@object-ui/types'; import { form, crud, button, input, card, grid, flex } from '../../builder/schema-builder'; +/** + * `CRUDSchema.operations.` is `boolean | CRUDOperation | undefined`, so an + * `.enabled` read off it does not compile. The `enable*()` builders always write + * the OBJECT form; this narrows to it and fails loudly if that ever stops being + * true, instead of casting the distinction away. + */ +function operationOf(schema: CRUDSchema, key: 'create' | 'update' | 'delete'): CRUDOperation { + const operation = schema.operations?.[key]; + if (typeof operation !== 'object' || operation === null) { + throw new Error(`operations.${key} is not the object form: ${String(operation)}`); + } + return operation; +} + describe('SchemaBuilder', () => { describe('form()', () => { it('creates a basic form schema', () => { @@ -87,19 +102,25 @@ describe('SchemaBuilder', () => { expect(schema.resource).toBe('users'); }); + // The fixtures below used to be authored as `{ name, label }` — a dialect + // `TableColumn` does not have (its keys are `accessorKey` / `header`). The + // builder stores whatever it is handed, so `columns![0].name` was asserting + // that the builder returns its own input, on a shape no CRUD renderer reads + // (objectui#4040). it('supports column definitions', () => { const schema = crud() - .column({ name: 'name', label: 'Name' }) + .column({ accessorKey: 'name', header: 'Name' }) .build(); expect(schema.columns).toHaveLength(1); - expect(schema.columns![0].name).toBe('name'); + expect(schema.columns![0].accessorKey).toBe('name'); + expect(schema.columns![0].header).toBe('Name'); }); it('supports bulk columns', () => { const schema = crud() .columns([ - { name: 'id', label: 'ID' }, - { name: 'name', label: 'Name' }, + { accessorKey: 'id', header: 'ID' }, + { accessorKey: 'name', header: 'Name' }, ]) .build(); expect(schema.columns).toHaveLength(2); @@ -113,9 +134,9 @@ describe('SchemaBuilder', () => { .enableDelete() .build(); expect(schema.operations).toBeDefined(); - expect(schema.operations!.create.enabled).toBe(true); - expect(schema.operations!.update.enabled).toBe(true); - expect(schema.operations!.delete.enabled).toBe(true); + expect(operationOf(schema, 'create').enabled).toBe(true); + expect(operationOf(schema, 'update').enabled).toBe(true); + expect(operationOf(schema, 'delete').enabled).toBe(true); }); it('supports pagination', () => { diff --git a/packages/core/src/evaluator/__tests__/listConditional.test.ts b/packages/core/src/evaluator/__tests__/listConditional.test.ts index 8cd5bb7954..7a87f245af 100644 --- a/packages/core/src/evaluator/__tests__/listConditional.test.ts +++ b/packages/core/src/evaluator/__tests__/listConditional.test.ts @@ -6,7 +6,13 @@ * LICENSE file in the root directory of this source tree. */ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterEach, type MockInstance } from 'vitest'; + +// The `warn` spies below are typed `MockInstance` rather +// than `ReturnType`: the latter is the un-instantiated +// `MockInstance`, whose `mock.calls` carries no +// argument types, leaving every `(c) => String(c[0])` callback an implicit +// `any` (objectui#4040). import { evalRowPredicate, resolveConditionalFormatting, @@ -81,7 +87,7 @@ describe('evalRowPredicate', () => { }); describe('legacy-dialect routing', () => { - let warn: ReturnType; + let warn: MockInstance; beforeEach(() => { warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); }); @@ -101,7 +107,7 @@ describe('evalRowPredicate', () => { }); describe('warnOnError (fail-closed row actions)', () => { - let warn: ReturnType; + let warn: MockInstance; beforeEach(() => { warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); }); diff --git a/packages/core/src/registry/__tests__/PluginSystem.test.ts b/packages/core/src/registry/__tests__/PluginSystem.test.ts index 9b9d97c592..15cddd0d63 100644 --- a/packages/core/src/registry/__tests__/PluginSystem.test.ts +++ b/packages/core/src/registry/__tests__/PluginSystem.test.ts @@ -24,6 +24,15 @@ describe('PluginSystem', () => { name: 'test-plugin', version: '1.0.0', register: (reg) => { + // `RegistryPluginDefinition.register` takes `Registry | PluginScope`, + // and only the legacy `Registry` arm has `register()` — a scope + // registers through `registerComponent()`. `loadPlugin(..., false)` + // below selects the legacy arm, so narrow to it rather than assuming + // it: if scoped loading ever became the default for this call, this + // throws instead of failing somewhere further down (objectui#4040). + if (!(reg instanceof Registry)) { + throw new Error('expected the legacy Registry in useScope:false mode'); + } reg.register('test', () => 'test'); } }; diff --git a/packages/core/src/utils/__tests__/normalize-list-view.test.ts b/packages/core/src/utils/__tests__/normalize-list-view.test.ts index 3741f10035..f48da85a21 100644 --- a/packages/core/src/utils/__tests__/normalize-list-view.test.ts +++ b/packages/core/src/utils/__tests__/normalize-list-view.test.ts @@ -209,7 +209,7 @@ describe('normalizeListViewSchema (#2890)', () => { const out = normalizeListViewSchema({ viewType: 'grid', aria: { label: 'Accounts', describedBy: 'hint', live: 'polite' }, - }) as Record>; + }) as Record; expect(out.aria).toEqual({ ariaLabel: 'Accounts', ariaDescribedBy: 'hint', live: 'polite' }); }); @@ -217,7 +217,7 @@ describe('normalizeListViewSchema (#2890)', () => { const out = normalizeListViewSchema({ viewType: 'grid', aria: { ariaLabel: 'canonical', label: 'legacy', role: 'grid' }, - }) as Record>; + }) as Record; expect(out.aria).toEqual({ ariaLabel: 'canonical', role: 'grid' }); }); diff --git a/packages/core/tsconfig.test.json b/packages/core/tsconfig.test.json new file mode 100644 index 0000000000..e4c498866d --- /dev/null +++ b/packages/core/tsconfig.test.json @@ -0,0 +1,41 @@ +{ + // Type-checks this package's TESTS, which `tsconfig.json` excludes. + // See `packages/types/tsconfig.test.json` for why that exclusion was a hole: + // the build correctly keeps tests out of `dist`, but nothing else compiled + // them, so a test could assert a contract the compiler never checked. + // + // This project is now the ONLY one that compiles + // `src/utils/__tests__/dataset-result-field-spec-parity.test.ts` and + // `src/actions/__tests__/actionDef-closed-surface.test.ts`, whose compile-time + // pins are erased at runtime — vitest proves nothing about them and `tsc` is + // the only thing that can (objectui#3181). A narrow `tsconfig.typetests.json` + // next door used to name those two files while the rest of the tree was in + // TEST_DEBT; the package graduated in objectui#4040 and the narrow project was + // retired with it, after `--listFiles` showed this project reads both (2 of + // 576) and a provably-false `Assert` appended to the parity test turned this + // project red. + // + // Chained from this package's `type-check` script, which is what the CI + // `Type Check` job runs; scripts/check-type-check-coverage.mjs enforces the + // chaining — a config nothing runs is the objectui#3009 failure itself. + "extends": "../../tsconfig.json", + "compilerOptions": { + // A checking project, never an emitting one. + "noEmit": true, + // The package build emits `dist`; this project emits nothing, so it must + // not inherit `composite` / `declaration` from the build config. + "composite": false, + "declaration": false, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + // Naming `types` at all switches off automatic `@types/*` inclusion. + // `node` is required: several suites read fixtures off disk through + // `node:fs` / `node:url` / `__dirname`, and the ActionRunner suites stub + // `global.fetch`. + "types": ["node"], + // Drop the root tsconfig's source-tree `paths` so `@object-ui/*` and + // `@objectstack/spec` resolve through the workspace dependency's built + // `.d.ts` instead of pulling sibling sources in as program inputs (TS6059). + "paths": {} + }, + "include": ["src/**/*.test.ts", "src/**/*.test.tsx"] +} diff --git a/packages/core/tsconfig.typetests.json b/packages/core/tsconfig.typetests.json deleted file mode 100644 index 556620c266..0000000000 --- a/packages/core/tsconfig.typetests.json +++ /dev/null @@ -1,38 +0,0 @@ -{ - // Compiles the test file whose ENTIRE value is compile-time type assertions, - // so those assertions are actually checked by CI (objectui#3181). - // - // `src/utils/__tests__/dataset-result-field-spec-parity.test.ts` states its - // claims as TYPES — `Assert< Equal< Local, Spec > >` is a compile error or it - // is nothing. This package's own `tsconfig.json` is the BUILD config and - // excludes its test files (correctly — a test file must not emit into dist), - // and CI's only type gate drives that config, so without this project the - // pins would be the "declared != enforced" landmine objectstack#4115 exists - // to remove, sitting inside the guard for it. - // - // Chained from this package's `type-check` script, which is what the CI - // `Type Check` job runs; scripts/check-type-check-coverage.mjs enforces the - // chaining. Explicit include list, not a glob: this package is still in that - // script's TEST_DEBT, so a glob would drag in the rest of the test tree. - "extends": "../../tsconfig.json", - "compilerOptions": { - // A checking project, never an emitting one. - "noEmit": true, - "composite": false, - "declaration": false, - "lib": ["ES2020", "DOM"], - "types": ["node"], - // Drop the root tsconfig's source-tree `paths` so `@objectstack/spec` and - // the `@object-ui/*` workspace deps resolve through the real dependency - // graph rather than through sibling `src/`. - "paths": {} - }, - "include": [ - "src/utils/__tests__/dataset-result-field-spec-parity.test.ts", - // objectstack#4075 step 3 — `ActionDef` is a closed surface. Every - // assertion in this file is an `@ts-expect-error` or a bare assignment, so - // it is a compile error or it is nothing; the package's build config - // excludes `src/**` test files, which is why it has to be listed here. - "src/actions/__tests__/actionDef-closed-surface.test.ts" - ] -} diff --git a/scripts/check-type-check-coverage.mjs b/scripts/check-type-check-coverage.mjs index 7f2f3d8406..ecbd30548f 100644 --- a/scripts/check-type-check-coverage.mjs +++ b/scripts/check-type-check-coverage.mjs @@ -109,19 +109,12 @@ export const CHECKED_BY_OWN_BUILD = { // type, the dialect problem. Declare the dialect next to the vocabulary it // extends (see scripts/check-spec-symbol-derivation.mjs), don't widen the // type to silence it. +// Counts are REMEASURED, never inherited: the #2911-era sweep that seeded this +// table was unreliable in BOTH directions (i18n declared 13 and measured 103; +// react declared 27 and measured 43), so remeasure before planning against any +// number here. The tranche-4 remeasurement of `core` (56) and `app-shell` (62) +// held exactly at tranche 5, which is what a measured number is supposed to do. export const TEST_DEBT = { - // `@object-ui/core` and `@object-ui/app-shell` are partially covered already: - // a `tsconfig.typetests.json` compiles the test files whose whole value is - // compile-time type assertions (objectui#3181), listed one by one so the rest - // of the debt tree stays out. Both entries stay because that REST is still - // unchecked — each number below is that remainder, not the whole package. - // Counts REMEASURED at objectui#4040 tranche 4 against the template config - // these packages will graduate with (config-tier errors excluded). The old - // numbers were the #2911-era sweep and were unreliable in both directions — - // i18n declared 13 and measured 103, react declared 27 and measured 43 — so - // remeasure before planning against any number here. - "@object-ui/core": { errors: 56, issue: 4118, note: "TS2322x17, TS7006x10 — mostly the input-vs-output fixture confusion (was declared 72)" }, - "@object-ui/app-shell": { errors: 62, issue: 4118, note: "TS2339x8, TS2698x8, TS2739x7 — implementation wider than the type; needs lib ES2022 (was declared 53)" }, "@object-ui/plugin-dashboard": { errors: 6, issue: 4118 }, }; From b2f02b95729a6a7e99ed6765b399b930386f7992 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 11 Aug 2026 23:44:46 +0000 Subject: [PATCH 2/2] test(scripts): the narrow-project set is empty now, so pin that instead of "some remain" (#4040) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `check-type-check-coverage.test.ts` asserted `withNarrow.length > 0` under a comment saying survivors were guaranteed while #4040 burned down. Tranche 5 is where that premise expires: retiring `core`'s and `app-shell`'s narrow projects takes the last two, so the assertion is false by construction. Rewritten to the terminal statement — `expect(withNarrow.map(p => p.name)) .toEqual([])` — which is NOT vacuous in the direction that matters: a `tsconfig.typetests.json` reappearing anywhere turns it red, i.e. #4291's ratchet stated as a test and not only as a gate rule. Proved by driving `collect()` with a synthetic narrow project on `app-shell`: control `[]`, probe `["@object-ui/app-shell"]`. The sibling "holds each survivor" case is now vacuous by design, and says so; it re-arms the moment the set is non-empty again. `@object-ui/core` and `@object-ui/app-shell` join the named `retired` list, which is the case that keeps carrying real weight. Refs #4040, #4291. --- .../check-type-check-coverage.test.ts | 23 +++++++++++++++---- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/scripts/__tests__/check-type-check-coverage.test.ts b/scripts/__tests__/check-type-check-coverage.test.ts index ebb71153a5..0185ba1aac 100644 --- a/scripts/__tests__/check-type-check-coverage.test.ts +++ b/scripts/__tests__/check-type-check-coverage.test.ts @@ -500,17 +500,26 @@ describe('every surviving narrow project is a package still in debt, in this rep const packages = collect(repoRoot); const withNarrow = packages.filter((p) => p.hasTypeTestsConfig); - it('is a set this gate can see at all', () => { - // Deliberately not a count — objectui#4291 retired six and the rest follow as - // #4040 burns down. What matters is that some remain to be judged. - expect(withNarrow.length).toBeGreaterThan(0); + it('is empty — the rescue hatch has no users left', () => { + // This case used to read `expect(withNarrow.length).toBeGreaterThan(0)`, + // with a comment saying survivors were guaranteed while #4040 burned down. + // That premise EXPIRED at tranche 5: retiring `core`'s and `app-shell`'s + // narrow projects took the last two, so the old assertion is now false by + // construction and the honest statement is the terminal one. + // + // It is not vacuous in the direction that matters. A `tsconfig.typetests.json` + // reappearing ANYWHERE turns this red — which is objectui#4291's ratchet + // stated as a test rather than only as a gate rule. The next case then + // holds vacuously, correctly: there is nothing left for it to judge, and it + // is what re-arms the moment this list is non-empty again. + expect(withNarrow.map((p) => p.name)).toEqual([]); }); it('holds each survivor to being genuinely unrescued elsewhere', () => { // The invariant objectui#4291 established: a narrow project is worth keeping // only where the full test project does not already compile the same file. // Graduate a package without deleting its narrow project and this goes red, - // as does the gate itself. + // as does the gate itself. Vacuous today by design — see the case above. for (const pkg of withNarrow) { expect(testsCovered(pkg), `${pkg.name} type-checks its tests now — its narrow project is redundant`).toBe( false, @@ -536,6 +545,10 @@ describe('every surviving narrow project is a package still in debt, in this rep // package, which is what #4291's ratchet now requires. '@object-ui/components', '@object-ui/react', + // objectui#4040 tranche 5 (final) — the last two, and the reason the + // first case in this block now asserts the set is EMPTY. + '@object-ui/app-shell', + '@object-ui/core', ]; for (const name of retired) { const pkg = packages.find((p) => p.name === name);