Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .changeset/core-app-shell-type-check-their-tests.md
Original file line number Diff line number Diff line change
@@ -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.
3 changes: 1 addition & 2 deletions packages/app-shell/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
12 changes: 11 additions & 1 deletion packages/app-shell/src/console/ai/AiChatPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,17 @@ import {
buildProgressFromDraftReview,
type AgentDescriptor,
type ChatbotEnhancedToolInvocation,
type ChatMessage,
// The ENHANCED message shape — the one `<ChatbotEnhanced>` 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';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof deriveBoundPackageId>[0][number];
type PackageBearingTool = NonNullable<PackageBearingMessage['toolInvocations']>[number];

const msg = (toolInvocations: readonly PackageBearingTool[]): PackageBearingMessage => ({ toolInvocations });

describe('deriveBoundPackageId', () => {
it('prefers the explicit editPackageId (Edit-with-AI) over anything in messages', () => {
Expand All @@ -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',
);
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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 };
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
<I18nProvider config={{ defaultLanguage: 'en', detectBrowserLanguage: false }}>
<ActionProvider context={{ user: { id: 'u1' } } as any} handlers={{ api: execute as any }}>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
toUIMessages,
useChatConversation,
writeConversationMessagesCache,
type HydratedUIMessage,
} from '../useChatConversation';

const API_BASE = 'http://ai.test/api/v1/ai';
Expand Down Expand Up @@ -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(() =>
Expand Down
45 changes: 33 additions & 12 deletions packages/app-shell/src/hooks/useConsoleActionRuntime.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -79,7 +79,12 @@ export interface ConsoleActionRuntime {
navigateHandler: NavigationHandler;
paramCollectionHandler: ParamCollectionHandler;
resultDialogHandler: ResultDialogHandler;
apiHandler: (action: ActionDef) => Promise<ActionResult>;
// Two parameters, like its three siblings below — the implementation has
// always been `(action, context?)` (it reads `context.pageVariables` to
// resolve `{{page.<var>}}` 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<ActionResult>;
flowHandler: (action: ActionDef, context?: ActionContext) => Promise<ActionResult>;
serverActionHandler: (action: ActionDef, context?: ActionContext) => Promise<ActionResult>;
/** `type: 'modal'` — opens `target` as a page/object form, else runs the action server-side. */
Expand All @@ -88,16 +93,32 @@ export interface ConsoleActionRuntime {
authFetch: ReturnType<typeof createAuthenticatedFetch>;
/** Open the shared environment entitlement (upgrade / limit) dialog. */
openEntitlementDialog: (spec: EntitlementDialogSpec) => void;
/** Props to spread onto `<ActionProvider>`. */
actionProviderProps: {
context: Record<string, any>;
onConfirm: ConfirmationHandler;
onToast: ToastHandler;
onNavigate: NavigationHandler;
onParamCollection: ParamCollectionHandler;
onResultDialog: ResultDialogHandler;
handlers: Record<string, (action: ActionDef) => Promise<ActionResult>>;
};
/**
* Props to spread onto `<ActionProvider>`.
*
* 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 `<ActionProvider>` 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;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
14 changes: 12 additions & 2 deletions packages/app-shell/src/providers/writeWarningToast.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,14 +41,24 @@ const t = (_key: string, opts?: Record<string, unknown>) => {
/** 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[<apiKey>].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<string, { label: string }> };

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<ObjectSchemaShape> => ANDON_SCHEMA) };

const EVENT: WriteWarningEvent = {
operation: 'update',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
7 changes: 5 additions & 2 deletions packages/app-shell/src/utils/decisionOutputParams.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand Down Expand Up @@ -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');
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<Record<string, unknown> | string>,
options: Array<ActionParamOption | string>,
): ResolveActionParamsContext => ({
objectName: 'account',
objects: [{ name: 'account', fields: { tier: { type: 'select', label: 'Tier', options } } }],
Expand All @@ -63,7 +73,7 @@ const ROLE_GATED = [
* the widget as `field` with `id={param.name}`.
*/
function renderInheritedSelect(
options: Array<Record<string, unknown> | string>,
options: Array<ActionParamOption | string>,
positions: string[],
value?: string,
) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>) {
Expand Down
Loading
Loading