From 741d3b1acf04b72beeec3c2318f9d094f44aa512 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Wed, 23 Sep 2026 18:34:59 +0200 Subject: [PATCH] fix(mobile): tidy PR Review header and session title fallbacks https://github.com/Kilo-Org/cloud/pull/6614 --- .../agents/markdown-renderer.test.ts | 44 +++++ .../components/agents/remote-session-row.tsx | 2 + .../agents/session-detail-content.test.ts | 20 +- .../agents/session-detail-content.tsx | 8 +- .../agents/session-detail-queue.test.ts | 13 +- .../session-detail-rename-state.test.ts | 33 ++++ .../agents/session-detail-rename-state.ts | 8 + .../agents/session-row.mounted.test.tsx | 25 +++ .../src/components/agents/session-row.tsx | 2 + .../feature-flags-section.mounted.test.tsx | 25 ++- .../pr-review/pr-review-entry-screen.test.ts | 18 ++ .../pr-review/pr-review-entry-screen.tsx | 178 +++++++++--------- .../lib/harness-state-route-contract.test.ts | 61 ++++++ 13 files changed, 336 insertions(+), 101 deletions(-) create mode 100644 apps/mobile/src/lib/harness-state-route-contract.test.ts diff --git a/apps/mobile/src/components/agents/markdown-renderer.test.ts b/apps/mobile/src/components/agents/markdown-renderer.test.ts index 945a650313..75eaa896f9 100644 --- a/apps/mobile/src/components/agents/markdown-renderer.test.ts +++ b/apps/mobile/src/components/agents/markdown-renderer.test.ts @@ -854,6 +854,50 @@ describe('MarkdownRenderer list marker alignment', () => { await unmountMarkdown(mounted); }); + it('keeps a wrapped bullet item under its own first line, not under the marker', async () => { + // The finding's own shape: one tight bullet whose text wraps over several + // lines with an inline code span and an em-dash continuation. The row is a + // row — a fixed-width marker box beside a content View that shrinks — so + // every wrapped line lays out inside that content View and starts at the + // first line's left edge, never at the marker's. A change that moved the + // item text out of the shrinking View, or stopped the View shrinking, would + // let a continuation line start at the marker and must fail this test. + const mounted = await mountMarkdown( + '- Current branch `kilo/breezy-engine-kdc` has **no CLI** — only `src/strategies/120-ema.ts` (a top-level-await script that simulates and console.logs trades), db.ts, batch-db.ts, tickers.ts, base-url.ts.' + ); + assertAlignedList(mounted, 1); + assertItemTextMetrics(mounted, ['Current']); + + const row = mounted.root.findAll(node => propOf(node, 'testID') === 'marked-list-item')[0]; + if (!row) { + throw new Error('list item row missing'); + } + const contentView = viewChildren(row)[0]; + if (!contentView) { + throw new Error('list item content View missing'); + } + const textNodes = (phrase: string) => + mounted.root.findAll( + node => + typeof node.type === 'string' && + (node.type as unknown) === 'Text' && + node.children.some(child => typeof child === 'string' && child.includes(phrase)) + ); + // The first line and its wrapped continuation are separate inline Text + // nodes; both must be descendants of the one shrinking content View. + const firstLine = textNodes('Current branch'); + const continuation = textNodes('— only'); + expect(firstLine).toHaveLength(1); + expect(continuation).toHaveLength(1); + for (const text of [firstLine[0], continuation[0]]) { + if (!text) { + throw new Error('item text node missing'); + } + expect(contentView.findAll(node => node === text)).toHaveLength(1); + } + await unmountMarkdown(mounted); + }); + it('keeps a leading blockquote margin in a list item', async () => { const mounted = await mountMarkdown('- > quoted text'); const rows = mounted.root.findAll(node => propOf(node, 'testID') === 'marked-list-item'); diff --git a/apps/mobile/src/components/agents/remote-session-row.tsx b/apps/mobile/src/components/agents/remote-session-row.tsx index c70b395772..213f7ff0be 100644 --- a/apps/mobile/src/components/agents/remote-session-row.tsx +++ b/apps/mobile/src/components/agents/remote-session-row.tsx @@ -95,6 +95,8 @@ export function RemoteSessionRow({ }; }, [refreshScope]); const exitingRef = useRef(false); + // One derivation for the visible label, the spoken label and the rename + // prompt: a creation placeholder title reads as "Untitled session". // The backend names an unnamed session with a raw ISO placeholder // ("New session - 2026-09-22T02:05:22.778Z"); it is not a name the user // should see, so the row falls back to the localized unnamed name the same diff --git a/apps/mobile/src/components/agents/session-detail-content.test.ts b/apps/mobile/src/components/agents/session-detail-content.test.ts index b744ebc842..c66976c680 100644 --- a/apps/mobile/src/components/agents/session-detail-content.test.ts +++ b/apps/mobile/src/components/agents/session-detail-content.test.ts @@ -659,7 +659,10 @@ type MountDetailsOptions = { metadataReady?: Promise; displayScope?: ComponentProps['displayScope']; cachedRows?: StoredMessage[] | null; - /** The route's cached metadata title, as `[session-id].tsx` passes it. */ + /** + * The route's cached list title, seeded before the session record loads, as + * `[session-id].tsx` passes it. + */ cachedTitle?: string; /** The route's `?at=` param the screen mounts with. */ resumeAt?: string | null; @@ -1012,6 +1015,21 @@ describe('SessionDetailContent header title', () => { // `ScreenHeader` caps the trailing slot at 50% of the row, but RN's default // flexShrink is 0: unless the cluster and the pill opt in, their children // keep their natural width and paint past the row's right edge, off-screen. + // The route seeds the header with the cached list title it opened from. A + // session created through cloud-agent-next carries the creation placeholder + // `New session - ` there, and the header must fall back to its + // own title rather than paint the machine string while the record loads. + it('shows the fallback title instead of a placeholder cached title', async () => { + const metadata = Promise.withResolvers(); + const view = await mountDetails([], { + metadataReady: metadata.promise, + cachedTitle: 'New session - 2026-09-22T17:26:31.465Z', + }); + const header = view.renderer.root.findByType(ScreenHeader); + expect(header.props.title).toBe(i18n.t('agentChat.session.title')); + expect(String(header.props.title)).not.toContain('2026-09-22'); + }); + it('lets the trailing header cluster shrink instead of spilling off-screen', async () => { const { renderer } = await mountDetails(); const headerRight = renderer.root.findByType(ScreenHeader).props.headerRight as { diff --git a/apps/mobile/src/components/agents/session-detail-content.tsx b/apps/mobile/src/components/agents/session-detail-content.tsx index c042b11a5f..e76b958a7e 100644 --- a/apps/mobile/src/components/agents/session-detail-content.tsx +++ b/apps/mobile/src/components/agents/session-detail-content.tsx @@ -1628,9 +1628,11 @@ export function SessionDetailContent({ isLoaded: isSessionLoaded, serverTitle, // Same seed the route's loading screen used, so the header keeps the - // title it opened with instead of blinking back to "Session". The route's - // cached metadata can hold the backend's ISO placeholder, which must not - // paint either, while a title the user's own rename wrote is kept. + // title it opened with instead of blinking back to "Session". A creation + // placeholder cached in the list is not a title: fall back to "Session". + // The route's cached metadata can hold the backend's ISO placeholder, + // which must not paint either, while a title the user's own rename wrote + // is kept. fallbackTitle: namedSessionTitle(cachedTitle, sessionId) ?? t('agentChat.session.title'), }); const handleRenameSave = rename.submit; diff --git a/apps/mobile/src/components/agents/session-detail-queue.test.ts b/apps/mobile/src/components/agents/session-detail-queue.test.ts index 6b07309d27..1d3e064818 100644 --- a/apps/mobile/src/components/agents/session-detail-queue.test.ts +++ b/apps/mobile/src/components/agents/session-detail-queue.test.ts @@ -15,6 +15,7 @@ import { type KiloSessionId, type StoredMessage } from '@kilocode/cloud-agent-sd import type * as ReactI18next from 'react-i18next'; import { type SessionTranscriptItem } from '@/components/agents/session-transcript'; +import type * as SessionListHelpers from '@/components/agents/session-list-helpers'; import { SessionMessageList } from '@/components/agents/session-message-list'; import { MessageDetailsSheet } from '@/components/agents/message-details-sheet'; import { MessageBubble } from '@/components/agents/message-bubble'; @@ -369,9 +370,15 @@ vi.mock('@/components/agents/context-usage-display', () => ({ vi.mock('@/components/agents/session-composer-disabled', () => ({ resolveSessionComposerDisabled: () => false, })); -vi.mock('@/components/agents/session-list-helpers', () => ({ - selectSessionCostInputs: () => ({ breakdownCostUsd: null, totalMicrodollars: null }), -})); +// Keep the real pure helpers (the header derives its fallback title through +// `sessionDisplayTitle`); only the cost derivation is stubbed for this suite. +vi.mock('@/components/agents/session-list-helpers', async importOriginal => { + const actual = await importOriginal(); + return { + ...actual, + selectSessionCostInputs: () => ({ breakdownCostUsd: null, totalMicrodollars: null }), + }; +}); vi.mock('@/components/agents/mobile-session-manager-helpers', () => ({ buildRemoteAttachmentParts: vi.fn(), })); diff --git a/apps/mobile/src/components/agents/session-detail-rename-state.test.ts b/apps/mobile/src/components/agents/session-detail-rename-state.test.ts index 5cb3252771..5815825534 100644 --- a/apps/mobile/src/components/agents/session-detail-rename-state.test.ts +++ b/apps/mobile/src/components/agents/session-detail-rename-state.test.ts @@ -45,6 +45,39 @@ describe('getSessionDetailRenameState', () => { }); }); + it('falls back to the caller title when the server title is the creation placeholder', () => { + // A session created through cloud-agent-next carries + // `New session - ` (or the child variant) until it is named. + // The header must never paint that machine string, whether it arrived as + // the fetched server title or as a live `session.updated` title (which + // reaches the hook as `serverTitle`). + const state = getSessionDetailRenameState({ + fallbackTitle: 'Session', + isLoaded: true, + serverTitle: 'New session - 2026-09-22T17:26:31.465Z', + renameState: { ...initialRenameState(), isModalOpen: true }, + }); + expect(state.title).toBe('Session'); + expect(state.modalInitialValue).toBe('Session'); + expect(state.title).not.toContain('2026-09-22'); + }); + + it('treats the caller fallback as opaque and never rewrites it', () => { + // The helper sanitizes only the server title. The screen pre-sanitizes the + // cached list title before it becomes `fallbackTitle` + // (session-detail-content.tsx), so re-running `sessionDisplayTitle` on the + // fallback here would be an identity call and change nothing. + for (const fallback of ['Session', 'New session - 2026-09-22T17:26:31.465Z', ' ']) { + const state = getSessionDetailRenameState({ + fallbackTitle: fallback, + isLoaded: true, + serverTitle: undefined, + renameState: initialRenameState(), + }); + expect(state.title).toBe(fallback); + } + }); + it('falls back to the fallback name when the server title is a generated placeholder', () => { expect( getSessionDetailRenameState({ diff --git a/apps/mobile/src/components/agents/session-detail-rename-state.ts b/apps/mobile/src/components/agents/session-detail-rename-state.ts index e61d23472b..a10a0e3121 100644 --- a/apps/mobile/src/components/agents/session-detail-rename-state.ts +++ b/apps/mobile/src/components/agents/session-detail-rename-state.ts @@ -159,6 +159,14 @@ export function namedSessionTitle( * the caller's fallback copy shows instead of the raw ISO string the CLI listed * the session under. Pass the session id so a title the user's own rename wrote * is not hidden as the backend placeholder. + * + * The server title — the fetched title, or a live `session.updated` title the + * hook folds into `serverTitle` — passes through `namedSessionTitle`, so the + * creation placeholder (`New session - `) can never reach the + * header from the server. The fallback title is the caller's to sanitize: the + * screen already routes the cached list title through `namedSessionTitle` + * before passing it here (`session-detail-content.tsx`), so a placeholder + * cached in the list cannot reach the header either. */ export function getSessionDetailRenameState(input: { sessionId?: string; diff --git a/apps/mobile/src/components/agents/session-row.mounted.test.tsx b/apps/mobile/src/components/agents/session-row.mounted.test.tsx index 3fcafee5e5..9f1228ef6f 100644 --- a/apps/mobile/src/components/agents/session-row.mounted.test.tsx +++ b/apps/mobile/src/components/agents/session-row.mounted.test.tsx @@ -354,6 +354,23 @@ describe('StoredSessionRow live speech', () => { expect(selectedId).toBe(destinationsDisabled ? null : 'stored-1'); } ); + + it('shows the untitled fallback for a creation placeholder title and never speaks the ISO instant', () => { + const renderer = mount( + row({ + session: { + ...session, + title: 'New session - 2026-09-22T17:26:31.465Z', + git_branch: null, + total_cost_microdollars: null, + }, + }) + ); + expect(texts(renderer)).toContain(i18n.t('agents.sessionRow.untitled')); + const button = hosts(renderer, 'Pressable')[0]; + expect(button?.props.accessibilityLabel).toContain(i18n.t('agents.sessionRow.untitled')); + expect(button?.props.accessibilityLabel).not.toContain('2026-09-22'); + }); }); describe('StoredSessionRow rename prefill', () => { @@ -542,4 +559,12 @@ describe('RemoteSessionRow live speech', () => { 'Live work, Idle, feature/live, LIVE-REPO, and 5 minutes ago' ); }); + + it('shows the untitled fallback for a creation placeholder title and never speaks the ISO instant', () => { + const renderer = mountRemote({ title: 'New session - 2026-09-22T17:26:31.465Z' }); + expect(texts(renderer)).toContain(i18n.t('agents.sessionRow.untitled')); + const button = hosts(renderer, 'Pressable')[0]; + expect(button?.props.accessibilityLabel).toContain(i18n.t('agents.sessionRow.untitled')); + expect(button?.props.accessibilityLabel).not.toContain('2026-09-22'); + }); }); diff --git a/apps/mobile/src/components/agents/session-row.tsx b/apps/mobile/src/components/agents/session-row.tsx index 55d326df1a..6465610afa 100644 --- a/apps/mobile/src/components/agents/session-row.tsx +++ b/apps/mobile/src/components/agents/session-row.tsx @@ -110,6 +110,8 @@ export function StoredSessionRow({ const { t } = useTranslation(); const themedSheet = useThemedActionSheetOptions(); const { showActionSheetWithOptions } = useActionSheet(); + // One derivation for the visible label, the spoken label and the rename + // prompt: a creation placeholder title reads as "Untitled session". // The backend names an unnamed session with a raw ISO placeholder // ("New session - 2026-09-22T02:05:22.778Z"); it is not a name the user // should see, so the row falls back to the localized unnamed name the same diff --git a/apps/mobile/src/components/feature-flags-section.mounted.test.tsx b/apps/mobile/src/components/feature-flags-section.mounted.test.tsx index 046bca2968..f2af8568cf 100644 --- a/apps/mobile/src/components/feature-flags-section.mounted.test.tsx +++ b/apps/mobile/src/components/feature-flags-section.mounted.test.tsx @@ -46,9 +46,10 @@ beforeEach(() => { vi.stubGlobal('__DEV__', true); posthog.statuses = []; }); -afterEach(() => { +afterEach(async () => { view?.unmount(); view = undefined; + await i18n.changeLanguage('en'); vi.unstubAllGlobals(); }); afterAll(async () => { @@ -121,6 +122,28 @@ describe('FeatureFlagsSection', () => { expect(textLines(tree)).toContain('Enabled · default · not loaded'); }); + it('reads the reason copy in the selected language and keeps the flag key as an identifier', async () => { + // The finding's capture showed the English source/state words ("default · + // not loaded") in an otherwise Arabic row. `preferences.featureFlagNotLoaded` + // is prose, not notation: it is deliberately absent from + // `ENGLISH_IDENTICAL_ALLOWLIST` in `tools/i18n/check-catalogs.mjs`, and + // `i18n/feature-flag-copy.test.ts` fails any catalog that ships the English + // string. The flag key stays the ASCII identifier the registry is keyed by. + await i18n.changeLanguage('ar'); + posthog.statuses = [unloaded]; + const tree = await mount(); + + const lines = textLines(tree); + const reason = i18n.t('preferences.featureFlagNotLoaded'); + expect(reason).not.toBe('default · not loaded'); + expect(lines).toContain(`${i18n.t('common.enabled')} · ${reason}`); + // The value word and the reason both follow the selected language… + expect(i18n.t('common.enabled')).not.toBe('Enabled'); + expect(lines).not.toContain(`Enabled · ${reason}`); + // …while the flag key remains the technical identifier it is keyed by. + expect(lines).toContain('mobile-pr-review'); + }); + it('marks a below-minimum flag with the version gate even before remote flags load', async () => { // The staged v1.0.5 case: the gate is decided by the app version alone, so // the row must read "< 1.0.6" whether or not PostHog ever returned a value. diff --git a/apps/mobile/src/components/pr-review/pr-review-entry-screen.test.ts b/apps/mobile/src/components/pr-review/pr-review-entry-screen.test.ts index 6a7d7abc68..0b1ac587eb 100644 --- a/apps/mobile/src/components/pr-review/pr-review-entry-screen.test.ts +++ b/apps/mobile/src/components/pr-review/pr-review-entry-screen.test.ts @@ -20,6 +20,7 @@ import { renderLoaded, resetHookSlots, seedRecents, + textValues, } from './pr-review-entry-screen-test-utils'; // The field draws the visible placeholder with the same one-line overlay on @@ -186,4 +187,21 @@ describe('provider-neutral URL field', () => { const after = render(); expect(find(after, 'Pressable', p => p.accessibilityLabel === 'Clear link')).toBeTruthy(); }); + + it('states the instruction once, in the header eyebrow, not the field caption', async () => { + const tree = await renderLoaded(); + const header = find(tree, 'ScreenHeader', () => true); + // Finding: the field's own letter-spaced "Paste a pull request or merge + // request link" caption repeated the header instruction. The header keeps + // the single instruction as its eyebrow; the field keeps its placeholder. + expect(header.props?.title).toBe('PR Review'); + expect(header.props?.eyebrow).toBe('Open a pull request or merge request by URL'); + const input = find(tree, 'TextInput', () => true); + expect(input.props?.placeholder).toBe('Pull request or merge request URL'); + // The paste block no longer carries a caption repeating the instruction. + const list = find(tree, 'PrReviewInboxList', () => true); + const pasteBlockTexts = textValues(propsOf(list).header); + expect(pasteBlockTexts).not.toContain('Paste a pull request or merge request link'); + expect(pasteBlockTexts).not.toContain('Open a pull request or merge request by URL'); + }); }); diff --git a/apps/mobile/src/components/pr-review/pr-review-entry-screen.tsx b/apps/mobile/src/components/pr-review/pr-review-entry-screen.tsx index e3cf1b557e..6be1d414bb 100644 --- a/apps/mobile/src/components/pr-review/pr-review-entry-screen.tsx +++ b/apps/mobile/src/components/pr-review/pr-review-entry-screen.tsx @@ -1,6 +1,6 @@ import * as Clipboard from 'expo-clipboard'; import { useFocusEffect, useRouter } from 'expo-router'; -import { Clipboard as ClipboardIcon, Link2, SearchX, X } from '@/components/ui/icons'; +import { Clipboard as ClipboardIcon, SearchX, X } from '@/components/ui/icons'; import { DirectionalChevronRight } from '@/components/ui/directional-icons'; import { type ReactNode, useCallback, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; @@ -240,103 +240,95 @@ export function PrReviewEntryScreen() { } const pasteBlock = ( - + - - - {t('prReview.entry.pastePrLink')} - - - - - - { - // Don't setState on every keystroke; track only whether the - // input has any text. The raw value lives in the ref so - // handleSubmit reads the latest text without re-rendering. - const decision = consumePrLinkInputEcho(pendingProgrammaticTextsRef.current, value); - pendingProgrammaticTextsRef.current = [...decision.pending]; - if (decision.kind === 'echo') { - // Echo of setNativeProps: inputValueRef already holds the - // intentional value from applyFieldText — do not clobber it - // with a delayed/stale echo. - return; - } - inputValueRef.current = value; - setHasInput(value.length > 0); - }} - // leading-[normal] so no lineHeight reaches the style: an explicit lineHeight - // makes iOS draw the placeholder lower than the typed text (see AGENTS.md). - // min-h-14 (not py-*) sizes the single-line field per the mobile - // input rules and still lets Dynamic Type grow it past the floor. - className="min-h-14 min-w-0 flex-1 bg-transparent pl-3 pr-1 text-base text-foreground leading-[normal]" - accessibilityLabel={t('prReview.entry.urlAccessibility')} - returnKeyType="go" - onSubmitEditing={handleSubmit} - /> - {!hasInput ? : null} - {showClearButton ? ( - // h-13 w-13 measures 45×45pt on device; h-12 is 42pt and h-11 is - // 38pt in this app — do not "simplify" back to h-11/w-11. - { - // clear() is the iOS-safe native empty after real typing. - // setNativeProps({ text: '' }) loses the most-recent-event-count - // race and leaves the typed text visible while React state - // thinks the field is empty. Do not route through - // applyFieldText('') (paste-only path) and do not push an - // echo for '' — a non-arriving echo would stale the FIFO. - inputValueRef.current = ''; - setHasInput(false); - inputRef.current?.clear(); - inputRef.current?.focus(); - }} - accessibilityRole="button" - accessibilityLabel={t('prReview.entry.clearLink')} - className="h-13 w-13 items-center justify-center active:opacity-70" - > - - - ) : null} - - { - void handlePaste(); + + { + // Don't setState on every keystroke; track only whether the + // input has any text. The raw value lives in the ref so + // handleSubmit reads the latest text without re-rendering. + const decision = consumePrLinkInputEcho(pendingProgrammaticTextsRef.current, value); + pendingProgrammaticTextsRef.current = [...decision.pending]; + if (decision.kind === 'echo') { + // Echo of setNativeProps: inputValueRef already holds the + // intentional value from applyFieldText — do not clobber it + // with a delayed/stale echo. + return; + } + inputValueRef.current = value; + setHasInput(value.length > 0); }} - accessibilityRole="button" - accessibilityLabel={t('prReview.entry.pasteLink')} - hitSlop={4} - className="h-11 w-11 items-center justify-center rounded-md border border-border bg-card active:opacity-70" - > - - + // leading-[normal] so no lineHeight reaches the style: an explicit lineHeight + // makes iOS draw the placeholder lower than the typed text (see AGENTS.md). + // min-h-14 (not py-*) sizes the single-line field per the mobile + // input rules and still lets Dynamic Type grow it past the floor. + className="min-h-14 min-w-0 flex-1 bg-transparent pl-3 pr-1 text-base text-foreground leading-[normal]" + accessibilityLabel={t('prReview.entry.urlAccessibility')} + returnKeyType="go" + onSubmitEditing={handleSubmit} + /> + {!hasInput ? : null} + {showClearButton ? ( + // h-13 w-13 measures 45×45pt on device; h-12 is 42pt and h-11 is + // 38pt in this app — do not "simplify" back to h-11/w-11. + { + // clear() is the iOS-safe native empty after real typing. + // setNativeProps({ text: '' }) loses the most-recent-event-count + // race and leaves the typed text visible while React state + // thinks the field is empty. Do not route through + // applyFieldText('') (paste-only path) and do not push an + // echo for '' — a non-arriving echo would stale the FIFO. + inputValueRef.current = ''; + setHasInput(false); + inputRef.current?.clear(); + inputRef.current?.focus(); + }} + accessibilityRole="button" + accessibilityLabel={t('prReview.entry.clearLink')} + className="h-13 w-13 items-center justify-center active:opacity-70" + > + + + ) : null} - + + + ); diff --git a/apps/mobile/src/lib/harness-state-route-contract.test.ts b/apps/mobile/src/lib/harness-state-route-contract.test.ts new file mode 100644 index 0000000000..b9ca6066d5 --- /dev/null +++ b/apps/mobile/src/lib/harness-state-route-contract.test.ts @@ -0,0 +1,61 @@ +// Source guard for harness-only routes. +// +// A harness start state deep-links a raw path: `state.sh` reaches it with +// `session.sh open ` and no `--email`, so the state's `route` +// must be a product path, never a bare command name (runbook/sessions.md: +// without `--email` the route is a raw `kiloapp://` path, not a name from the +// dev command's table). The `settings` state had shipped the bare name +// `settings`, which resolves to `kiloapp://settings`; no product surface emits +// that link, and the repair points the state at the real `/profile/preferences` +// path. A `(app)/settings.tsx` alias had been added so the bad link resolved — +// a route whose only trigger is the harness is verification-only runtime +// support and must not come back. This guard reads the two things the contract +// has to agree with: the product route tree (`src/app`) and the universal-link +// table the app resolves links through. +// eslint-disable-next-line import/no-nodejs-modules -- vitest-only guard, runs in node, never bundled into the app +import { readdirSync } from 'node:fs'; +// eslint-disable-next-line import/no-nodejs-modules -- vitest-only guard, runs in node, never bundled into the app +import { fileURLToPath } from 'node:url'; + +import { webPathToAppPath } from '@kilocode/app-shared/universal-links'; +import { describe, expect, it } from 'vitest'; + +const appDir = fileURLToPath(new URL('../app', import.meta.url)); + +function routeFiles(dir: string): string[] { + return readdirSync(dir, { withFileTypes: true }).flatMap(entry => { + const full = `${dir}/${entry.name}`; + if (entry.isDirectory()) { + return routeFiles(full); + } + return entry.name.endsWith('.tsx') && !entry.name.endsWith('.test.tsx') ? [full] : []; + }); +} + +const routes = routeFiles(appDir); + +describe('harness start-state route contract', () => { + it('finds the product route tree it guards', () => { + // A walk that silently found nothing would make the assertions below pass + // vacuously. + expect(routes.length).toBeGreaterThan(50); + }); + + it('ships no route that exists only for a harness start state', () => { + // `/settings` names no product screen (system-search-route.test.ts and + // system-search-entries.test.ts record it), so a route file for it has no + // caller but the retired harness link. + const harnessOnly = routes.filter(file => file.endsWith('/(app)/settings.tsx')); + expect(harnessOnly).toEqual([]); + }); + + it('resolves the path the settings start state deep-links to', () => { + // The state carries `/profile/preferences`: the table must map exactly that + // path and the screen it maps to must exist in the tree. + const appPath = webPathToAppPath('/profile/preferences'); + expect(appPath).toBe('/(app)/(tabs)/(3_profile)/preferences'); + expect(routes).toContain(`${appDir}/(app)/(tabs)/(3_profile)/preferences.tsx`); + // The retired bare link stays unmapped. + expect(webPathToAppPath('/settings')).toBeNull(); + }); +});