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/__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); 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 }, };