diff --git a/.changeset/6507-boot-gate-redirect-splash.md b/.changeset/6507-boot-gate-redirect-splash.md new file mode 100644 index 000000000..a8f40444a --- /dev/null +++ b/.changeset/6507-boot-gate-redirect-splash.md @@ -0,0 +1,32 @@ +--- +'@object-ui/app-shell': patch +--- + +Keep the boot splash painted across seven more console redirects (objectui#6507) + +Every readiness gate on the console boot path renders `LoadingScreen` while it +waits and a bare `Navigate` the moment it decides. `Navigate` renders null and +react-router runs the navigation as a transition, so the destination tree +renders while the commit that already dropped the splash is what the compositor +shows — measured at 41-147 ms of empty `#root` on the three sibling gates +objectui#6506 fixed. + +Converted to `RedirectWithSplash`, which pairs the same navigation with the same +`LoadingScreen` so the handoff changes no pixels: + +- `RequireOrganization` — both decisions (orgs exist but none active; no org at + all with multi-org enabled) +- `RequireAiSurface` — a runtime that serves no agent +- `AuthenticatedRoute` — the signed-out fallback (published for consumers; + `apps/console` converted its own `ProtectedRoute` copy under objectui#6506) +- `RootRedirect` — byte-for-byte the shape that measured the widest window +- `SetupRedirect` — the `/setup` deep link +- `AppContent` — the no-accessible-app bounce, which returns above the single + `ConsoleLayout` mount + +`SystemRedirect` is deliberately left as a bare `Navigate`. It carries the same +shape on a first navigation, but it is the only site in this set that also fires +with the console already painted (`SettingsView` navigates to `/system/settings` +from a button; `AppSidebar` links to `/system`), and a redirect firing under an +already-painted layout must keep that layout rather than gain a splash. The five +URL-rewrite redirects in `AppContent` are excluded for the same reason. diff --git a/e2e/console-boot-indicator.spec.ts b/e2e/console-boot-indicator.spec.ts index 767614679..7ca2294e1 100644 --- a/e2e/console-boot-indicator.spec.ts +++ b/e2e/console-boot-indicator.spec.ts @@ -223,6 +223,45 @@ test.describe('Console boot indicator', () => { * transition, so the destination tree renders while the commit that dropped the * splash is already on screen. Measured on the production bundle: 41–147 ms of * empty `#root`. + * + * ## What this boot reaches, and what it does not (objectui#6507) + * + * The mocked boot below is SIGNED OUT, which is what makes it short enough to + * be deterministic: `/console/` takes the catch-all redirect, then the auth + * gate's redirect to `/login`. Those are two of the three sites #6506 fixed. + * + * #6507 converted seven further gates, and every one of them decides only AFTER + * a session exists — `RequireOrganization` (no active org), `RequireAiSurface` + * (a runtime serving no agent), `SetupRedirect` (the `/setup` deep link) and + * `AppContent`'s no-accessible-app bounce all sit behind `ProtectedRoute`, so a + * signed-out boot bounces to `/login` before reaching any of them. Two more — + * `RootRedirect` and `AuthenticatedRoute` — are published by + * `@object-ui/app-shell` for consumers and are not mounted by `apps/console` at + * all (it uses its own `RootLandingRedirect` and `ProtectedRoute`), so no boot + * of THIS bundle can reach them at any session state. + * + * A signed-in mock boot for the first four WAS built and run, and the result is + * the reason no per-site case was added here: those scenarios stay GREEN against + * a bundle rebuilt from ablated source — with the fix removed — and they stay + * green under 20x CPU throttling too. They do not bind to the defect, so + * committing them would have added a gate that cannot fail. + * + * The diagnosis is not a missing browser. A browser is available and this file + * runs against the production bundle; the acceptance spec passes. What is + * missing is a reproducible WINDOW: the pre-React `#boot-splash` counts as + * covering, and on those mocked boots the redirect chain resolves before the + * indicator is torn down, so at the moment the gate decides there is no blank + * for a sampler to catch. Making a per-site e2e gate that CAN fail therefore + * needs the window reproduced with the indicator already gone — not more + * endpoints, and not a browser. + * + * Until such a gate exists, those sites are pinned at the DOM level, one file + * per population, with an explicit control arm that must read "covered" so an + * "empty" reading stays falsifiable: + * `packages/app-shell/src/console/__tests__/bootRedirectCoverage.test.tsx` and + * `…/AppContent.bootRedirectCoverage.test.tsx`. Those measure the deciding + * COMMIT rather than the milliseconds, and under the ablation above they turn + * red where the e2e scenarios did not. */ interface CoverProbe { reactMountAt?: number; diff --git a/packages/app-shell/src/console/AppContent.tsx b/packages/app-shell/src/console/AppContent.tsx index 0963374bd..c5c279eb7 100644 --- a/packages/app-shell/src/console/AppContent.tsx +++ b/packages/app-shell/src/console/AppContent.tsx @@ -40,6 +40,7 @@ import { ConsoleLayout } from '../layout/ConsoleLayout.js'; import { CommandPalette } from '../chrome/CommandPalette.js'; import { ErrorBoundary } from '../chrome/ErrorBoundary.js'; import { LoadingScreen } from '../chrome/LoadingScreen.js'; +import { RedirectWithSplash } from '../chrome/RedirectWithSplash.js'; import { ObjectView } from '../views/ObjectView.js'; import { KeyboardShortcutsDialog } from '../chrome/KeyboardShortcutsDialog.js'; import { OnboardingWalkthrough } from '../chrome/OnboardingWalkthrough.js'; @@ -805,7 +806,18 @@ export function AppContent({ extraRoutes, extraRoutesNoApp }: AppContentProps = // envelope (objectstack#8013 → objectui#4252). Nothing here waits on it: the // guard reads only the per-user-filtered list that ships today. if (!activeApp && !isCreateAppRoute && !isSystemRoute && !isMetadataRoute && !isWorkspaceAdmin) { - return ; + // `RedirectWithSplash`, not a bare `` (objectui#6378 / #6507). + // Every readiness gate above this branch renders `LoadingScreen`, and the + // branch returns ABOVE the single `ConsoleLayout` mount — so a bare + // redirect, which renders null, hands the WHOLE viewport back to the page + // background while `/home` renders at transition priority. Measured on the + // three sibling gates #6506 fixed: 41-147 ms of empty `#root`. This one is + // a boot-path gate on the same evidence rule and keeps the splash painted + // across the handoff; the URL-rewrite redirects further down this file + // (`LegacyMetadataRedirect`, `ShorthandRecordRedirect`) deliberately do NOT + // convert -- they fire INSIDE `ConsoleLayout`, with the console already on + // screen, and a splash there would cover a layout that never went away. + return ; } if (!activeApp && !isCreateAppRoute && !isSystemRoute && !isMetadataRoute) return ( diff --git a/packages/app-shell/src/console/ConsoleShell.tsx b/packages/app-shell/src/console/ConsoleShell.tsx index 9b4fb7eca..a8509f201 100644 --- a/packages/app-shell/src/console/ConsoleShell.tsx +++ b/packages/app-shell/src/console/ConsoleShell.tsx @@ -36,6 +36,7 @@ import { } from '../context/UserStateAdapters.js'; import { ThemeProvider } from '../chrome/ThemeProvider.js'; import { LoadingScreen } from '../chrome/LoadingScreen.js'; +import { RedirectWithSplash } from '../chrome/RedirectWithSplash.js'; import { RemediationOverlay } from './RemediationOverlay.js'; import { HostNavigationBridge } from './HostNavigationBridge.js'; import { ImpersonationBanner } from '../layout/ImpersonationBanner.js'; @@ -348,12 +349,21 @@ export function RequireOrganization({ children }: { children: ReactNode }) { if (isOrganizationsLoading) return ; const orgList = organizations ?? []; const orgFeatureEnabled = orgList.length > 0 || !!activeOrganization; - if (orgFeatureEnabled && !activeOrganization) return ; + // `RedirectWithSplash` at every DECIDE below, not a bare `` + // (objectui#6378 / #6507): this gate renders `LoadingFallback` while it waits + // (one line up), and a bare redirect renders null — so the splash the user is + // looking at is dropped and nothing replaces it until `/organizations` + // renders at transition priority. Measured on the three sibling gates #6506 + // fixed: 41-147 ms of empty `#root`, a white flash on 67/87 boots. The + // replacement paints the SAME `LoadingScreen` this gate was already showing, + // so the handoff changes no pixels. + if (orgFeatureEnabled && !activeOrganization) + return ; // No org at all: on multi-org, send them to /organizations (the create // screen); wait for the flag so we don't flash /home then redirect. if (orgList.length === 0 && !activeOrganization) { if (multiOrgEnabled === null) return ; - if (multiOrgEnabled) return ; + if (multiOrgEnabled) return ; } return <>{children}; } @@ -379,7 +389,14 @@ export function RequireAiSurface({ }) { const { enabled, isLoading } = useAiSurfaceEnabled(); if (isLoading) return ; - if (!enabled) return ; + // Splash-preserving handoff (objectui#6507). This is a BOOT-path redirect + // even though `/ai` is reachable from inside the console: every in-app entry + // point gates on this same `useAiSurfaceEnabled` signal (`AppHeader`'s + // assistant button, `ConsoleLayout`'s dock, `HomeLayout`/`HomePage`), so on a + // runtime where this branch fires none of them is rendered. What reaches it + // is a stale bookmark or an external link — a first navigation, with the + // splash still up and no layout underneath. + if (!enabled) return ; return <>{children}; } @@ -398,7 +415,18 @@ export function AuthenticatedRoute({ loginPath?: string; }) { return ( - } loadingFallback={}> + // The same splash-preserving handoff as the gates above, written as two + // props rather than two returns (objectui#6507): `loadingFallback` paints + // while the session resolves and `fallback` is what replaces it the moment + // it decides. A bare `` there renders null, which is the same + // blank viewport #6378 measured — and `apps/console` already converted its + // own copy of this composition (`ProtectedRoute.tsx`) under #6506, so a + // consumer assembling protected routes from THIS wrapper would otherwise + // get the unfixed handoff. + } + loadingFallback={} + > {requireOrganization ? {children} : children} @@ -413,7 +441,12 @@ export function AuthenticatedRoute({ export function RootRedirect() { const { loading } = useMetadata(); if (loading) return ; - return ; + // Splash-preserving handoff (objectui#6507). `apps/console` mounts its own + // `RootLandingRedirect` rather than this one, and #6506 converted that twin + // after measuring the WIDEST window of the campaign on it (147 ms) — this is + // byte-for-byte the same shape, published to consumers via + // `@object-ui/app-shell`. + return ; } /** @@ -427,6 +460,18 @@ export function RootRedirect() { * that makes the hub mount at all. */ export function SystemRedirect() { + // ⚠️ DELIBERATELY a bare ``, unlike every other redirect in this + // file (objectui#6507). It does carry the null-render shape on a first + // navigation — but it is the one site here that ALSO fires with the console + // already painted: `SettingsView.tsx` navigates to `/system/settings` from a + // button, and `AppSidebar.tsx` links to `/system`. Neither is gated on + // anything, so both are live in exactly the runtimes this component serves. + // The #6507 triage ruling is explicit that a redirect firing under an + // already-painted layout must KEEP that layout rather than gain a splash, so + // converting this one would trade a boot-path blank for a full-screen splash + // flashing over a working console. Splitting the two paths (deep link vs + // in-app navigation) needs a measurement neither #6378 nor #6507 has taken. + // Pinned as unconverted by `__tests__/bootRedirectCoverage.test.tsx`. const location = useLocation(); const suffix = location.pathname.replace(/^\/system/, ''); const target = suffix ? `/apps/setup/system${suffix}` : '/apps/setup/system'; @@ -512,5 +557,11 @@ export function SetupRedirect() { const location = useLocation(); if (loading) return ; const target = resolveSetupAppPath(apps as SetupAppLike[] | undefined); - return ; + // Splash-preserving handoff (objectui#6507) — the gate one line up renders + // `LoadingFallback`, so a bare redirect would drop it for nothing. Unlike + // `SystemRedirect` beside it, `/setup` has no in-app producer: it is mounted + // as a route and reached by bookmark, deep link or runbook, i.e. always as a + // first navigation with the splash up. (The home launcher's card links to + // `/apps/` directly, not through this alias.) + return ; } diff --git a/packages/app-shell/src/console/__tests__/AppContent.bootRedirectCoverage.test.tsx b/packages/app-shell/src/console/__tests__/AppContent.bootRedirectCoverage.test.tsx new file mode 100644 index 000000000..9d0716513 --- /dev/null +++ b/packages/app-shell/src/console/__tests__/AppContent.bootRedirectCoverage.test.tsx @@ -0,0 +1,232 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectui#6507 — the `AppContent` boot bounce, and the control that keeps the + * conversion honest. + * + * ## The two arms + * + * `bootRedirectCoverage.test.tsx` states the probe in full: hold the + * destination un-painted, then ask whether the app tree holds anything for the + * viewport to show at the commit where the gate DECIDES. This file runs that + * same probe on the two populations that only exist inside `AppContent`: + * + * SUBJECT — the no-accessible-app bounce (`AppContent.tsx`, the + * `!activeApp && … && !isWorkspaceAdmin` branch). Every readiness gate above + * it renders `LoadingScreen`, and the branch returns ABOVE the single + * `ConsoleLayout` mount, so what it hands off to is the whole viewport. + * + * CONTROL — `LegacyMetadataRedirect`, one of the five URL-rewrite redirects + * the #6507 triage ruling names as OUT of shape. It is declared INSIDE + * `ConsoleLayout`, so it fires with the console already painted. It must read + * "covered" with no change to this codebase, and it must keep reading + * "covered" after the subject converts — converting it would hand it a splash + * it never had, which is the failure mode the ruling bans. + * + * The control is not decoration. Without it, "the tree was empty" is a claim + * the probe could make about every redirect in the repository; with it, the + * probe is shown to distinguish the two populations the ruling turns on. + */ + +import '@testing-library/jest-dom/vitest'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import React, { useLayoutEffect } from 'react'; +import { render, screen, waitFor } from '@testing-library/react'; +import { MemoryRouter, Routes, Route, useLocation } from 'react-router-dom'; + +vi.mock('@object-ui/plugin-designer', () => ({ + CreateAppPage: () =>
create app
, + EditAppPage: () =>
, + DashboardDesignPage: () =>
, +})); + +/** + * The real layout, reduced to the fact under test: it PAINTS. Whatever renders + * inside it is renders with the console already on screen. + */ +vi.mock('../../layout/ConsoleLayout', () => ({ + ConsoleLayout: ({ activeAppName, children }: { activeAppName?: string; children?: React.ReactNode }) => ( +
+
chrome
+ {children} +
+ ), +})); +vi.mock('../../chrome/CommandPalette', () => ({ CommandPalette: () => null })); +vi.mock('../../chrome/KeyboardShortcutsDialog', () => ({ KeyboardShortcutsDialog: () => null })); +vi.mock('../../chrome/OnboardingWalkthrough', () => ({ OnboardingWalkthrough: () => null })); +vi.mock('../../views/ObjectView', () => ({ + ObjectView: () =>
, +})); + +vi.mock('@object-ui/i18n', async (importOriginal) => ({ + ...(await importOriginal>()), + useObjectTranslation: () => ({ + t: (key: string, options?: Record) => String(options?.defaultValue ?? key), + }), + useObjectLabel: () => ({ + objectLabel: ({ label }: { label?: string }) => label, + }), +})); + +let isWorkspaceAdmin = false; +vi.mock('@object-ui/auth', async (importOriginal) => ({ + ...(await importOriginal>()), + useAuth: () => ({ + user: { id: 'u_b', name: 'B', email: 'b@example.com', role: 'member' }, + getAuthConfig: async () => ({ features: {} }), + activeOrganization: { id: 'org_jia', name: '甲' }, + }), + useWorkspaceAdminStatus: () => ({ isAdmin: isWorkspaceAdmin, isResolved: true }), +})); + +const dataSourceStub = { + onConnectionStateChange: () => () => {}, + getConnectionState: () => 'connected', +}; +vi.mock('../../providers/AdapterProvider', async (importOriginal) => ({ + ...(await importOriginal>()), + useAdapter: () => dataSourceStub, +})); + +/** As the SERVER hands it to this session — empty is the no-accessible-app case. */ +let metadataApps: unknown[] = []; +vi.mock('../../providers/MetadataProvider', async (importOriginal) => ({ + ...(await importOriginal>()), + useMetadata: () => ({ + apps: metadataApps, + objects: [], + loading: false, + ensureType: undefined, + error: null, + refresh: vi.fn(async () => {}), + }), +})); + +const actionRunnerStub = { registerHandler: vi.fn(), getContext: () => ({}) }; +vi.mock('@object-ui/react', async (importOriginal) => ({ + ...(await importOriginal>()), + useActionRunner: () => ({ execute: vi.fn(), runner: actionRunnerStub }), + useGlobalUndo: () => {}, + useMutationInvalidationBridge: () => {}, +})); + +import { AppContent } from '../AppContent'; + +const APP_ROOT = 'app-root'; + +/** + * One sample per React commit: what the app tree held, and where the router + * was when it held it. Same record shape as the e2e probe's `uncovered` + * ledger (`{ t, centre, path }`), which is what this stands in for. + */ +interface Sample { + empty: boolean; + path: string; +} +let samples: Sample[] = []; + +/** + * Records every commit from INSIDE the app root, rendering nothing itself. + * + * `useLayoutEffect` with no dependency array, so it runs after EVERY commit and + * — this is the load-bearing part — before ``'s passive effect fires. + * That is what lets the deciding commit be read while the router is still on + * the app URL, which is the window the defect lives in. A read taken after the + * route change would see the destination and report the same thing for a fixed + * build as for a broken one. + */ +function CommitRecorder() { + const location = useLocation(); + useLayoutEffect(() => { + const root = document.querySelector(`[data-testid="${APP_ROOT}"]`); + samples.push({ + empty: (root?.innerHTML ?? '').trim() === '', + path: location.pathname, + }); + }); + return null; +} + +/** + * Renders the console at `initialUrl` and returns every commit it produced. + * The destination is UN-PAINTED (renders null), which is what "the destination + * renders at transition priority" reduces to in a DOM-only environment. + */ +async function commitsAt(initialUrl: string) { + samples = []; + render( + +
+ + + } /> + + + +
+
, + ); + await waitFor(() => { + expect(screen.getByTestId(APP_ROOT)).toBeTruthy(); + }); + return samples; +} + +/** The commits taken while the router was still on the app URL — the window. */ +const onAppUrl = (all: Sample[]) => all.filter((sample) => sample.path.startsWith('/apps/')); + +beforeEach(() => { + vi.clearAllMocks(); + isWorkspaceAdmin = false; + metadataApps = []; +}); + +describe('boot-gate coverage — AppContent (objectui#6507)', () => { + it('the no-accessible-app bounce keeps the viewport covered', async () => { + const window0 = onAppUrl(await commitsAt('/apps/setup')); + + // Guard against a vacuous pass: if the branch never ran there is nothing to + // measure and "no empty commit" would be true for the wrong reason. + expect(window0.length, 'the bounce branch never rendered').toBeGreaterThan(0); + + const blanks = window0.filter((sample) => sample.empty); + expect( + blanks.length, + `AppContent no-accessible-app bounce: ${blanks.length} of ${window0.length} ` + + `commit(s) on the app URL held an EMPTY tree. Every readiness gate above ` + + `this branch renders LoadingScreen and the branch returns above the ` + + `ConsoleLayout mount, so a bare hands the whole viewport back ` + + `to the page background while /home renders at transition priority — ` + + `objectui#6378's white flash.`, + ).toBe(0); + }); +}); + +describe('boot-gate coverage — AppContent control arm (out-of-shape redirects)', () => { + /** + * `LegacyMetadataRedirect` — declared INSIDE `ConsoleLayout`, so the console + * is already painted when it rewrites the URL. It must read "covered" both + * before and after the subject above converts, and it must be covered by the + * LAYOUT rather than by a splash. A red here means the conversion leaked into + * the URL-rewrite population the #6507 triage ruling excluded by name. + * + * This is what makes the subject's reading a measurement: the same probe, + * run on a redirect that fires under a painted layout, comes out the other way. + */ + it('LegacyMetadataRedirect fires under a painted layout — no splash needed, none added', async () => { + metadataApps = [{ name: 'crm', _packageId: 'com.example.crm' }]; + const all = await commitsAt('/apps/crm/component/metadata/directory'); + + expect(all.length, 'nothing rendered').toBeGreaterThan(0); + expect( + all.filter((sample) => sample.empty).length, + 'an out-of-shape redirect is covered by the layout it never left', + ).toBe(0); + expect(screen.getByTestId('console-layout')).toBeInTheDocument(); + expect( + screen.getByTestId(APP_ROOT).querySelector('div.h-screen.bg-background'), + 'an out-of-shape redirect must NOT have gained a splash', + ).toBeNull(); + }); +}); diff --git a/packages/app-shell/src/console/__tests__/bootRedirectCoverage.test.tsx b/packages/app-shell/src/console/__tests__/bootRedirectCoverage.test.tsx new file mode 100644 index 000000000..a129411ab --- /dev/null +++ b/packages/app-shell/src/console/__tests__/bootRedirectCoverage.test.tsx @@ -0,0 +1,305 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectui#6507 — the remaining console boot gates must not hand the viewport + * to an empty document. + * + * ## What this file measures, and what it cannot + * + * The defect established by objectui#6378 is a TIMING one: a gate renders + * `LoadingFallback` while it WAITS and a bare `` the moment it + * DECIDES. `` renders null and react-router runs the navigation as a + * transition, so the destination tree renders at transition priority while the + * commit that already dropped the splash is what the compositor shows — + * measured at 41–147 ms of empty `#root` on the three sites that card fixed. + * + * happy-dom has no compositor, no CSS engine and no transition scheduler, so + * **nothing here measures that window in milliseconds**. The pixel/frame ledger + * needs a real browser and is recorded on the pull request; the end-to-end + * invariant lives in `e2e/console-boot-indicator.spec.ts`. + * + * What IS measurable here is the half that decides WHICH sites may convert. + * The triage ruling on #6507 is explicit that converting on sight is the + * failure mode: "a redirect firing under an already-painted layout keeps that + * layout rather than gaining a splash". So the question each case below asks is + * not "does this redirect work" but: + * + * > At the commit where this gate DECIDES, with the destination still + * > un-painted, does the app tree hold anything for the viewport to show? + * + * That is the same question `e2e/console-boot-indicator.spec.ts` asks with a + * hit test at the viewport centre (`el.closest('#root')`), reduced to what a + * DOM-only environment can answer honestly. + * + * ## Why nothing here renders into a `` sink + * + * The window is the gate's OWN deciding commit, not the route change that + * follows it. In order: + * + * 1. the gate decides and commits — it returns a redirect element INSTEAD of + * the `LoadingFallback` it was rendering, so the splash is dropped HERE; + * 2. ``'s effect fires and react-router starts the navigation as a + * transition; + * 3. the destination tree renders, at transition priority. + * + * The blank viewport is step 1, and it lasts until step 3 — which is why a real + * browser shows it for 41–147 ms. happy-dom has no transition scheduler, so + * step 3 lands synchronously and any read taken after it sees the destination, + * not the window. Reading there would report "empty" for a CORRECT fix as + * loudly as for a broken one, i.e. it would measure nothing. + * + * So each case below mounts the gate OUTSIDE a `` sink: the element + * stays mounted after it navigates, and what is read is what it RENDERS at + * step 1 — the frames the compositor is actually showing during the window. + * (`chrome/RedirectWithSplash.test.tsx` states the same reason for the same + * reduction.) + * + * ## Why the control arm is load-bearing + * + * A probe that can only ever report "empty" would prove nothing — it would + * convict every redirect in the codebase, including the five URL-rewrite + * redirects in `AppContent.tsx` that must NOT be converted. So the same probe + * is run against gates in their PASS-THROUGH state, where a real view is + * mounted. Those cases must read "covered". They are what makes an "empty" + * reading a measurement rather than a foregone conclusion. + * + * The out-of-shape redirects under a mounted `ConsoleLayout` are controlled + * separately, where that layout actually exists — + * `AppContent.bootRedirectCoverage.test.tsx`. + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/react'; +import { MemoryRouter, Routes, Route } from 'react-router-dom'; +import type { ReactNode } from 'react'; + +// Imported at module scope, not inside a test: `@object-ui/components` is a +// heavy barrel and resolving it mid-assertion would race the RTL timeouts +// (AGENTS.md § 测试纪律). +import '@object-ui/components'; + +/** The AI-surface signal. Its own plumbing is covered by useAiSurface.test.ts. */ +let aiSurface = { enabled: true, isLoading: false }; +vi.mock('../../hooks/useAiSurface', () => ({ + useAiSurfaceEnabled: () => aiSurface, +})); + +/** + * The session. `AuthGuard` itself is deliberately NOT stubbed — the shape under + * test at `AuthenticatedRoute` is what the REAL guard renders when it decides, + * and a stubbed guard would be asserting the stub. + */ +let auth: Record = {}; +vi.mock('@object-ui/auth', async (importOriginal) => ({ + ...(await importOriginal>()), + useAuth: () => auth, +})); + +let metadata: Record = { apps: [], loading: false }; +vi.mock('../../providers/MetadataProvider', async (importOriginal) => ({ + ...(await importOriginal>()), + useMetadata: () => metadata, +})); + +import { + RequireOrganization, + RequireAiSurface, + AuthenticatedRoute, + RootRedirect, + SystemRedirect, + SetupRedirect, +} from '../ConsoleShell'; + +/** + * Stands in for `#root` — the element the e2e probe's hit test resolves + * through. Anything the router renders lands inside it; nothing else does. + */ +const APP_ROOT = 'app-root'; + +/** Renders `ui` under a router and reports what the viewport would hold. */ +async function coverageAt(initialPath: string, ui: ReactNode) { + render( + +
{ui}
+
, + ); + // The gates that decide from an effect (`RequireOrganization` awaits + // `getAuthConfig`) need their decision to have LANDED before the tree is + // read; otherwise this measures the waiting state, which is covered by + // construction and would report a false "covered". + await waitFor(() => { + expect(screen.getByTestId(APP_ROOT)).toBeTruthy(); + }); + const root = screen.getByTestId(APP_ROOT); + return { + covered: root.innerHTML.trim() !== '', + html: root.innerHTML, + }; +} + +/** + * The invariant, phrased so a failure prints the reading that produced it. + * `LoadingScreen` renders a full-viewport root (`div.h-screen.bg-background`), + * which is what "covered" has to mean — a stray text node would not do. + */ +function expectCovered(reading: { covered: boolean; html: string }, site: string) { + expect( + reading.covered, + `${site}: at the deciding commit the app tree was EMPTY, so the viewport is ` + + `the bare page background for the whole transition — objectui#6378's white ` + + `flash. Hand off with RedirectWithSplash, not a bare .`, + ).toBe(true); + expect( + screen.getByTestId(APP_ROOT).querySelector('div.h-screen.bg-background'), + `${site}: something rendered, but not the full-viewport splash — the page ` + + `background still shows around it.`, + ).toBeTruthy(); +} + +beforeEach(() => { + aiSurface = { enabled: true, isLoading: false }; + metadata = { apps: [], loading: false }; + auth = { + isAuthenticated: true, + isLoading: false, + user: { id: 'u_1', role: 'admin' }, + organizations: [], + activeOrganization: { id: 'org_1' }, + isOrganizationsLoading: false, + getAuthConfig: async () => ({ features: { multiOrgEnabled: true } }), + }; +}); + +describe('boot-gate coverage — ConsoleShell (objectui#6507)', () => { + it('RequireOrganization :351 — orgs exist but none is active', async () => { + auth = { ...auth, organizations: [{ id: 'org_1' }], activeOrganization: null }; + const reading = await coverageAt( + '/home', + +
APP
+
, + ); + expectCovered(reading, 'RequireOrganization (org exists, none active)'); + }); + + it('RequireOrganization :356 — no org at all, multi-org enabled', async () => { + auth = { ...auth, organizations: [], activeOrganization: null }; + const reading = await coverageAt( + '/home', + +
APP
+
, + ); + expectCovered(reading, 'RequireOrganization (no org, multi-org on)'); + }); + + it('RequireAiSurface :382 — the runtime serves no agent', async () => { + aiSurface = { enabled: false, isLoading: false }; + const reading = await coverageAt( + '/ai', + +
AI
+
, + ); + expectCovered(reading, 'RequireAiSurface'); + }); + + it('AuthenticatedRoute :401 — the session resolves to signed-out', async () => { + auth = { ...auth, isAuthenticated: false, user: null }; + const reading = await coverageAt( + '/apps/crm', + +
APP
+
, + ); + expectCovered(reading, 'AuthenticatedRoute'); + }); + + it('RootRedirect :416 — metadata has settled', async () => { + metadata = { apps: [], loading: false }; + const reading = await coverageAt('/', ); + expectCovered(reading, 'RootRedirect'); + }); + + it('SetupRedirect :515 — the /setup deep link resolves', async () => { + metadata = { apps: [{ name: 'setup', _packageId: 'com.objectstack.setup' }], loading: false }; + const reading = await coverageAt('/setup', ); + expectCovered(reading, 'SetupRedirect'); + }); +}); + +/** + * CONTROL ARM — the same probe, on states that must read "covered" without any + * change to this codebase. If these ever go red the probe has stopped + * discriminating and every "empty" reading above is worthless. + */ +describe('boot-gate coverage — control arm (the probe can report "covered")', () => { + it('RequireOrganization passes through when an org is active', async () => { + const reading = await coverageAt( + '/home', + +
APP
+
, + ); + expect(reading.covered, 'a pass-through gate renders its children').toBe(true); + expect(screen.getByText('APP')).toBeTruthy(); + }); + + it('RequireAiSurface passes through when the runtime serves agents', async () => { + aiSurface = { enabled: true, isLoading: false }; + const reading = await coverageAt( + '/ai', + +
AI
+
, + ); + expect(reading.covered).toBe(true); + expect(screen.getByText('AI')).toBeTruthy(); + }); + + it('a waiting gate is covered by its own LoadingFallback', async () => { + aiSurface = { enabled: false, isLoading: true }; + const reading = await coverageAt( + '/ai', + +
AI
+
, + ); + // The WAITING state was never the defect — this pins the baseline the + // deciding state has to match. + expect(reading.covered).toBe(true); + expect( + screen.getByTestId(APP_ROOT).querySelector('div.h-screen.bg-background'), + ).toBeTruthy(); + }); + + /** + * `SystemRedirect` is DELIBERATELY absent from the conversion set and is + * pinned here as a bare redirect. It carries the card's shape on a first + * navigation, but unlike every gate above it is also reached from INSIDE a + * painted console — `SettingsView.tsx` navigates to `/system/settings` from a + * button, and `AppSidebar.tsx` links to `/system` — and on those paths a + * splash would cover a layout that is already on screen. That is exactly the + * regression the #6507 triage ruling bans, so this site keeps its bare + * `` until someone can measure the two paths apart. + */ + it('SystemRedirect :433 stays a bare redirect — it also fires under a painted layout', async () => { + // NOTE the `` sink here, which the cases above deliberately avoid. + // `SystemRedirect` derives its target from `location.pathname`, so mounted + // outside a sink it would re-navigate on every render, each hop appending + // its own suffix (`/apps/setup/system/apps/setup/system/…`) — an infinite + // loop, not a measurement. The sink costs nothing here because this case + // asserts the ABSENCE of a splash, which the route change cannot manufacture. + const reading = await coverageAt( + '/system/settings', + + } /> + + , + ); + expect( + reading.covered, + 'SystemRedirect is intentionally NOT converted (see the comment above this test)', + ).toBe(false); + }); +});