* feat(ui-open-in-app): announce settled open outcomes app-wide
The shared file and directory opener reported a nullable failure, so it could
not tell a handoff the Host acknowledged from a gesture an adapter deliberately
dropped — a duplicate already in flight, or a controller disposed before the
gesture ran. Its notice also lived inside the control that asked for the open,
so it died with the panel, tab, or card the gesture started in.
Replace that with a discriminated outcome on both controllers and adapters, and
report each settled gesture into one `shell.overlay` entry that renders the
app-wide Toast: the Session-header command, the files-tab and Session-header
buttons, both document-preview controls, and both delivery-card controls.
`{ kind: 'skipped' }` announces nothing, because no handoff happened, so card
status and the toast never disagree about what settled.
Name the action for what it does on every surface — "Open on your computer" —
instead of after whichever application would take the handoff. The application
stays supplementary, in the tooltip beside the shortcut it runs under, which is
what the accessible name, visible text, and command label now share.
* fix(client): unify open-in-app feedback
* fix(client): report native open outcomes accurately
* fix(client): tighten open feedback outcomes and lifecycle tests
* test(ptc): synchronize hot-loop log capture
Observe a synchronous child marker before cancellation so the console retention assertion does not depend on completing Node startup within one second.
* test(bench): interleave scaling samples
* fix(client): scope native-open feedback to mobile
Explain native opens on another device for mobile and PWA clients while keeping desktop success silent. Preserve failure feedback after the initiating surface closes and ignore skipped requests.
Remove unrelated benchmark and PTC test changes and retain existing button labels.
Refs #6097
---------
Co-authored-by: EricMoin <[email protected]>
Co-authored-by: EricMoin <[email protected]>
112 lines
5.4 KiB
TypeScript
112 lines
5.4 KiB
TypeScript
/** Controller wire behavior: document-relative routes, availability filtering, and launch errors. */
|
|
|
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
import { OpenInAppController } from '../src/client/controller.ts'
|
|
|
|
afterEach(() => {
|
|
vi.unstubAllGlobals()
|
|
})
|
|
|
|
function jsonResponse(payload: unknown, status = 200): Response {
|
|
return new Response(JSON.stringify(payload), { status })
|
|
}
|
|
|
|
describe('OpenInAppController availability', () => {
|
|
it('starts without a platform-specific choice', () => {
|
|
const controller = new OpenInAppController(async () => jsonResponse({ apps: [] }))
|
|
expect(controller.choice.getSnapshot()).toBe('')
|
|
expect(controller.currentApp()).toBeUndefined()
|
|
})
|
|
|
|
it('shares one availability read across concurrent loads', async () => {
|
|
const fetcher = vi.fn(async () => jsonResponse({ apps: ['finder'] }))
|
|
const controller = new OpenInAppController(fetcher)
|
|
await Promise.all([controller.load(), controller.load()])
|
|
await controller.load()
|
|
expect(fetcher).toHaveBeenCalledOnce()
|
|
expect(controller.apps.getSnapshot()).toEqual(['finder'])
|
|
})
|
|
|
|
it('publishes an empty list for a non-OK availability answer and for a non-array payload', async () => {
|
|
const failing = new OpenInAppController(async () => jsonResponse({}, 500))
|
|
await failing.load()
|
|
expect(failing.apps.getSnapshot()).toEqual([])
|
|
|
|
const malformed = new OpenInAppController(async () => jsonResponse({ apps: 'nope' }))
|
|
await malformed.load()
|
|
expect(malformed.apps.getSnapshot()).toEqual([])
|
|
})
|
|
|
|
it('requests the document-relative availability route', async () => {
|
|
const fetcher = vi.fn(async (input: string | URL) => { void input; return jsonResponse({ apps: [] }) })
|
|
const controller = new OpenInAppController(fetcher)
|
|
await controller.load()
|
|
expect(fetcher.mock.calls[0]?.[0]).toBe('open-in-app/apps')
|
|
})
|
|
})
|
|
|
|
describe('OpenInAppController launching', () => {
|
|
it('uses only nameable installed apps and falls back when the remembered choice is unavailable', () => {
|
|
const controller = new OpenInAppController()
|
|
controller.apps.set(['unknown-app', 'finder', 'cursor'])
|
|
controller.choose('cursor')
|
|
expect(controller.currentApp()).toBe('cursor')
|
|
controller.apps.set(['unknown-app', 'finder'])
|
|
expect(controller.currentApp()).toBe('finder')
|
|
controller.apps.set(['unknown-app'])
|
|
expect(controller.currentApp()).toBeUndefined()
|
|
})
|
|
|
|
it('shares the busy operation across gestures and captures its app and directory', async () => {
|
|
let finish!: (response: Response) => void
|
|
const fetcher = vi.fn((_input: string | URL, _init?: RequestInit) => new Promise<Response>((resolve) => { finish = resolve }))
|
|
const controller = new OpenInAppController(fetcher)
|
|
controller.choose('cursor')
|
|
const pending = controller.launch('cursor', '/workspace/first')
|
|
expect(controller.operation.getSnapshot()).toEqual({ phase: 'busy', path: '/workspace/first' })
|
|
controller.choose('finder')
|
|
// The gesture another launch already holds settles as deliberate silence.
|
|
await expect(controller.launch('finder', '/workspace/second')).resolves.toEqual({ kind: 'skipped' })
|
|
expect(controller.choice.getSnapshot()).toBe('cursor')
|
|
expect(fetcher).toHaveBeenCalledOnce()
|
|
expect(fetcher.mock.calls[0]?.[1]?.body).toBe(JSON.stringify({ app: 'cursor', path: '/workspace/first' }))
|
|
finish(jsonResponse({ ok: true }))
|
|
await expect(pending).resolves.toEqual({ kind: 'opened' })
|
|
expect(controller.operation.getSnapshot()).toEqual({ phase: 'idle', path: '/workspace/first' })
|
|
})
|
|
|
|
it('restores the chosen app from the open-in-app storage key', () => {
|
|
const values = new Map<string, string>()
|
|
vi.stubGlobal('localStorage', {
|
|
getItem: (key: string) => values.get(key) ?? null,
|
|
setItem: (key: string, value: string) => { values.set(key, value) },
|
|
})
|
|
const controller = new OpenInAppController(async () => jsonResponse({ apps: [] }))
|
|
controller.choose('cursor')
|
|
expect(controller.choice.getSnapshot()).toBe('cursor')
|
|
expect(values.get('dsh.open-in-app.choice')).toBe('"cursor"')
|
|
const reloaded = new OpenInAppController(async () => jsonResponse({ apps: [] }))
|
|
expect(reloaded.choice.getSnapshot()).toBe('cursor')
|
|
})
|
|
|
|
it('posts the launch body and surfaces HTTP failures', async () => {
|
|
const fetcher = vi.fn(async (input: string | URL, init?: RequestInit) => { void input; void init; return jsonResponse({ ok: true }) })
|
|
const controller = new OpenInAppController(fetcher)
|
|
await expect(controller.launch('cursor', '/w/dir')).resolves.toEqual({ kind: 'opened' })
|
|
expect(fetcher.mock.calls[0]?.[0]).toBe('open-in-app/open')
|
|
expect(fetcher.mock.calls[0]?.[1]).toMatchObject({
|
|
method: 'POST',
|
|
headers: { 'content-type': 'application/json' },
|
|
body: JSON.stringify({ app: 'cursor', path: '/w/dir' }),
|
|
})
|
|
|
|
const failing = new OpenInAppController(async () => jsonResponse({}, 404))
|
|
await expect(failing.launch('cursor', '/w/dir')).resolves.toEqual({ kind: 'failed', failure: 'openError' })
|
|
expect(failing.operation.getSnapshot()).toEqual({ phase: 'error', path: '/w/dir' })
|
|
|
|
const offline = new OpenInAppController(async () => { throw new Error('offline') })
|
|
await expect(offline.launch('cursor', '/w/dir')).resolves.toEqual({ kind: 'failed', failure: 'openError' })
|
|
expect(offline.operation.getSnapshot()).toEqual({ phase: 'error', path: '/w/dir' })
|
|
})
|
|
})
|