-
Notifications
You must be signed in to change notification settings - Fork 7.7k
fix(daemon): report Deliver-to-editor launch failures instead of swallowing them #3952
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
maxmilian
wants to merge
2
commits into
nexu-io:main
Choose a base branch
from
maxmilian:fix/3871-handoff-launch-error
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+180
−16
Open
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,111 @@ | ||
| // Route-level coverage for POST /api/projects/:id/open-in (#3871). | ||
| // | ||
| // The helper-level tests in host-tools-routes.test.ts pin launchHostTool's | ||
| // contract, but not the route's translation of a refused launch into an HTTP | ||
| // response — if the route regressed back to swallowing `!launch.ok` (or mapped | ||
| // it to `200 { ok: true }`), those tests would stay green. Here the spawn is | ||
| // mocked at the node:child_process boundary so the full route path runs, and | ||
| // the assertions lock the observable behavior: HTTP status + error code/body. | ||
|
|
||
| import { EventEmitter } from 'node:events'; | ||
| import type http from 'node:http'; | ||
| import type { AddressInfo } from 'node:net'; | ||
| import path from 'node:path'; | ||
| import { tmpdir } from 'node:os'; | ||
| import express from 'express'; | ||
| import type { Response } from 'express'; | ||
| import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; | ||
|
|
||
| import { registerHostToolsRoutes } from '../src/routes/host-tools.js'; | ||
| import type { RegisterHostToolsRoutesDeps } from '../src/routes/host-tools.js'; | ||
|
|
||
| const spawnState = vi.hoisted(() => ({ fail: false, error: 'spawn cursor ENOENT' })); | ||
|
|
||
| // Deterministic spawn: emits `error` or `spawn` on the next tick depending on | ||
| // spawnState, so both launch outcomes are reachable on any CI platform. | ||
| vi.mock('node:child_process', async (importOriginal) => { | ||
| const actual = await importOriginal<typeof import('node:child_process')>(); | ||
| return { | ||
| ...actual, | ||
| spawn: vi.fn(() => { | ||
| const child = new EventEmitter() as EventEmitter & { unref: () => void }; | ||
| child.unref = () => {}; | ||
| setImmediate(() => { | ||
| if (spawnState.fail) child.emit('error', new Error(spawnState.error)); | ||
| else child.emit('spawn'); | ||
| }); | ||
| return child; | ||
| }), | ||
| }; | ||
| }); | ||
|
|
||
| // Make the $PATH probe succeed everywhere so resolveHostToolLaunchPlan | ||
| // reports the editor as available and the route reaches the launch step. | ||
| vi.mock('node:fs/promises', async (importOriginal) => { | ||
| const actual = await importOriginal<typeof import('node:fs/promises')>(); | ||
| return { ...actual, access: async () => undefined }; | ||
| }); | ||
|
|
||
| // Absolute baseDir short-circuits projectHostOpenDir, so resolveProjectDir is | ||
| // never consulted and no real project layout is needed. | ||
| const PROJECT_DIR = path.join(tmpdir(), 'od-3871-project'); | ||
|
|
||
| let server: http.Server; | ||
| let baseUrl: string; | ||
|
|
||
| beforeAll(async () => { | ||
| const app = express(); | ||
| app.use(express.json()); | ||
| registerHostToolsRoutes(app, { | ||
| db: {}, | ||
| http: { | ||
| // Mirrors the compat shape of server.ts sendApiError: status + { error: { code, message } }. | ||
| sendApiError: (res: Response, status: number, code: string, message: string) => | ||
| res.status(status).json({ error: { code, message } }), | ||
| }, | ||
| paths: { PROJECTS_DIR: tmpdir() }, | ||
| projectStore: { | ||
| getProject: (_db: unknown, id: string) => | ||
| id === 'p1' ? { id, metadata: { baseDir: PROJECT_DIR } } : null, | ||
| }, | ||
| projectFiles: { resolveProjectDir: () => PROJECT_DIR }, | ||
| } as unknown as RegisterHostToolsRoutesDeps); | ||
| server = app.listen(0); | ||
| await new Promise<void>((resolve) => server.once('listening', () => resolve())); | ||
| baseUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}`; | ||
| }); | ||
|
|
||
| afterAll(async () => { | ||
| await new Promise<void>((resolve) => server.close(() => resolve())); | ||
| }); | ||
|
|
||
| function postOpenIn(projectId: string) { | ||
| return fetch(`${baseUrl}/api/projects/${projectId}/open-in`, { | ||
| method: 'POST', | ||
| headers: { 'content-type': 'application/json' }, | ||
| body: JSON.stringify({ editorId: 'cursor' }), | ||
| }); | ||
| } | ||
|
|
||
| describe('POST /api/projects/:id/open-in launch reporting (#3871)', () => { | ||
| it('returns 500 EDITOR_LAUNCH_FAILED when the OS refuses the launch', async () => { | ||
| spawnState.fail = true; | ||
|
|
||
| const resp = await postOpenIn('p1'); | ||
|
|
||
| expect(resp.status).toBe(500); | ||
| const body = (await resp.json()) as { error: { code: string; message: string } }; | ||
| expect(body.error.code).toBe('EDITOR_LAUNCH_FAILED'); | ||
| expect(body.error.message).toContain('Failed to launch Cursor'); | ||
| expect(body.error.message).toContain('spawn cursor ENOENT'); | ||
| }); | ||
|
|
||
| it('returns 200 ok:true once the OS confirms the launch', async () => { | ||
| spawnState.fail = false; | ||
|
|
||
| const resp = await postOpenIn('p1'); | ||
|
|
||
| expect(resp.status).toBe(200); | ||
| expect(await resp.json()).toEqual({ ok: true, editorId: 'cursor', path: PROJECT_DIR }); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This new block only exercises
🔁 Powered by Looper · runner=reviewer · agent=codex · An autonomous AI dev team for your GitHub repos.launchHostTooldirectly, so it does not actually lock in the observable regression described in the PR:POST /api/projects/:id/open-inreturning a non-OKEDITOR_LAUNCH_FAILEDresponse when the launch is refused. If the route later regressed back to swallowing!launch.okor mistakenly mapped that branch to200 { ok: true }, both of these tests would still stay green because the helper invariant would keep passing. Please add one route-level spec that forces the launch path to fail and asserts the HTTP status plus error code/body from/api/projects/:id/open-in, even if that means mockinglaunchHostToolorchild_process.spawnin the daemon test harness.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Added in 8b84778 —
host-tools-open-in-route.test.tsnow exercises the full route:registerHostToolsRoutesmounted on a real Express app with stub deps,spawnmocked at thenode:child_processboundary (per your suggestion) so the refused-launch path runs deterministically on any CI platform, andfs.accessmocked so the editor probe resolves without an installed editor.Assertions lock the observable contract you described: forcing the launch to fail asserts HTTP 500 with
error.code === 'EDITOR_LAUNCH_FAILED'and the message body (Failed to launch Cursor: spawn cursor ENOENT), plus the200 { ok: true, editorId, path }counterpart.Falsifiability check: re-introducing the swallow (
if (false && !launch.ok)) turns the new test red, so a regression back to200 { ok: true }can't stay green. Daemon typecheck + both host-tools suites pass (6 tests).