mirror of
https://github.com/nexu-io/open-design.git
synced 2026-09-20 06:15:06 +08:00
548 lines
22 KiB
TypeScript
548 lines
22 KiB
TypeScript
/**
|
|
* The packaged Windows smoke's app-shell probe and the rule that reads it.
|
|
*
|
|
* `specs/win.spec.ts` only executes on a Windows runner that has a packaged
|
|
* build installed, and the probe below reaches the packaged renderer as a
|
|
* plain string through `tools-pack inspect --expr` — so neither the compiler
|
|
* nor any cross-platform suite ever sees it. Keeping the probe text and the
|
|
* terminal-state rule that consumes its result in one pure module lets
|
|
* `tests/packaged/app-shell.test.ts` hold both to a contract from any platform.
|
|
*/
|
|
|
|
/**
|
|
* Runs inside the packaged renderer. Written as a two-argument arrow function
|
|
* rather than an IIFE so the exact same text can be evaluated in Node against a
|
|
* fixture document (see `evaluatePackagedAppShellProbe`); in the renderer both
|
|
* arguments are the ordinary globals.
|
|
*/
|
|
const PACKAGED_APP_SHELL_PROBE = `
|
|
(doc, ElementCtor) => {
|
|
const home = doc.querySelector('[data-testid="entry-nav-home"]');
|
|
const onboardingShell = doc.querySelector('.entry-shell--onboarding, .entry-onboarding-modal');
|
|
const cloudSignIn = doc.querySelector('.onboarding-cloud__primary');
|
|
return {
|
|
cloudSignInVisible: cloudSignIn instanceof ElementCtor,
|
|
homeVisible: home instanceof ElementCtor && home.getClientRects().length > 0,
|
|
onboardingVisible: onboardingShell instanceof ElementCtor,
|
|
text: doc.body?.textContent?.trim().slice(0, 300) ?? '',
|
|
title: doc.title,
|
|
};
|
|
}
|
|
`;
|
|
|
|
export const packagedAppShellExpression = `(${PACKAGED_APP_SHELL_PROBE})(document, HTMLElement)`;
|
|
|
|
export type PackagedAppShellSnapshot = {
|
|
cloudSignInVisible: boolean;
|
|
homeVisible: boolean;
|
|
onboardingVisible: boolean;
|
|
text: string;
|
|
title: string;
|
|
};
|
|
|
|
export type PackagedAppShellProbeElement = {
|
|
getClientRects(): ArrayLike<unknown>;
|
|
};
|
|
|
|
export type PackagedAppShellProbeDocument = {
|
|
body: { textContent: string | null } | null;
|
|
querySelector(selectors: string): PackagedAppShellProbeElement | null;
|
|
querySelectorAll(selectors: string): Iterable<PackagedAppShellProbeElement>;
|
|
title: string;
|
|
};
|
|
|
|
/**
|
|
* Evaluates the shipped probe text against a fixture document.
|
|
*
|
|
* The probe is a string, so nothing in the normal build checks it. Running the
|
|
* identical text here is the only way a non-Windows machine can prove that the
|
|
* selectors, the `getClientRects` visibility rule, and the reported field set
|
|
* still behave as the smoke expects.
|
|
*/
|
|
export function evaluatePackagedAppShellProbe(
|
|
document: PackagedAppShellProbeDocument,
|
|
elementConstructor: new (...args: never[]) => PackagedAppShellProbeElement,
|
|
): unknown {
|
|
// Parenthesized deliberately: the probe text opens with a newline, and a bare
|
|
// `return` followed by one gets an automatic semicolon.
|
|
const probe = new Function(`return (${PACKAGED_APP_SHELL_PROBE});`)() as (
|
|
document: PackagedAppShellProbeDocument,
|
|
elementConstructor: unknown,
|
|
) => unknown;
|
|
return probe(document, elementConstructor);
|
|
}
|
|
|
|
export function asPackagedAppShellSnapshot(value: unknown): PackagedAppShellSnapshot | null {
|
|
if (typeof value !== 'object' || value == null || Array.isArray(value)) return null;
|
|
const candidate = value as Partial<PackagedAppShellSnapshot>;
|
|
if (
|
|
typeof candidate.cloudSignInVisible !== 'boolean' ||
|
|
typeof candidate.homeVisible !== 'boolean' ||
|
|
typeof candidate.onboardingVisible !== 'boolean' ||
|
|
typeof candidate.text !== 'string' ||
|
|
typeof candidate.title !== 'string'
|
|
) {
|
|
return null;
|
|
}
|
|
return candidate as PackagedAppShellSnapshot;
|
|
}
|
|
|
|
/**
|
|
* A surface the packaged app can legitimately come to rest on.
|
|
*
|
|
* `home` is the signed-in/seeded main shell. `onboarding-landing` is the cloud
|
|
* sign-in landing a first run stops at.
|
|
*/
|
|
export type PackagedAppShellState = 'home' | 'onboarding-landing';
|
|
|
|
/**
|
|
* Whether a health/readiness observation still belongs to the packaged
|
|
* renderer. Route ownership is the health boundary; the app-shell probe below
|
|
* decides whether a particular route rendered an allowed product surface.
|
|
*/
|
|
export function packagedAppRouteUrl(value: unknown): boolean {
|
|
if (typeof value !== 'string') return false;
|
|
try {
|
|
const url = new URL(value);
|
|
return url.protocol === 'od:' && url.hostname === 'app';
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Which settled surface the renderer is showing, or `null` while it is showing
|
|
* neither — a blank window, a crashed renderer, a boot still on the loader, or
|
|
* a half-rendered onboarding shell all fall through to `null`.
|
|
*
|
|
* The landing is recognised positively from the identity gate's sign-in CTA.
|
|
* Local and BYOK are intentionally unavailable until identity completes, so a
|
|
* bare `onboardingVisible` would degrade this into "anything that is not home"
|
|
* and stop failing on a renderer that mounted the shell and then died.
|
|
*/
|
|
export function packagedAppShellState(value: unknown): PackagedAppShellState | null {
|
|
const snapshot = asPackagedAppShellSnapshot(value);
|
|
if (snapshot == null) return null;
|
|
if (snapshot.homeVisible) return 'home';
|
|
if (snapshot.onboardingVisible && snapshot.cloudSignInVisible) {
|
|
return 'onboarding-landing';
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Reads the daemon's own onboarding-completion fact inside the packaged
|
|
* renderer. `GET /api/app-config` serves `readAppConfig(RUNTIME_DATA_DIR)`, so
|
|
* this reports what the running daemon resolved.
|
|
*
|
|
* `fetch` is taken as an argument (and wrapped at the call site, since an
|
|
* unbound `fetch` throws in a browser) so the same text can be driven against a
|
|
* fake in Node.
|
|
*/
|
|
const PACKAGED_ONBOARDING_CONFIG_PROBE = `
|
|
(async (fetchImpl) => {
|
|
try {
|
|
const response = await fetchImpl('/api/app-config');
|
|
const status = typeof response.status === 'number' ? response.status : null;
|
|
if (!response.ok) return { error: 'daemon returned HTTP ' + status, kind: 'http-error', ok: false, status };
|
|
const body = await response.json();
|
|
const config = body == null ? null : body.config;
|
|
if (config == null || typeof config !== 'object' || Array.isArray(config)) {
|
|
return { error: 'daemon response carried no config object', kind: 'no-config', ok: false, status };
|
|
}
|
|
// Absent is not malformed. A daemon whose app-config.json does not exist
|
|
// returns {} plus telemetry defaults, so the key is simply missing — a
|
|
// legitimate reading of a fresh install. A key present with the wrong type
|
|
// is corruption. They get different outcomes because different scenarios
|
|
// may accept them.
|
|
if (!('onboardingCompleted' in config)) {
|
|
return { kind: 'absent', ok: false, status };
|
|
}
|
|
// Require the type before reading it. Coercing here (\`x === true\`) would
|
|
// manufacture a boolean out of a null or the string "false" — and the
|
|
// manufactured value is \`false\`, which is exactly the reading that
|
|
// permits the onboarding landing.
|
|
if (typeof config.onboardingCompleted !== 'boolean') {
|
|
return {
|
|
error: 'daemon config carried a non-boolean onboardingCompleted (got '
|
|
+ (config.onboardingCompleted === null ? 'null' : typeof config.onboardingCompleted)
|
|
+ ')',
|
|
kind: 'malformed',
|
|
ok: false,
|
|
status,
|
|
};
|
|
}
|
|
return { kind: 'reading', ok: true, onboardingCompleted: config.onboardingCompleted, status };
|
|
} catch (error) {
|
|
return {
|
|
error: error instanceof Error ? error.message : String(error),
|
|
kind: 'transport-failure',
|
|
ok: false,
|
|
status: null,
|
|
};
|
|
}
|
|
})
|
|
`;
|
|
|
|
export const packagedOnboardingConfigExpression = `(${PACKAGED_ONBOARDING_CONFIG_PROBE})((input) => fetch(input))`;
|
|
|
|
export type PackagedOnboardingConfigFetch = (input: string) => Promise<{
|
|
json(): Promise<unknown>;
|
|
ok: boolean;
|
|
status: number;
|
|
}>;
|
|
|
|
export function evaluatePackagedOnboardingConfigProbe(
|
|
fetchImpl: PackagedOnboardingConfigFetch,
|
|
): Promise<unknown> {
|
|
const probe = new Function(`return (${PACKAGED_ONBOARDING_CONFIG_PROBE});`)() as (
|
|
fetchImpl: PackagedOnboardingConfigFetch,
|
|
) => Promise<unknown>;
|
|
return probe(fetchImpl);
|
|
}
|
|
|
|
/**
|
|
* Raised when the daemon's onboarding-completion fact could not be established.
|
|
*
|
|
* Its own type so a caller can never mistake "we could not find out" for a
|
|
* `false` reading.
|
|
*/
|
|
export class PackagedOnboardingConfigError extends Error {
|
|
constructor(reason: string) {
|
|
super(`packaged windows daemon onboarding config could not be established: ${reason}`);
|
|
this.name = 'PackagedOnboardingConfigError';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The daemon's `onboardingCompleted`, or an error.
|
|
*
|
|
* Never returns a default. An unestablished fact must not become a permission.
|
|
*/
|
|
/**
|
|
* The complete set of distinct outcomes this probe can produce, and which
|
|
* scenario may accept each. Seven review rounds on #6481 each found a defect of
|
|
* one family: a *fault* and an *absence* sharing a representation. This table is
|
|
* the fix for the family rather than for any one instance.
|
|
*
|
|
* | outcome | meaning | first-run | completed-user |
|
|
* |--------------------|--------------------------------------------|-----------|----------------|
|
|
* | `transport-failure`| fetch rejected; daemon unreachable | reject | reject |
|
|
* | `http-error` | non-2xx from `/api/app-config` | reject | reject |
|
|
* | `no-config` | 200 whose body carried no `config` object | reject | reject |
|
|
* | `absent` | 200, `config` present, key never written | **accept**| reject |
|
|
* | `malformed` | key present but not a boolean | reject | reject |
|
|
* | `reading` | key present and boolean | accept | accept if true |
|
|
*
|
|
* `absent` is the only asymmetric row, and it is asymmetric for a reason: a key
|
|
* that was never written is the honest state of a fresh install, whereas in a
|
|
* run that seeded completion its disappearance means the seed vanished.
|
|
*/
|
|
export type PackagedOnboardingConfigOutcomeKind =
|
|
| 'absent'
|
|
| 'http-error'
|
|
| 'malformed'
|
|
| 'no-config'
|
|
| 'reading'
|
|
| 'transport-failure';
|
|
|
|
export type PackagedOnboardingConfigOutcome = {
|
|
readonly error: string | null;
|
|
readonly kind: PackagedOnboardingConfigOutcomeKind;
|
|
readonly onboardingCompleted: boolean | null;
|
|
readonly status: number | null;
|
|
};
|
|
|
|
const PACKAGED_ONBOARDING_OUTCOME_KINDS: readonly PackagedOnboardingConfigOutcomeKind[] = [
|
|
'absent',
|
|
'http-error',
|
|
'malformed',
|
|
'no-config',
|
|
'reading',
|
|
'transport-failure',
|
|
];
|
|
|
|
/**
|
|
* Classifies a probe result. Raises only when the probe itself produced
|
|
* something unusable — never to express a daemon-side fault, which is a value.
|
|
*/
|
|
export function packagedOnboardingOutcomeFromProbe(value: unknown): PackagedOnboardingConfigOutcome {
|
|
if (typeof value !== 'object' || value == null || Array.isArray(value)) {
|
|
throw new PackagedOnboardingConfigError(
|
|
`the probe returned no result (${JSON.stringify(value) ?? 'undefined'})`,
|
|
);
|
|
}
|
|
const candidate = value as Record<string, unknown>;
|
|
const kind = candidate.kind;
|
|
if (typeof kind !== 'string' || !PACKAGED_ONBOARDING_OUTCOME_KINDS.includes(kind as PackagedOnboardingConfigOutcomeKind)) {
|
|
throw new PackagedOnboardingConfigError(`the probe returned no recognisable outcome (${JSON.stringify(candidate)})`);
|
|
}
|
|
const status = typeof candidate.status === 'number' ? candidate.status : null;
|
|
if (kind === 'reading') {
|
|
if (status !== 200 || typeof candidate.onboardingCompleted !== 'boolean') {
|
|
throw new PackagedOnboardingConfigError(
|
|
`the probe claimed a reading without one (${JSON.stringify(candidate)})`,
|
|
);
|
|
}
|
|
return { error: null, kind, onboardingCompleted: candidate.onboardingCompleted, status };
|
|
}
|
|
return {
|
|
error: typeof candidate.error === 'string' ? candidate.error : null,
|
|
kind: kind as PackagedOnboardingConfigOutcomeKind,
|
|
onboardingCompleted: null,
|
|
status,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Applies the table above: the daemon's onboarding fact for this scenario, or an
|
|
* error naming what could not be established.
|
|
*/
|
|
export function packagedOnboardingCompletedForScenario(
|
|
outcome: PackagedOnboardingConfigOutcome,
|
|
scenario: PackagedLaunchScenario,
|
|
): boolean {
|
|
if (outcome.kind === 'reading') {
|
|
if (outcome.onboardingCompleted == null) {
|
|
throw new PackagedOnboardingConfigError('the reading carried no value');
|
|
}
|
|
return outcome.onboardingCompleted;
|
|
}
|
|
if (outcome.kind === 'absent') {
|
|
// A fresh install has never written the key; that IS "not completed".
|
|
if (scenario === 'first-run') return false;
|
|
// In a seeded run the key must exist. Its absence means the seed vanished,
|
|
// which is the round-5 regression, so raise that cause rather than this one.
|
|
throw new PackagedOnboardingSeedError(
|
|
'the relaunched daemon has no onboardingCompleted at all, so the seeded state is gone — check that the protocol cold launch still resolves the tools-pack runtime data root',
|
|
);
|
|
}
|
|
throw new PackagedOnboardingConfigError(
|
|
`${outcome.error ?? outcome.kind} (status=${outcome.status ?? 'none'})`,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Back-compat reader for call sites that already know they are in a seeded
|
|
* (completed-user) run, where every non-reading outcome is a fault.
|
|
*/
|
|
export function packagedOnboardingCompletedFromProbe(value: unknown): boolean {
|
|
return packagedOnboardingCompletedForScenario(packagedOnboardingOutcomeFromProbe(value), 'completed-user');
|
|
}
|
|
|
|
export type PackagedAppShellPolicyInput = {
|
|
/**
|
|
* What the daemon itself reports for `onboardingCompleted`, read from
|
|
* `GET /api/app-config` — which serves `readAppConfig(RUNTIME_DATA_DIR)`, the
|
|
* daemon's own resolved data root.
|
|
*/
|
|
readonly daemonOnboardingCompleted: boolean;
|
|
/** Whether the run is the core smoke profile. */
|
|
readonly coreProfile: boolean;
|
|
/**
|
|
* Whether this run seeded onboarding completion and observed the daemon
|
|
* confirm it earlier, before any relaunch.
|
|
*/
|
|
readonly seededOnboardingCompleted: boolean;
|
|
};
|
|
|
|
|
|
/**
|
|
* Which terminal states this run may settle on.
|
|
*
|
|
* Derived from the daemon's own `onboardingCompleted`, never from the smoke
|
|
* profile, so a run's setup and its accepted terminal state cannot disagree.
|
|
* The smoke seeds `onboardingCompleted: true` before start and independently
|
|
* confirms that the daemon retains it. The auth-first entry shell may still
|
|
* route a signed-out core run to the cloud sign-in landing; that is an identity
|
|
* gate, not evidence that onboarding state was lost. A genuine first run may
|
|
* stop on the same surface after the daemon explicitly reports `false`.
|
|
*
|
|
* `coreProfile` still narrows it: the full profile goes on to drive the entry
|
|
* rail, which `clickUpdaterRailExpression` refuses while onboarding is up, so
|
|
* it needs home either way.
|
|
*/
|
|
export function packagedAppShellPolicy(
|
|
input: PackagedAppShellPolicyInput,
|
|
): { readonly acceptOnboardingLanding: boolean } {
|
|
if (input.coreProfile !== true) return { acceptOnboardingLanding: false };
|
|
// A completed-user run earns auth-first permission only when both the seed
|
|
// and the daemon reading are explicit `true`. `assertSeededOnboardingRetained`
|
|
// turns the `true -> false` cold-launch regression into a named failure before
|
|
// this policy is applied.
|
|
if (input.seededOnboardingCompleted === true) {
|
|
return { acceptOnboardingLanding: input.daemonOnboardingCompleted === true };
|
|
}
|
|
// A first-run landing likewise requires two explicit facts. Closed checks
|
|
// keep malformed values from falling through to the permissive branch.
|
|
return {
|
|
acceptOnboardingLanding:
|
|
input.seededOnboardingCompleted === false && input.daemonOnboardingCompleted === false,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Polls an observation until the renderer settles on a state the policy allows.
|
|
*
|
|
* The clock and sleep are injectable so the transition can be driven without
|
|
* waiting out a real timeout.
|
|
*/
|
|
export async function settlePackagedAppShell(options: {
|
|
readonly describeLast?: (value: unknown) => string;
|
|
readonly now?: () => number;
|
|
readonly observe: () => Promise<unknown>;
|
|
readonly policy: { readonly acceptOnboardingLanding: boolean };
|
|
readonly sleep?: (ms: number) => Promise<void>;
|
|
readonly timeoutMs?: number;
|
|
}): Promise<PackagedAppShellState> {
|
|
const now = options.now ?? (() => Date.now());
|
|
const sleep = options.sleep ?? ((ms: number) => new Promise<void>((r) => setTimeout(r, ms)));
|
|
const timeoutMs = options.timeoutMs ?? 45_000;
|
|
const startedAt = now();
|
|
let lastResult: unknown = null;
|
|
let lastValue: unknown = null;
|
|
|
|
while (now() - startedAt < timeoutMs) {
|
|
try {
|
|
const observed = await options.observe();
|
|
lastResult = observed;
|
|
lastValue = observed;
|
|
if (packagedAppShellSettled(lastValue, options.policy)) {
|
|
const state = packagedAppShellState(lastValue);
|
|
if (state != null) return state;
|
|
}
|
|
} catch (error) {
|
|
lastResult = error;
|
|
}
|
|
await sleep(750);
|
|
}
|
|
|
|
throw new Error(
|
|
[
|
|
`packaged windows runtime did not reach a usable app shell: ${packagedAppShellFailureReason(lastValue, options.policy)}`,
|
|
options.describeLast?.(lastResult) ?? String(lastResult),
|
|
].join('\n'),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Raised when a run that seeded onboarding completion, and saw the daemon
|
|
* confirm it, later observes it gone.
|
|
*/
|
|
export class PackagedOnboardingSeedError extends Error {
|
|
constructor(reason: string) {
|
|
super(reason);
|
|
this.name = 'PackagedOnboardingSeedError';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Holds the run's own seeded state across a process transition.
|
|
*
|
|
* Once this run has seeded completion and observed `true`, a later `false` is a
|
|
* regression in the packaged runtime, never new information about the user. The
|
|
* core profile stops the app and relaunches it through the OS protocol handler,
|
|
* which inherits none of this process's environment — so if that cold launch
|
|
* resolves a different data root, the seeded config disappears. This is the
|
|
* point where that loss becomes a failure with a cause attached, instead of
|
|
* being absorbed as a first run.
|
|
*/
|
|
export function assertSeededOnboardingRetained(input: {
|
|
readonly daemonOnboardingCompleted: boolean;
|
|
readonly seededOnboardingCompleted: boolean;
|
|
}): void {
|
|
if (input.seededOnboardingCompleted !== true) return;
|
|
if (input.daemonOnboardingCompleted === true) return;
|
|
throw new PackagedOnboardingSeedError(
|
|
'the relaunched daemon lost the seeded onboarding state — check that the protocol cold launch still resolves the tools-pack runtime data root',
|
|
);
|
|
}
|
|
|
|
/**
|
|
* The two launch scenarios the packaged app legitimately has.
|
|
*
|
|
* Both are real product behaviour, not a contradiction. A first run reaches
|
|
* the cloud sign-in landing because setup has not completed; a completed but
|
|
* signed-out core run can reach the same landing because identity is now the
|
|
* entry gate. The daemon config reading distinguishes the two and the retained
|
|
* seed proves a protocol cold launch did not switch data roots.
|
|
*/
|
|
export type PackagedLaunchScenario = 'completed-user' | 'first-run';
|
|
|
|
/**
|
|
* Walks one launch scenario: read the daemon's onboarding fact, hold the run's
|
|
* own seeded state across the transition, derive the policy, and settle.
|
|
*
|
|
* Returns the reading alongside the state so the caller can record both.
|
|
*/
|
|
export async function runPackagedAppShellPhase(options: {
|
|
readonly coreProfile: boolean;
|
|
readonly describeLast?: (value: unknown) => string;
|
|
readonly now?: () => number;
|
|
readonly observe: () => Promise<unknown>;
|
|
readonly readOnboardingConfig: () => Promise<unknown>;
|
|
readonly scenario: PackagedLaunchScenario;
|
|
readonly sleep?: (ms: number) => Promise<void>;
|
|
readonly timeoutMs?: number;
|
|
}): Promise<{ appShell: PackagedAppShellState; onboardingCompleted: boolean }> {
|
|
const onboardingCompleted = packagedOnboardingCompletedForScenario(
|
|
packagedOnboardingOutcomeFromProbe(await options.readOnboardingConfig()),
|
|
options.scenario,
|
|
);
|
|
// Only a completed-user phase carries a seed to hold across the transition. A
|
|
// first-run phase deliberately has none, so `false` there is the fact under
|
|
// test rather than a loss — which is why the scenario has to be declared by
|
|
// the caller instead of inferred from the reading.
|
|
const seededOnboardingCompleted = options.scenario === 'completed-user';
|
|
assertSeededOnboardingRetained({ daemonOnboardingCompleted: onboardingCompleted, seededOnboardingCompleted });
|
|
const policy = packagedAppShellPolicy({
|
|
coreProfile: options.coreProfile,
|
|
daemonOnboardingCompleted: onboardingCompleted,
|
|
seededOnboardingCompleted,
|
|
});
|
|
const appShell = await settlePackagedAppShell({
|
|
...(options.describeLast == null ? {} : { describeLast: options.describeLast }),
|
|
...(options.now == null ? {} : { now: options.now }),
|
|
observe: options.observe,
|
|
policy,
|
|
...(options.sleep == null ? {} : { sleep: options.sleep }),
|
|
...(options.timeoutMs == null ? {} : { timeoutMs: options.timeoutMs }),
|
|
});
|
|
return { appShell, onboardingCompleted };
|
|
}
|
|
|
|
/**
|
|
* Whether the renderer has reached a surface the caller can proceed from.
|
|
*
|
|
* `acceptOnboardingLanding` belongs to the caller because the answer depends on
|
|
* what the smoke does next, not on what the app is allowed to show.
|
|
*/
|
|
export function packagedAppShellSettled(
|
|
value: unknown,
|
|
options: { readonly acceptOnboardingLanding: boolean },
|
|
): boolean {
|
|
const state = packagedAppShellState(value);
|
|
if (state === 'home') return true;
|
|
// Explicit permission only, for the same reason as `packagedAppShellPolicy`.
|
|
return state === 'onboarding-landing' && options.acceptOnboardingLanding === true;
|
|
}
|
|
|
|
/**
|
|
* A named cause for the timeout, so a failed run points at a layer instead of
|
|
* dumping an opaque snapshot.
|
|
*/
|
|
export function packagedAppShellFailureReason(
|
|
value: unknown,
|
|
options: { readonly acceptOnboardingLanding: boolean },
|
|
): string {
|
|
const snapshot = asPackagedAppShellSnapshot(value);
|
|
if (snapshot == null) return 'the packaged renderer returned no app-shell snapshot';
|
|
if (packagedAppShellState(value) === 'onboarding-landing' && !options.acceptOnboardingLanding) {
|
|
return 'the packaged renderer stopped on the onboarding cloud sign-in landing, but this smoke profile has to drive the entry rail and needs home';
|
|
}
|
|
if (snapshot.onboardingVisible) {
|
|
return 'the onboarding shell mounted but its cloud sign-in landing did not render (no sign-in CTA, or fewer than two runtime links)';
|
|
}
|
|
return 'neither the home nav rail nor the onboarding cloud sign-in landing rendered';
|
|
}
|