|
| 1 | +// What an incoming link may open. The two shapes that used to replace the |
| 2 | +// visible screen — a session capture that cannot name a session, and a path |
| 3 | +// outside the app's route namespace — are rejected here, so the navigator never |
| 4 | +// resolves them and the screen beneath keeps its unsaved state. |
| 5 | + |
| 6 | +// eslint-disable-next-line import/no-nodejs-modules -- vitest-only check, runs in node, never bundled into the app |
| 7 | +import { readdirSync, readFileSync } from 'node:fs'; |
| 8 | +// eslint-disable-next-line import/no-nodejs-modules -- vitest-only check, runs in node, never bundled into the app |
| 9 | +import { fileURLToPath } from 'node:url'; |
| 10 | + |
| 11 | +import { describe, expect, it } from 'vitest'; |
| 12 | + |
| 13 | +import { APP_ROUTE_ROOTS, opensAppRoutePath, opensDestination } from './deep-link-destination'; |
| 14 | + |
| 15 | +const SESSION_ID = 'ses_0123456789abcdefghijklmnop'; |
| 16 | + |
| 17 | +describe('opensDestination', () => { |
| 18 | + it.each([ |
| 19 | + '/(app)/(tabs)/(0_home)', |
| 20 | + '/(app)/(tabs)/(3_profile)', |
| 21 | + '/(app)/security-agent/personal/findings', |
| 22 | + '/(app)/pr-review/Kilo-Org/cloud/6234', |
| 23 | + '/(app)/agent-chat/new', |
| 24 | + `/(app)/agent-chat/${SESSION_ID}`, |
| 25 | + ])('opens the table destination %s', href => { |
| 26 | + expect(opensDestination(href)).toBe(true); |
| 27 | + }); |
| 28 | + |
| 29 | + it.each([ |
| 30 | + '/(app)/agent-chat/not-a-session', |
| 31 | + '/(app)/agent-chat/00000000-0000-0000-0000-000000000000', |
| 32 | + '/(app)/agent-chat/ses_short', |
| 33 | + ])('refuses the session destination %s', href => { |
| 34 | + expect(opensDestination(href)).toBe(false); |
| 35 | + }); |
| 36 | +}); |
| 37 | + |
| 38 | +describe('opensAppRoutePath', () => { |
| 39 | + it.each([ |
| 40 | + 'kiloapp:///(app)/profile-management/34d743e6-7bb8-4dd1-880c-580bd8d907d5/variables', |
| 41 | + 'kiloapp:///(auth)/session-row-fixture', |
| 42 | + 'kiloapp:///force-update', |
| 43 | + 'kiloapp:///button-label-fixture', |
| 44 | + '/(app)/agent-chat/new?mode=code', |
| 45 | + '/(auth)/login', |
| 46 | + `kiloapp:///(app)/agent-chat/${SESSION_ID}`, |
| 47 | + 'kiloapp:///(app)/agent-chat/new', |
| 48 | + `kiloapp://agent-chat/${SESSION_ID}`, |
| 49 | + 'kiloapp://pr-review/Kilo-Org/cloud/6234', |
| 50 | + 'kiloapp://security-agent/personal', |
| 51 | + ])('opens the app route %s', path => { |
| 52 | + expect(opensAppRoutePath(path)).toBe(true); |
| 53 | + }); |
| 54 | + |
| 55 | + it.each([ |
| 56 | + 'kiloapp:///', |
| 57 | + 'kiloapp://', |
| 58 | + 'kiloapp:///definitely-not-a-route', |
| 59 | + 'kiloapp:///dev/unknown-fixture', |
| 60 | + 'https://app.kilo.ai/admin', |
| 61 | + 'https://example.com/profile', |
| 62 | + 'not a url', |
| 63 | + 'kiloapp://agent-chat/not-a-session', |
| 64 | + 'kiloapp:///(app)/agent-chat/not-a-session', |
| 65 | + '/(app)/agent-chat/ses_short', |
| 66 | + ])('refuses %s', path => { |
| 67 | + expect(opensAppRoutePath(path)).toBe(false); |
| 68 | + }); |
| 69 | +}); |
| 70 | + |
| 71 | +describe('route namespace', () => { |
| 72 | + it('names every root entry of the route directory', () => { |
| 73 | + // A new root-level route must be opened deliberately: an incoming link |
| 74 | + // reaches the navigator only through one of these names (or the indexed |
| 75 | + // families), so a route missing here is unreachable by link. |
| 76 | + const entries = readdirSync(fileURLToPath(new URL('../app', import.meta.url)), { |
| 77 | + withFileTypes: true, |
| 78 | + }) |
| 79 | + .map(entry => entry.name) |
| 80 | + // `_layout` and `+`-prefixed files are navigator plumbing, not routes. |
| 81 | + .filter(name => !name.startsWith('_') && !name.startsWith('+')) |
| 82 | + .filter(name => !/\.test\.tsx?$/.test(name)) |
| 83 | + .map(name => name.replace(/\.(tsx|ts|jsx|js)$/, '')); |
| 84 | + |
| 85 | + expect([...new Set(entries)].sort()).toEqual([...APP_ROUTE_ROOTS].sort()); |
| 86 | + }); |
| 87 | +}); |
| 88 | + |
| 89 | +describe('seed fixture links', () => { |
| 90 | + it('records the link form the app opens', () => { |
| 91 | + // `dev/seed/AGENTS.md` requires a fixture to record the deep link that opens |
| 92 | + // it, and `opensAppRoutePath` forwards only the group-prefixed form (or a |
| 93 | + // `dev/` fixture link, which `+native-intent` answers before the mapper). |
| 94 | + const files = seedSources(fileURLToPath(new URL('../../../../dev/seed', import.meta.url))); |
| 95 | + const links = files.flatMap(file => |
| 96 | + [...readFileSync(file, 'utf8').matchAll(/kiloapp:\/\/[^\s'"`]+/g)].map(match => match[0]) |
| 97 | + ); |
| 98 | + |
| 99 | + expect(links.length).toBeGreaterThan(0); |
| 100 | + for (const link of links) { |
| 101 | + expect( |
| 102 | + link.startsWith('kiloapp:///(app)/') || link.startsWith('kiloapp:///dev/'), |
| 103 | + `${link} does not follow the recorded link form` |
| 104 | + ).toBe(true); |
| 105 | + } |
| 106 | + }); |
| 107 | +}); |
| 108 | + |
| 109 | +function seedSources(directory: string): string[] { |
| 110 | + return readdirSync(directory, { withFileTypes: true }).flatMap(entry => { |
| 111 | + const path = `${directory}/${entry.name}`; |
| 112 | + if (entry.isDirectory()) { |
| 113 | + return seedSources(path); |
| 114 | + } |
| 115 | + return entry.name.endsWith('.ts') ? [path] : []; |
| 116 | + }); |
| 117 | +} |
0 commit comments