From 86e6a2ae763ae4dce88e87bb43557d0cd005949c Mon Sep 17 00:00:00 2001 From: Serhii Dziupin Date: Thu, 13 Aug 2026 15:30:54 +0300 Subject: [PATCH] Remove verified dead declarations (#2714) * chore: remove verified dead declarations Co-authored-by: Serhii Dziupin * chore: narrow unused internal exports Co-authored-by: Serhii Dziupin * chore: remove newly exposed dead helpers Co-authored-by: Serhii Dziupin * chore: remove unused deep-link serializer Co-authored-by: Serhii Dziupin * test: drop two tests that assert on copies of the code mainLayoutMobileSidebarMount read MainLayout.tsx and SessionSidebar.tsx as strings and asserted on source substrings down to exact indentation, so it failed on formatting rather than behaviour. useProjectSessionSelection.test reimplemented the hook's visitNodes logic inside the test file and asserted against that copy, so it could not observe the hook at all. Co-authored-by: Serhii Dziupin * test: repair sync suites that had rotted while unrunnable No runner executed packages/ui, so these drifted from the source unnoticed: two imported helpers that are no longer exported, one directory-store stub predated the session field routeMessage reads, and the WebSocket fake missed the mandatory url-token mint plus the close event the socket wrapper reads. Co-authored-by: Serhii Dziupin * test: stop the web suite failing on timeouts and a hand-copied mock The Git suites drive a real git binary, so the 5s default made a valid suite fail differently per run. The gitApiHttp mock listed ~70 export names by hand and fell behind the source; it now derives every stub from the real module, which the added shared-UI aliases make resolvable. Co-authored-by: Serhii Dziupin * test: run every suite from one command and in CI packages/ui (232 files) and packages/vscode (22) had no test script at all, CI ran neither, and 9 vscode files could never run because Node cannot resolve their extensionless TypeScript imports. Three electron files sat outside every script list, one of them importing vitest, which that package does not depend on. A runner gives each file its own process, since these suites keep module-level singletons and fail by load order when sharing one. Co-authored-by: Serhii Dziupin * chore: delete a superseded repro harness and a completed plan The issue-2638 harness needed lsof, overrode process.platform and spawned real servers, and nothing referenced it; event-stream/rebind.test.js now covers the same hub-pinned-to-the-old-port behaviour. The pairing v2 plan described relay and the pairing UI as out of scope, both of which shipped. Co-authored-by: Serhii Dziupin * docs: point at the theme tools and record the github barrel invariant convert-vscode-theme and harmonize-theme were referenced nowhere, so the theme-authoring reference now names them. The github barrel is loaded through await import('./index.js') and destructured per route, which no static report can see; documenting that is what stops the next cleanup from deleting it. Co-authored-by: Serhii Dziupin * test: repair merge drift in bridge and route-registry mocks upstream/main gained upsertProviderConfig on bridge-system-runtime and a PATCH scheduled-task route after this branch forked. Their test doubles were never updated to match: - bridge-system-runtime.test.js: add upsertProviderConfig to the opencodeConfig mock so the import resolves. - sse-routes.test.js: add app.patch to the route registry stub. --------- Co-authored-by: Serhii Dziupin --- .../theme-system/references/adding-themes.md | 9 + .github/workflows/oc-review.yml | 3 + CONTRIBUTING.md | 7 + docs/pairing-v2-implementation-plan.md | 948 ------------------ package.json | 1 + packages/electron/linux-app-discovery.mjs | 12 +- packages/electron/linux-autostart.mjs | 2 +- packages/electron/opencode-cwd.test.mjs | 2 +- packages/electron/package.json | 1 + packages/electron/path-open-utils.mjs | 2 +- packages/electron/updater-check.mjs | 2 +- packages/ui/package.json | 3 +- packages/ui/src/apps/deepLinkNavigation.ts | 10 +- packages/ui/src/apps/deepLinks.ts | 45 +- packages/ui/src/apps/mobileConnections.ts | 14 +- packages/ui/src/apps/mobilePaths.ts | 7 - packages/ui/src/apps/mobileWidgetSnapshot.ts | 2 +- .../chat/composer/editor/composerLanguage.ts | 7 +- .../components/chat/composer/editor/theme.ts | 2 +- .../chat/composer/language/triggers.ts | 2 - .../ui/src/components/chat/composer/text.ts | 2 +- .../chat/composer/ui/DraftTargetSelectors.tsx | 2 +- .../chat/message/selectionMarkdown.ts | 2 +- .../mainLayoutMobileSidebarMount.test.ts | 48 - .../projects/useProjectIdentityForm.ts | 2 +- .../providers/custom-provider-form.ts | 8 +- .../sections/shared/SettingsSection.tsx | 2 +- .../hooks/useProjectSessionSelection.test.ts | 547 ---------- .../hooks/useProjectSessionSelection.ts | 1 - .../sidebar/hooks/useSessionPrefetch.ts | 2 +- .../ui/src/components/ui/dropdown-trigger.ts | 5 +- .../src/components/views/PierreDiffViewer.tsx | 2 +- packages/ui/src/hooks/useLocalTTS.ts | 2 +- packages/ui/src/hooks/useSessionAssist.ts | 2 +- packages/ui/src/lib/desktop.ts | 2 +- packages/ui/src/lib/desktopRelayRestore.ts | 2 +- packages/ui/src/lib/gitApi.ts | 25 - packages/ui/src/lib/hardwareKeyboard.ts | 4 +- packages/ui/src/lib/i18n/messages/fr.ts | 2 - .../ui/src/lib/opencode/provider-tracker.ts | 17 +- packages/ui/src/lib/relay/protocol.ts | 11 - packages/ui/src/lib/runtime-switch.ts | 3 - packages/ui/src/lib/runtimeSurface.ts | 2 +- packages/ui/src/stores/DOCUMENTATION.md | 1 - .../ui/src/stores/useGitHubPrStatusStore.ts | 36 +- .../ui/src/stores/useSessionDisplayStore.ts | 2 +- .../src/sync/__tests__/event-pipeline.test.js | 18 +- .../src/sync/__tests__/live-aggregate.test.js | 16 - packages/ui/src/sync/session-actions.ts | 2 +- packages/ui/src/sync/session-ui-store.ts | 3 - .../sync/session-worktree-contract.test.js | 21 - packages/ui/src/sync/sync-context.tsx | 33 - packages/vscode/package.json | 1 + .../vscode/src/bridge-system-runtime.test.js | 1 + packages/web/bin/lib/commands-models.js | 2 +- packages/web/server/lib/git/credentials.js | 33 - .../web/server/lib/github/DOCUMENTATION.md | 2 +- packages/web/server/lib/walkthrough/index.js | 2 - packages/web/server/sse-routes.test.js | 3 + packages/web/src/api/git.test.ts | 76 +- packages/web/vitest.config.ts | 28 +- scripts/repro/issue-2638/README.md | 47 - .../repro/issue-2638/fake-opencode-serve.mjs | 151 --- scripts/repro/issue-2638/reproduce-2638.mjs | 376 ------- scripts/run-isolated-tests.mjs | 117 +++ 65 files changed, 238 insertions(+), 2509 deletions(-) delete mode 100644 docs/pairing-v2-implementation-plan.md delete mode 100644 packages/ui/src/components/layout/__tests__/mainLayoutMobileSidebarMount.test.ts delete mode 100644 packages/ui/src/components/session/sidebar/hooks/useProjectSessionSelection.test.ts delete mode 100644 scripts/repro/issue-2638/README.md delete mode 100755 scripts/repro/issue-2638/fake-opencode-serve.mjs delete mode 100644 scripts/repro/issue-2638/reproduce-2638.mjs create mode 100644 scripts/run-isolated-tests.mjs diff --git a/.agents/skills/theme-system/references/adding-themes.md b/.agents/skills/theme-system/references/adding-themes.md index 0209a660..776730e5 100644 --- a/.agents/skills/theme-system/references/adding-themes.md +++ b/.agents/skills/theme-system/references/adding-themes.md @@ -43,6 +43,15 @@ export const presetThemes: Theme[] = [ bun run type-check && bun run lint && bun run build ``` +## Authoring Tools + +Both do the mechanical work of steps 1–2 and are run by hand: + +- `node scripts/convert-vscode-theme.cjs ` converts a VS Code + theme into this format and registers it in `presets.ts`. +- `node scripts/harmonize-theme.mjs [--write]` aligns accent roles + to one chroma/lightness target in OKLCH so borrowed colors read as one family. + ## Key Files - Theme types: `packages/ui/src/types/theme.ts` diff --git a/.github/workflows/oc-review.yml b/.github/workflows/oc-review.yml index be7aba8b..7fccf5ba 100644 --- a/.github/workflows/oc-review.yml +++ b/.github/workflows/oc-review.yml @@ -32,6 +32,9 @@ jobs: - name: Lint run: bun run lint + - name: Tests + run: bun run test + - name: Electron Linux packaging unit tests working-directory: packages/electron run: | diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 859a2f62..9e6ba882 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -113,9 +113,16 @@ The final AppImage verifier checks desktop identity and the architecture of Elec ```bash bun run type-check # Must pass bun run lint # Must pass +bun run test # Must pass bun run build # Must succeed ``` +`bun run test` runs every suite in the repository: shared UI, VS Code, Electron, +web/server, and the root scripts. The UI, VS Code, and Electron suites keep +module-level singletons, so `scripts/run-isolated-tests.mjs` gives each test file +its own process instead of letting load order decide the result. Run a single +file directly while iterating (`bun test `). + For docs-only changes, validation may be enough: ```bash diff --git a/docs/pairing-v2-implementation-plan.md b/docs/pairing-v2-implementation-plan.md deleted file mode 100644 index 1b4e9dc6..00000000 --- a/docs/pairing-v2-implementation-plan.md +++ /dev/null @@ -1,948 +0,0 @@ -# Pairing v2 Trusted-Device Issuance Backend Plan - -## Scope - -Implement the Pairing v2 mechanism without UI. - -Included: - -- Backend pairing session runtime. -- Pairing create/redeem/cancel routes. -- Trusted-device token issuance through the existing remote client auth runtime. -- Backward-compatible remote client metadata extension. -- Password/passkey issuance metadata alignment. -- Shared v2 `openchamber://connect` payload helpers. - -Not included: - -- Settings page. -- QR modal. -- Pair Device button. -- Device list UI. -- Translations/copy. -- Relay implementation. -- LAN discovery. -- End-user polished mobile/desktop screens. - -## Naming - -Use the existing `client-auth` domain. - -New module: - -```text -packages/web/server/lib/client-auth/pairing.js -``` - -Existing durable token module remains: - -```text -packages/web/server/lib/client-auth/remote-clients.js -``` - -Conceptual names: - -```text -Remote client -Trusted-device client token -Pairing session -Pairing secret -Pairing redeem -``` - -Deep link stays: - -```text -openchamber://connect -``` - -Versions: - -```text -v=1 => legacy server + long-lived token import -v=2 => one-time pairing handshake -``` - -## New Files - -### 1. `packages/web/server/lib/client-auth/pairing.js` - -Create a new backend runtime module for short-lived pairing sessions. - -Responsibilities: - -```text -createPairingSession -getPairingSession -cancelPairingSession -redeemPairingSession -sweepExpiredSessions -``` - -Store file: - -```text -OPENCHAMBER_DATA_DIR/client-pairing-sessions.json -``` - -Suggested store shape: - -```json -{ - "version": 1, - "sessions": [ - { - "id": "pair_...", - "secretHash": "...", - "createdAt": "...", - "expiresAt": "...", - "usedAt": null, - "cancelledAt": null, - "clientId": null, - "label": "Pair new device", - "fingerprint": "ABCD-1234", - "allowedClientKinds": ["mobile", "desktop"], - "createdByClientId": null - } - ] -} -``` - -Security requirements: - -```text -Persist only secretHash. -Return plaintext secret only from createPairingSession. -Redeem is one-time. -Redeem is expiry-aware. -Redeem is cancellation-aware. -Redeem must be mutation-serialized to avoid double issuance. -No raw token/secret logging. -``` - -Public methods should accept injected dependencies, following `remote-clients.js` style: - -```js -createClientPairingRuntime({ - fsPromises, - path, - crypto, - storePath, - remoteClientAuthRuntime, -}) -``` - -## Existing Files To Update - -### 2. `packages/web/server/lib/client-auth/remote-clients.js` - -Extend trusted-device metadata backward-compatibly. - -Current `createClient` input: - -```js -{ - label, - expiresAt, - clientKind, - dedupeKey, -} -``` - -Extend to: - -```js -{ - label, - expiresAt, - clientKind, - dedupeKey, - authMethod, - pairingId, - deviceName, - devicePlatform, - deviceModel, - appVersion, -} -``` - -Add normalized public fields: - -```text -authMethod -pairingId -deviceName -devicePlatform -deviceModel -appVersion -``` - -Backward compatibility rules: - -```text -Existing remote-clients.json remains valid. -Missing new fields normalize to null. -Existing tokens continue authenticating. -Public client output never exposes tokenHash. -Raw token is returned only from createClient. -``` - -Recommended `authMethod` values: - -```text -pairing -password -passkey -desktop-local -manual -legacy -``` - -Do not force migration for old records. Treat missing `authMethod` as legacy/null. - -### 3. `packages/web/server/index.js` - -Instantiate the new pairing runtime next to `remoteClientAuthRuntime`. - -Existing: - -```js -const remoteClientAuthRuntime = createRemoteClientAuthRuntime({ - fsPromises, - path, - crypto, - storePath: REMOTE_CLIENTS_FILE_PATH, -}); -``` - -Add: - -```js -const CLIENT_PAIRING_SESSIONS_FILE_PATH = path.join( - OPENCHAMBER_DATA_DIR, - 'client-pairing-sessions.json', -); -``` - -Then: - -```js -const clientPairingRuntime = createClientPairingRuntime({ - fsPromises, - path, - crypto, - storePath: CLIENT_PAIRING_SESSIONS_FILE_PATH, - remoteClientAuthRuntime, -}); -``` - -Pass `clientPairingRuntime` into `registerAuthAndAccessRoutes` dependencies. - -### 4. `packages/web/server/lib/opencode/core-routes.js` - -Add pairing routes near existing client-auth routes: - -```text -/api/client-auth/clients -``` - -Add: - -```http -POST /api/client-auth/pairing/sessions -DELETE /api/client-auth/pairing/sessions/:id -POST /api/client-auth/pairing/redeem -``` - -Optional, can be deferred: - -```http -GET /api/client-auth/pairing/sessions/:id -``` - -Since UI polling is out of scope, `GET` is not required for this phase. - -#### Route: `POST /api/client-auth/pairing/sessions` - -Purpose: - -```text -Create one short-lived pairing session and return data needed to build QR/deep link. -``` - -Auth: - -```text -Require UI session auth. -Allow desktop-local client only if consistent with existing client-create exception. -Reject arbitrary remote client tokens. -Reject url-token auth. -``` - -Request: - -```json -{ - "label": "Pair new device", - "allowedClientKinds": ["mobile", "desktop"] -} -``` - -Response: - -```json -{ - "pairing": { - "id": "pair_...", - "secret": "one_time_secret", - "expiresAt": "...", - "fingerprint": "ABCD-1234", - "label": "Pair new device" - }, - "server": { - "label": "OpenChamber", - "candidates": [ - { - "type": "lan", - "url": "http://192.168.1.20:4096", - "priority": 10 - }, - { - "type": "tunnel", - "url": "https://abc.ngrok.app", - "priority": 20 - } - ] - } -} -``` - -Headers: - -```http -Cache-Control: no-store -``` - -Note: - -```text -This route does not render QR. -UI can later encode the returned data into openchamber://connect?v=2&p=... -``` - -#### Route: `DELETE /api/client-auth/pairing/sessions/:id` - -Purpose: - -```text -Cancel an unused pairing session. -``` - -Auth: - -```text -Require owner/session auth. -``` - -Behavior: - -```text -Set cancelledAt. -Do not delete immediately. -If already used, cancellation should not revoke the issued client. -``` - -Response: - -```json -{ - "cancelled": true -} -``` - -#### Route: `POST /api/client-auth/pairing/redeem` - -Purpose: - -```text -Exchange pairingId + one-time secret for a trusted-device client token. -``` - -Auth: - -```text -No existing auth required. -The one-time pairing secret is the authentication factor. -``` - -Request: - -```json -{ - "pairingId": "pair_...", - "secret": "one_time_secret", - "clientLabel": "Iryna iPhone", - "clientKind": "mobile", - "deviceName": "Iryna iPhone", - "devicePlatform": "ios", - "deviceModel": "iPhone", - "appVersion": "1.12.0", - "dedupeKey": "optional-stable-device-key" -} -``` - -Server behavior: - -```text -Validate pairing exists. -Validate secret using constant-time comparison. -Validate not expired. -Validate not cancelled. -Validate not used. -Validate clientKind is allowed. -Mark pairing used. -Create remote client through remoteClientAuthRuntime.createClient. -Return clientToken once. -``` - -Create client with: - -```js -{ - label: clientLabel || deviceName || 'Remote client', - clientKind, - dedupeKey, - authMethod: 'pairing', - pairingId, - deviceName, - devicePlatform, - deviceModel, - appVersion, -} -``` - -Response: - -```json -{ - "ok": true, - "server": { - "label": "OpenChamber", - "url": "https://selected-or-current-url", - "fingerprint": "ABCD-1234" - }, - "client": { - "id": "device_...", - "label": "Iryna iPhone", - "clientKind": "mobile", - "authMethod": "pairing", - "createdAt": "..." - }, - "clientToken": "oc_client_..." -} -``` - -Headers: - -```http -Cache-Control: no-store -``` - -Failure response should be generic: - -```json -{ - "error": "Invalid or expired pairing session" -} -``` - -Do not reveal whether id, secret, expiry, used, or cancellation caused failure. - -### 5. `packages/web/server/lib/ui-auth/ui-auth.js` - -Preserve existing password/passkey behavior. - -Only add metadata to client token issuance when `issueClientToken === true`. - -Password issuance should pass: - -```js -authMethod: 'password' -clientKind: req.body?.clientKind -dedupeKey: req.body?.dedupeKey -deviceName: req.body?.deviceName -devicePlatform: req.body?.devicePlatform -deviceModel: req.body?.deviceModel -appVersion: req.body?.appVersion -``` - -Passkey issuance should pass: - -```js -authMethod: 'passkey' -clientKind: req.body?.clientKind -dedupeKey: req.body?.dedupeKey -deviceName: req.body?.deviceName -devicePlatform: req.body?.devicePlatform -deviceModel: req.body?.deviceModel -appVersion: req.body?.appVersion -``` - -Backward compatibility: - -```text -Existing POST /auth/session payload still works. -Existing response shape still works. -Existing clientToken issuance still works. -Password login remains disabled for tunnel/public scope. -``` - -### 6. `packages/ui/src/lib/connectionPayload.ts` - -Extend existing connect payload helpers. - -Keep current v1 behavior: - -```text -openchamber://connect?v=1&server=...&token=...&label=... -``` - -Add v2 payload types and helpers. - -Suggested types: - -```ts -export type ClientConnectionPayloadV1 = { - v: 1; - serverUrl: string; - token: string; - label?: string; -}; - -export type PairingEndpointCandidate = { - type: 'lan' | 'tunnel' | 'relay'; - url: string; - priority?: number; -}; - -export type PairingConnectionPayloadV2 = { - v: 2; - pairingId: string; - secret: string; - label?: string; - fingerprint?: string; - expiresAt?: string; - candidates: PairingEndpointCandidate[]; -}; -``` - -Suggested helpers: - -```ts -encodePairingConnectionPayload(payload: PairingConnectionPayloadV2): string -parsePairingConnectionPayload(value: string): PairingConnectionPayloadV2 | null -``` - -Use deep link format: - -```text -openchamber://connect?v=2&p= -``` - -Validation: - -```text -Require v=2. -Require pairingId. -Require secret. -Require at least one valid http/https candidate. -Reject malformed URL. -Reject oversized payload. -Reject expired payload locally if expiresAt is clearly in the past. -``` - -Do not break current exports used by mobile QR/manual connect. - -### 7. `packages/ui/src/apps/mobileQrScan.ts` - -Update parser only. - -Current scan parser recognizes legacy fields like: - -```text -server -label -``` - -Add support for v2 connect links. - -Output should be able to distinguish: - -```text -legacy v1 token import -pairing v2 payload -plain URL -``` - -Do not implement full mobile UI flow in this scope unless there is already a non-UI callable path. - -### 8. `packages/ui/src/apps/mobileConnections.ts` - -Add non-visual callable mechanism for redeeming pairing payload. - -Add a function conceptually like: - -```ts -redeemPairingConnection(payload: PairingConnectionPayloadV2): Promise -``` - -Responsibilities: - -```text -Try endpoint candidates. -POST /api/client-auth/pairing/redeem. -Persist issued token securely. -Persist connection metadata. -Switch runtime only after token write succeeds. -``` - -No new screens/buttons. - -Existing password flow remains unchanged. - -Candidate selection: - -```text -Normalize candidates. -Probe /health with timeout. -Try candidates by priority. -Prefer HTTPS when priority ties. -If network failure, try next candidate. -If server says invalid/expired/used, stop. -``` - -Mobile native should reuse existing native HTTP fallback path for LAN HTTP. - -### 9. `packages/electron/main.mjs` - -Extend existing connect deep-link handling. - -Current v1 behavior: - -```text -openchamber://connect?v=1&server=...&token=... -``` - -Keep it. - -Add v2 branch: - -```text -openchamber://connect?v=2&p=... -``` - -Behavior: - -```text -Parse v2 payload. -Show confirmation before redeem/write/switch. -Probe candidates. -Redeem pairing secret. -Store returned clientToken in desktop hosts config. -Ask/switch according to existing remote host behavior. -Never show token. -Never write config before confirmation. -``` - -If this phase is strictly backend-only, this file can be deferred. But if desktop app as client must be functionally supported by deep link in this phase, include this change. - -### 10. `packages/electron/preload.mjs` - -No change expected unless a renderer-side desktop API is needed for pairing redeem. - -Prefer keeping pairing redeem in main process only for deep-link handling if desktop v2 is implemented there. - -### 11. `packages/web/server/lib/ui-auth/DOCUMENTATION.md` - -Update module documentation to reflect the unified issuance model: - -```text -Password, passkey, and pairing are issuance methods. -Trusted-device client token is the durable credential. -Pairing v2 uses one-time secrets and issues remote client tokens. -``` - -Optionally add: - -```text -packages/web/server/lib/client-auth/DOCUMENTATION.md -``` - -if the client-auth module needs ownership docs. - -## Route Registration Summary - -Add to `registerAuthAndAccessRoutes`: - -```http -POST /api/client-auth/pairing/sessions -DELETE /api/client-auth/pairing/sessions/:id -POST /api/client-auth/pairing/redeem -``` - -Optional later: - -```http -GET /api/client-auth/pairing/sessions/:id -``` - -Route placement: - -```text -Register before generic OpenCode proxy. -Place near existing /api/client-auth/clients routes. -``` - -## Execution Sequence - -### Step 1: Extend Remote Client Metadata - -Files: - -```text -packages/web/server/lib/client-auth/remote-clients.js -``` - -Do: - -```text -Add metadata normalization. -Extend createClient input. -Extend publicClient output. -Keep old records valid. -Do not change token generation/authentication behavior. -``` - -### Step 2: Add Password/Passkey Metadata Issuance - -Files: - -```text -packages/web/server/lib/ui-auth/ui-auth.js -``` - -Do: - -```text -When issueClientToken is true, pass authMethod='password' from password login. -When issueClientToken is true, pass authMethod='passkey' from passkey auth. -Pass optional device metadata through. -Preserve response shape. -``` - -### Step 3: Create Pairing Runtime Module - -Files: - -```text -packages/web/server/lib/client-auth/pairing.js -``` - -Do: - -```text -Implement session creation. -Implement hashed secret storage. -Implement cancel. -Implement redeem. -Implement expiry/used/cancelled checks. -Integrate remoteClientAuthRuntime.createClient in redeem. -``` - -### Step 4: Instantiate Pairing Runtime - -Files: - -```text -packages/web/server/index.js -``` - -Do: - -```text -Define CLIENT_PAIRING_SESSIONS_FILE_PATH. -Instantiate createClientPairingRuntime. -Pass clientPairingRuntime to registerAuthAndAccessRoutes. -``` - -### Step 5: Add Pairing Routes - -Files: - -```text -packages/web/server/lib/opencode/core-routes.js -``` - -Do: - -```text -Destructure clientPairingRuntime from dependencies. -Add POST /api/client-auth/pairing/sessions. -Add DELETE /api/client-auth/pairing/sessions/:id. -Add POST /api/client-auth/pairing/redeem. -Use correct auth gates. -Set Cache-Control: no-store where secrets/tokens are returned. -Keep error responses generic for redeem. -``` - -### Step 6: Add v2 Payload Helpers - -Files: - -```text -packages/ui/src/lib/connectionPayload.ts -``` - -Do: - -```text -Keep v1 helpers unchanged. -Add v2 payload type. -Add encode v2 helper. -Add parse v2 helper. -Use openchamber://connect?v=2&p=. -Validate candidates. -Reject malformed/expired/oversized payloads. -``` - -### Step 7: Update QR Scan Parser Shape - -Files: - -```text -packages/ui/src/apps/mobileQrScan.ts -``` - -Do: - -```text -Recognize v2 connect payload. -Return structured v2 result. -Do not add new UI. -Do not break v1/manual URL behavior. -``` - -### Step 8: Add Non-UI Mobile Redeem Plumbing - -Files: - -```text -packages/ui/src/apps/mobileConnections.ts -``` - -Do: - -```text -Add callable redeem pairing function. -Try endpoint candidates. -Redeem via /api/client-auth/pairing/redeem. -Persist token before runtime switch. -Reuse existing storage model. -Keep password/manual connect unchanged. -``` - -### Step 9: Add Desktop Deep-Link v2 Handling If In Scope - -Files: - -```text -packages/electron/main.mjs -``` - -Do: - -```text -Extend connect deep-link parser to recognize v2. -Confirm before redeem. -Redeem against candidate endpoint. -Store remote host config with returned token. -Switch only after confirmation and successful storage. -Keep v1 behavior unchanged. -``` - -If desktop client deep-link support is deferred, skip this step and document that v2 backend/shared payload exists but desktop consumer is not wired yet. - -### Step 10: Update Documentation - -Files: - -```text -packages/web/server/lib/ui-auth/DOCUMENTATION.md -``` - -Optionally add: - -```text -packages/web/server/lib/client-auth/DOCUMENTATION.md -``` - -Document: - -```text -Unified trusted-device token issuance. -Pairing v2 flow. -Password/passkey/pairing authMethod values. -Security rules. -Backward compatibility guarantees. -``` - -## Important Non-Goals - -Do not implement: - -```text -Settings page -Pair Device button -QR modal -Device list UI -Translations -Visual design -Relay transport -LAN discovery -Account/cloud sync -Token migration to OS keychain on desktop -``` - -## Backward Compatibility Requirements - -Must remain true: - -```text -Existing v1 openchamber://connect links keep working. -Existing password login with issueClientToken keeps working. -Existing passkey issueClientToken keeps working. -Existing remote-clients.json keeps loading. -Existing client tokens keep authenticating. -Existing mobile saved connections keep working. -Existing desktop remote hosts keep working. -``` - -## Security Requirements - -Must hold: - -```text -No long-lived token in v2 link. -Pairing secret persisted only as hash. -Pairing secret returned only once. -Client token returned only once. -Token hash persisted server-side. -Redeem is one-time. -Redeem is expiry-aware. -Redeem is cancellation-aware. -Redeem errors are generic. -Password login remains disabled for tunnel/public scope. -Pairing session creation requires owner/session auth. -Pairing redeem requires no prior auth but requires valid one-time secret. -Desktop v2 connect confirms before writing host config or switching runtime. -``` diff --git a/package.json b/package.json index 44d8ff29..329094f1 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,7 @@ "lint:ui": "bun run --cwd packages/ui lint", "lint:electron": "bun run --cwd packages/electron lint", "lint:mobile": "bun run --cwd packages/mobile lint", + "test": "node scripts/run-isolated-tests.mjs scripts && bun run --cwd packages/ui test && bun run --cwd packages/vscode test && bun run --cwd packages/electron test && bun run --cwd packages/web test", "clean": "bun run --filter '*' clean", "changelog-card": "node scripts/changelog-card/generate.mjs", "postinstall": "node ./fix-deprecation.js && patch-package && node ./packages/electron/scripts/ensure-electron.mjs --best-effort", diff --git a/packages/electron/linux-app-discovery.mjs b/packages/electron/linux-app-discovery.mjs index 28e11900..c98947d4 100644 --- a/packages/electron/linux-app-discovery.mjs +++ b/packages/electron/linux-app-discovery.mjs @@ -8,7 +8,7 @@ const DEFAULT_XDG_DATA_DIRS = ['/usr/local/share', '/usr/share']; const TARGET_FIELD_CODES = new Set(['f', 'F', 'u', 'U']); const TERMINAL_APP_IDS = new Set(['terminal', 'iterm2', 'ghostty']); -export const LINUX_CLI_BY_APP_ID = { +const LINUX_CLI_BY_APP_ID = { vscode: 'code', cursor: 'cursor', vscodium: 'codium', @@ -43,7 +43,7 @@ const normalizeComparable = (value) => String(value || '') .trim(); const normalizeCompactComparable = (value) => normalizeComparable(value).replace(/\s+/g, ''); -export const stripDesktopExecFieldCodes = (execValue) => String(execValue || '') +const stripDesktopExecFieldCodes = (execValue) => String(execValue || '') .replace(/%%/g, '\^@') .replace(/%[fFuUdDnNickvm]/g, '') .replace(/%./g, '') @@ -142,9 +142,7 @@ export const readLinuxDesktopEntries = async (options = {}) => { return entries.sort((left, right) => left.name.localeCompare(right.name)); }; -export const discoverLinuxDesktopApps = readLinuxDesktopEntries; - -export const desktopEntryMatchesApp = (entry, appName, appId = '') => { +const desktopEntryMatchesApp = (entry, appName, appId = '') => { const needles = uniqueStrings([appName, appId]).flatMap((value) => [normalizeComparable(value), normalizeCompactComparable(value)]).filter(Boolean); const haystacks = [entry.name, entry.id, path.basename(entry.filePath || ''), entry.exec] .flatMap((value) => [normalizeComparable(value), normalizeCompactComparable(value)]); @@ -331,7 +329,7 @@ const pathExistsSync = (candidate) => { } }; -export const linuxIconThemeDirs = ({ env = process.env, homeDir = os.homedir() } = {}) => { +const linuxIconThemeDirs = ({ env = process.env, homeDir = os.homedir() } = {}) => { const dataHome = typeof env.XDG_DATA_HOME === 'string' && env.XDG_DATA_HOME.trim() ? env.XDG_DATA_HOME.trim() : path.join(homeDir || os.homedir(), '.local', 'share'); @@ -419,7 +417,7 @@ export const resolveLinuxIconFile = (iconName, options = {}) => { return null; }; -export const resolveDefaultLinuxFileManagerId = ({ env = process.env, execFileSyncImpl = execFileSync } = {}) => { +const resolveDefaultLinuxFileManagerId = ({ env = process.env, execFileSyncImpl = execFileSync } = {}) => { try { const output = String(execFileSyncImpl('xdg-mime', ['query', 'default', 'inode/directory'], { encoding: 'utf8', diff --git a/packages/electron/linux-autostart.mjs b/packages/electron/linux-autostart.mjs index 8f370fda..668f87b3 100644 --- a/packages/electron/linux-autostart.mjs +++ b/packages/electron/linux-autostart.mjs @@ -5,7 +5,7 @@ import path from 'node:path'; const AUTOSTART_FILE_NAME = 'openchamber.desktop'; -export const resolveLinuxAutostartDirectory = ({ +const resolveLinuxAutostartDirectory = ({ env = process.env, homeDir = os.homedir(), } = {}) => { diff --git a/packages/electron/opencode-cwd.test.mjs b/packages/electron/opencode-cwd.test.mjs index 84805c4a..41c0d20b 100644 --- a/packages/electron/opencode-cwd.test.mjs +++ b/packages/electron/opencode-cwd.test.mjs @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest'; +import { describe, expect, it } from 'bun:test'; import { resolveManagedOpenCodeCwd } from './opencode-cwd.mjs'; diff --git a/packages/electron/package.json b/packages/electron/package.json index c52f8b46..fb6e7762 100644 --- a/packages/electron/package.json +++ b/packages/electron/package.json @@ -40,6 +40,7 @@ "bundle:main": "bun ./scripts/bundle-main.mjs", "generate:macos-icon": "node ./scripts/generate-macos-icon-assets.cjs", "rebuild:native": "node ./scripts/rebuild-native.mjs", + "test": "node ../../scripts/run-isolated-tests.mjs .", "test:architecture": "node --test ./startup-url-selection.test.mjs ./scripts/target-architecture.test.mjs ./scripts/verify-linux-appimage.test.mjs ./scripts/verify-update-manifest.test.mjs ./scripts/ensure-electron.test.mjs", "test:updater": "node --test ./updater-capability.test.mjs ./updater-channel.test.mjs ./updater-check.test.mjs ./updater-feed.test.mjs ./scripts/finalize-latest-yml.test.mjs ./scripts/updater-e2e-fixture.test.mjs", "test:linux-desktop": "node --test ./linux-autostart.test.mjs && node ./scripts/smoke-linux-app-discovery.mjs && node ./scripts/smoke-path-open-utils.mjs", diff --git a/packages/electron/path-open-utils.mjs b/packages/electron/path-open-utils.mjs index c83caf8e..b010579d 100644 --- a/packages/electron/path-open-utils.mjs +++ b/packages/electron/path-open-utils.mjs @@ -12,7 +12,7 @@ const accessErrorMessage = (label, targetPath, error) => { return `${label} could not be checked: ${error?.message || String(error)}`; }; -export const normalizeRequiredPath = (rawPath, label = 'Path') => { +const normalizeRequiredPath = (rawPath, label = 'Path') => { const targetPath = typeof rawPath === 'string' ? rawPath.trim() : ''; if (!targetPath) { throw new Error(`${label} is required`); diff --git a/packages/electron/updater-check.mjs b/packages/electron/updater-check.mjs index 77b80c63..b5179aea 100644 --- a/packages/electron/updater-check.mjs +++ b/packages/electron/updater-check.mjs @@ -1,7 +1,7 @@ const MISSING_UPDATE_FEED_RE = /404|ENOTFOUND|Cannot find (?:channel|latest)|latest-linux(?:-arm64)?\.yml|HttpError:\s*404|status code 404/i; -export const isMissingUpdateFeedError = (error) => { +const isMissingUpdateFeedError = (error) => { const message = error instanceof Error ? error.message : String(error ?? ''); return MISSING_UPDATE_FEED_RE.test(message); }; diff --git a/packages/ui/package.json b/packages/ui/package.json index aacac79f..56696d59 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -8,7 +8,8 @@ "dev": "tsc --noEmit --watch", "build": "tsc --noEmit", "type-check": "tsc --noEmit", - "lint": "eslint \"./src/**/*.{ts,tsx}\" --config ../../eslint.config.js" + "lint": "eslint \"./src/**/*.{ts,tsx}\" --config ../../eslint.config.js", + "test": "node ../../scripts/run-isolated-tests.mjs src" }, "dependencies": { "@aparajita/capacitor-secure-storage": "^8.0.0", diff --git a/packages/ui/src/apps/deepLinkNavigation.ts b/packages/ui/src/apps/deepLinkNavigation.ts index 80bd2de0..938c4126 100644 --- a/packages/ui/src/apps/deepLinkNavigation.ts +++ b/packages/ui/src/apps/deepLinkNavigation.ts @@ -3,7 +3,7 @@ import React from 'react'; import { isCapacitorApp } from '@/lib/platform'; import { useSessionUIStore } from '@/sync/session-ui-store'; -import { buildDeepLink, parseDeepLink, type DeepLinkIntent, type SessionsFilter, type ViewTarget } from './deepLinks'; +import { parseDeepLink, type DeepLinkIntent, type SessionsFilter, type ViewTarget } from './deepLinks'; /** * Navigation layer for {@link DeepLinkIntent}s — the only place that knows how to *apply* a @@ -93,13 +93,13 @@ const flush = (): void => { }; /** Apply an intent now if possible, otherwise stash it until the app is ready / a handler appears. */ -export const applyDeepLinkIntent = (intent: DeepLinkIntent): void => { +const applyDeepLinkIntent = (intent: DeepLinkIntent): void => { pending = intent; flush(); }; /** Convenience: parse a raw `openchamber://…` URL and apply it. No-op for unrecognised URLs. */ -export const applyDeepLinkUrl = (raw: string | null | undefined): void => { +const applyDeepLinkUrl = (raw: string | null | undefined): void => { const intent = parseDeepLink(raw); if (intent) { applyDeepLinkIntent(intent); @@ -192,7 +192,3 @@ export const useDeepLinkSource = (options: { ready: boolean }): void => { }; }, []); }; - -// Re-export so producers (notifications, future widgets) have one import for the whole vocabulary. -export { buildDeepLink, parseDeepLink }; -export type { DeepLinkIntent, SessionsFilter, ViewTarget }; diff --git a/packages/ui/src/apps/deepLinks.ts b/packages/ui/src/apps/deepLinks.ts index f4c3f213..43d92bc3 100644 --- a/packages/ui/src/apps/deepLinks.ts +++ b/packages/ui/src/apps/deepLinks.ts @@ -10,7 +10,7 @@ * context — including, eventually, a tiny encoder shared with the native widget/extension. */ -export const DEEP_LINK_SCHEME = 'openchamber'; +const DEEP_LINK_SCHEME = 'openchamber'; export type SessionsFilter = 'all' | 'attention' | 'recent'; export type ViewTarget = 'files' | 'mcp' | 'instances' | 'update'; @@ -124,46 +124,3 @@ export function parseDeepLink(raw: string | null | undefined): DeepLinkIntent | return null; } } - -/** - * Build a canonical `openchamber://…` URL for an intent. Used by anything that needs to hand - * a deep link to iOS — notification payloads, `widgetURL(...)`, Live Activity tap targets — - * so every producer emits the exact shape {@link parseDeepLink} understands. - */ -export function buildDeepLink(intent: DeepLinkIntent): string { - const base = `${DEEP_LINK_SCHEME}://`; - const withQuery = (path: string, params: Record): string => { - const search = new URLSearchParams(); - for (const [key, value] of Object.entries(params)) { - if (typeof value === 'string' && value.length > 0) { - search.set(key, value); - } - } - const query = search.toString(); - return query ? `${base}${path}?${query}` : `${base}${path}`; - }; - - switch (intent.type) { - case 'session': - return withQuery(`session/${encodeURIComponent(intent.sessionId)}`, { dir: intent.directory }); - case 'new-session': - return withQuery('new', { - dir: intent.directory, - project: intent.projectId, - agent: intent.agent, - model: intent.model, - }); - case 'sessions': - return withQuery('sessions', { filter: intent.filter }); - case 'status': - return `${base}status`; - case 'settings': - return intent.section ? `${base}settings/${encodeURIComponent(intent.section)}` : `${base}settings`; - case 'changes': - return withQuery(intent.path ? `changes/${intent.path}` : 'changes', { - staged: intent.staged ? 'true' : undefined, - }); - case 'view': - return `${base}view/${intent.target}`; - } -} diff --git a/packages/ui/src/apps/mobileConnections.ts b/packages/ui/src/apps/mobileConnections.ts index 131fa07e..30ba5d8f 100644 --- a/packages/ui/src/apps/mobileConnections.ts +++ b/packages/ui/src/apps/mobileConnections.ts @@ -164,7 +164,7 @@ type PairingRedeemResponse = { // URL helpers // --------------------------------------------------------------------------- -export const normalizeConnectionUrl = (value: string): string => { +const normalizeConnectionUrl = (value: string): string => { const trimmed = value.trim(); if (!trimmed) return ''; const withScheme = /^https?:\/\//i.test(trimmed) ? trimmed : `http://${trimmed}`; @@ -175,7 +175,7 @@ export const normalizeConnectionUrl = (value: string): string => { return url.toString().replace(/\/+$/, ''); }; -export const getConnectionLabel = (url: string): string => { +const getConnectionLabel = (url: string): string => { try { return new URL(url).host; } catch { @@ -191,7 +191,7 @@ const getConnectionStorageKey = (url: string): string => { } }; -export const isSameConnectionUrl = (left: string, right: string): boolean => +const isSameConnectionUrl = (left: string, right: string): boolean => getConnectionStorageKey(left) === getConnectionStorageKey(right); // --------------------------------------------------------------------------- @@ -201,7 +201,7 @@ export const isSameConnectionUrl = (left: string, right: string): boolean => // Stable identity for a relay connection. Also used as the runtime key passed // to switchRuntimeEndpoint so "is this saved entry the active runtime?" checks // can compare against getRuntimeKey(). -export const relayConnectionRuntimeKey = (relay: MobileRelayConfig): string => +const relayConnectionRuntimeKey = (relay: MobileRelayConfig): string => `relay:${relay.serverId}@${relay.relayUrl.trim()}`; // Stable, non-fetchable pseudo-URL for a relay-only device (display only). @@ -790,7 +790,7 @@ export const upsertMobileConnection = async ( return next; }; -export const deleteMobileConnection = async (id: string): Promise => { +const deleteMobileConnection = async (id: string): Promise => { const connections = readConnections(); const removed = connections.find((connection) => connection.id === id) ?? null; const next = connections.filter((connection) => connection.id !== id); @@ -1147,7 +1147,7 @@ const establishLiveTransport = async ( // tunnel via runtimeFetch. A transport failure/timeout is transient (the tunnel // reconnects on its own) and must not masquerade as a revoked session, so only // an explicit auth rejection reports invalid. -export const validateActiveRuntimeSession = async (input: { +const validateActiveRuntimeSession = async (input: { url: string; clientToken?: string | null; }, options?: { fast?: boolean }): Promise => { @@ -1283,7 +1283,7 @@ let candidateRefreshInFlight = false; // Only runs for relay-paired connections: their token/runtime key derives from // the stable relay identity, so rewriting direct URLs cannot orphan the stored // token. The response must echo the connection's serverId or it is ignored. -export const refreshActiveConnectionCandidates = async (): Promise => { +const refreshActiveConnectionCandidates = async (): Promise => { if (candidateRefreshInFlight) return 'skipped'; const active = findActiveConnection(); if (!active) { diff --git a/packages/ui/src/apps/mobilePaths.ts b/packages/ui/src/apps/mobilePaths.ts index 98d49719..2800fd60 100644 --- a/packages/ui/src/apps/mobilePaths.ts +++ b/packages/ui/src/apps/mobilePaths.ts @@ -1,5 +1,3 @@ -import type { ProjectEntry } from '@/lib/api/types'; - export const normalizePath = (value?: string | null): string => (value || '').replace(/\\/g, '/').replace(/\/+$/g, ''); @@ -9,8 +7,3 @@ export const getProjectLabel = (path: string): string => { const segments = normalized.split('/').filter(Boolean); return segments[segments.length - 1]?.replace(/[-_]/g, ' ') || normalized; }; - -export const getProjectDisplayLabel = (project: ProjectEntry | null, fallbackDirectory: string): string => { - if (project) return project.label?.trim() || getProjectLabel(project.path); - return getProjectLabel(fallbackDirectory); -}; diff --git a/packages/ui/src/apps/mobileWidgetSnapshot.ts b/packages/ui/src/apps/mobileWidgetSnapshot.ts index 4b40597c..9d688570 100644 --- a/packages/ui/src/apps/mobileWidgetSnapshot.ts +++ b/packages/ui/src/apps/mobileWidgetSnapshot.ts @@ -70,7 +70,7 @@ const projectLabelForDirectory = (directory: string | null, projects: ProjectEnt return basename(directory); }; -export const buildMobileWidgetSnapshot = (): MobileWidgetSnapshot => { +const buildMobileWidgetSnapshot = (): MobileWidgetSnapshot => { const sessions = useGlobalSessionsStore.getState().activeSessions; const unseenBySession = useNotificationStore.getState().index.session.unseenCount; const notifyOnSubtasks = useUIStore.getState().notifyOnSubtasks; diff --git a/packages/ui/src/components/chat/composer/editor/composerLanguage.ts b/packages/ui/src/components/chat/composer/editor/composerLanguage.ts index 0f6acbcb..2f15557d 100644 --- a/packages/ui/src/components/chat/composer/editor/composerLanguage.ts +++ b/packages/ui/src/components/chat/composer/editor/composerLanguage.ts @@ -41,7 +41,7 @@ const languageContextField = StateField.define({ }, }); -export const EMPTY_CONTEXT: ComposerLanguageContext = { +const EMPTY_CONTEXT: ComposerLanguageContext = { inputMode: 'normal', knownAgentNames: new Set(), confirmedMentions: new Set(), @@ -90,8 +90,3 @@ export function composerLanguage(initial: ComposerLanguageContext = EMPTY_CONTEX decorationField, ]; } - -/** The context currently in effect, for callers that need to read it back. */ -export function readLanguageContext(view: EditorView): ComposerLanguageContext { - return view.state.field(languageContextField); -} diff --git a/packages/ui/src/components/chat/composer/editor/theme.ts b/packages/ui/src/components/chat/composer/editor/theme.ts index 18ff9236..7a103d9d 100644 --- a/packages/ui/src/components/chat/composer/editor/theme.ts +++ b/packages/ui/src/components/chat/composer/editor/theme.ts @@ -152,7 +152,7 @@ export const NATIVE_SELECTION_THEME_SPEC = { }, }; -export const composerNativeSelectionTheme = EditorView.theme(NATIVE_SELECTION_THEME_SPEC); +const composerNativeSelectionTheme = EditorView.theme(NATIVE_SELECTION_THEME_SPEC); /** * The native-selection arrangement, installed on every device: the theme diff --git a/packages/ui/src/components/chat/composer/language/triggers.ts b/packages/ui/src/components/chat/composer/language/triggers.ts index bc192f4a..948f40e4 100644 --- a/packages/ui/src/components/chat/composer/language/triggers.ts +++ b/packages/ui/src/components/chat/composer/language/triggers.ts @@ -114,5 +114,3 @@ function matchMention( }); return query === null ? null : { kind: 'mention', query }; } - -export type { FileMentionAutocompleteInputSource }; diff --git a/packages/ui/src/components/chat/composer/text.ts b/packages/ui/src/components/chat/composer/text.ts index 0963c6e3..6b84e650 100644 --- a/packages/ui/src/components/chat/composer/text.ts +++ b/packages/ui/src/components/chat/composer/text.ts @@ -91,7 +91,7 @@ export function buildImagePasteInsertion(pastedText: string, citationText: strin * A single-line URL pasted over a selection becomes a markdown link rather * than replacing the selected text. */ -export const PASTE_LINK_URL_PATTERN = /^(https?:\/\/|mailto:)\S+$/i; +const PASTE_LINK_URL_PATTERN = /^(https?:\/\/|mailto:)\S+$/i; /** * Whether a pasted URL should wrap the selection as `[selected](url)`. A URL diff --git a/packages/ui/src/components/chat/composer/ui/DraftTargetSelectors.tsx b/packages/ui/src/components/chat/composer/ui/DraftTargetSelectors.tsx index 4025d7a2..e3cefb15 100644 --- a/packages/ui/src/components/chat/composer/ui/DraftTargetSelectors.tsx +++ b/packages/ui/src/components/chat/composer/ui/DraftTargetSelectors.tsx @@ -54,7 +54,7 @@ const getProjectIconColor = (projectColor?: string | null): string | undefined = projectColor ? PROJECT_COLOR_MAP[projectColor] ?? undefined : undefined; /** A project's icon (custom image, configured icon, or a folder) plus its name. */ -export function ProjectLabel({ project, theme }: { project: DraftTargetProject; theme: Theme }) { +function ProjectLabel({ project, theme }: { project: DraftTargetProject; theme: Theme }) { const projectIconName = project.icon ? PROJECT_ICON_MAP[project.icon] : null; const iconColor = getProjectIconColor(project.color); const fallbackIcon = projectIconName ? ( diff --git a/packages/ui/src/components/chat/message/selectionMarkdown.ts b/packages/ui/src/components/chat/message/selectionMarkdown.ts index c4527026..0dbaa56a 100644 --- a/packages/ui/src/components/chat/message/selectionMarkdown.ts +++ b/packages/ui/src/components/chat/message/selectionMarkdown.ts @@ -58,7 +58,7 @@ const toSelectionNode = (node: Node): SelectionNode | null => { }; }; -export const trimSelectionNodes = (nodes: SelectionNode[]): SelectionNode[] => { +const trimSelectionNodes = (nodes: SelectionNode[]): SelectionNode[] => { return nodes .filter((node) => node.type === 'text' || !node.isCodeLineNumber) .map((node) => node.type === 'text' diff --git a/packages/ui/src/components/layout/__tests__/mainLayoutMobileSidebarMount.test.ts b/packages/ui/src/components/layout/__tests__/mainLayoutMobileSidebarMount.test.ts deleted file mode 100644 index 2f567528..00000000 --- a/packages/ui/src/components/layout/__tests__/mainLayoutMobileSidebarMount.test.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { describe, expect, test } from 'bun:test'; -import { readFileSync } from 'node:fs'; -import { dirname, join } from 'node:path'; -import { fileURLToPath } from 'node:url'; - -const __dirname = dirname(fileURLToPath(import.meta.url)); -const mainLayoutSource = readFileSync( - join(__dirname, '..', 'MainLayout.tsx'), - 'utf-8', -); -const sessionSidebarSource = readFileSync( - join(__dirname, '..', '..', 'session', 'SessionSidebar.tsx'), - 'utf-8', -); - -describe('MainLayout mobile SessionSidebar mount (issue #1695 regression guard)', () => { - test('mobile SessionSidebar is not conditionally mounted on mobileLeftDrawerVisible', () => { - const mobileSidebarIndex = mainLayoutSource.indexOf(' { - const desktopSidebarIndex = mainLayoutSource.indexOf(''); - expect(desktopSidebarIndex).toBeGreaterThan(-1); - - const windowStart = Math.max(0, desktopSidebarIndex - 300); - const precedingWindow = mainLayoutSource.slice(windowStart, desktopSidebarIndex); - - expect(precedingWindow).toContain(' { - expect(sessionSidebarSource).toContain('useGitAllBranches(isVisible)'); - expect(sessionSidebarSource).toContain('useGitRepoStatusMap(isVisible ? normalizedProjectPaths : EMPTY_STRING_ARRAY)'); - expect(sessionSidebarSource).toContain('enabled: isVisible,\n isSessionSearchOpen'); - expect(sessionSidebarSource).toContain('enabled: isVisible,\n isDesktopShellRuntime'); - expect(sessionSidebarSource).toContain('if (!isVisible) return EMPTY_STRING_ARRAY;'); - }); -}); diff --git a/packages/ui/src/components/sections/projects/useProjectIdentityForm.ts b/packages/ui/src/components/sections/projects/useProjectIdentityForm.ts index b6ceabea..45639afd 100644 --- a/packages/ui/src/components/sections/projects/useProjectIdentityForm.ts +++ b/packages/ui/src/components/sections/projects/useProjectIdentityForm.ts @@ -7,7 +7,7 @@ import { useProjectsStore } from '@/stores/useProjectsStore'; const HEX_COLOR_PATTERN = /^#(?:[\da-fA-F]{3}|[\da-fA-F]{6})$/; -export const normalizeProjectIconBackground = (value: string | null | undefined): string | null => { +const normalizeProjectIconBackground = (value: string | null | undefined): string | null => { if (!value) { return null; } diff --git a/packages/ui/src/components/sections/providers/custom-provider-form.ts b/packages/ui/src/components/sections/providers/custom-provider-form.ts index d734288f..49178ece 100644 --- a/packages/ui/src/components/sections/providers/custom-provider-form.ts +++ b/packages/ui/src/components/sections/providers/custom-provider-form.ts @@ -6,9 +6,9 @@ export const CUSTOM_PROVIDER_NPM = '@ai-sdk/openai-compatible'; export const CUSTOM_PROVIDER_ID = '__custom_provider__'; -export const PROVIDER_ID_PATTERN = /^[a-z0-9][a-z0-9-_]*$/; -export const BASE_URL_PATTERN = /^https?:\/\//; -export const ENV_KEY_PATTERN = /^\{env:([^}]+)\}$/; +const PROVIDER_ID_PATTERN = /^[a-z0-9][a-z0-9-_]*$/; +const BASE_URL_PATTERN = /^https?:\/\//; +const ENV_KEY_PATTERN = /^\{env:([^}]+)\}$/; export type CustomProviderTranslator = ( key: string, @@ -126,7 +126,7 @@ export const createEmptyCustomProviderForm = (): CustomProviderFormState => ({ headers: [createHeaderRow()], }); -export function parseEnvApiKey(apiKey: string): { env?: string; key?: string } { +function parseEnvApiKey(apiKey: string): { env?: string; key?: string } { const trimmed = apiKey.trim(); if (!trimmed) { return {}; diff --git a/packages/ui/src/components/sections/shared/SettingsSection.tsx b/packages/ui/src/components/sections/shared/SettingsSection.tsx index d3ea47bd..7672b8a2 100644 --- a/packages/ui/src/components/sections/shared/SettingsSection.tsx +++ b/packages/ui/src/components/sections/shared/SettingsSection.tsx @@ -59,7 +59,7 @@ export const SETTINGS_SECTION_TITLE_CLASS = /** Split-pane sidebar panel title — same level as section titles. */ export const SETTINGS_PANEL_TITLE_CLASS = SETTINGS_SECTION_TITLE_CLASS; /** L3 — control-group heading inside a section. */ -export const SETTINGS_GROUP_TITLE_CLASS = +const SETTINGS_GROUP_TITLE_CLASS = 'typography-settings-group-title text-foreground'; /** L4 — field / control labels. */ export const SETTINGS_FIELD_LABEL_CLASS = diff --git a/packages/ui/src/components/session/sidebar/hooks/useProjectSessionSelection.test.ts b/packages/ui/src/components/session/sidebar/hooks/useProjectSessionSelection.test.ts deleted file mode 100644 index 1a3c98e3..00000000 --- a/packages/ui/src/components/session/sidebar/hooks/useProjectSessionSelection.test.ts +++ /dev/null @@ -1,547 +0,0 @@ -import React, { act } from 'react'; -import { createRoot, type Root } from 'react-dom/client'; -import { afterEach, describe, expect, mock, test } from 'bun:test'; -import type { Session } from '@opencode-ai/sdk/v2'; -import type { SessionGroup, SessionNode } from '../types'; - -let currentSessionId: string | null = null; -let newSessionDraftOpen = false; -let isNewWorktreeDialogOpen = false; - -mock.module('@/stores/useUIStore', () => ({ - useUIStore: Object.assign( - (selector: (state: { isNewWorktreeDialogOpen: boolean }) => unknown) => - selector({ isNewWorktreeDialogOpen }), - { getState: () => ({ isNewWorktreeDialogOpen }) }, - ), -})); - -mock.module('@/sync/session-ui-store', () => ({ - useSessionUIStore: (selector: (state: { - currentSessionId: string | null; - newSessionDraft: { open: boolean }; - }) => unknown) => selector({ - currentSessionId, - newSessionDraft: { open: newSessionDraftOpen }, - }), -})); - -const { - resolveMissingProjectSessionSelection, - ProjectSessionSelectionEffect, -} = await import('./useProjectSessionSelection'); - -// --------------------------------------------------------------------------- -// Helper: simulate the projectSessionMeta computation from the hook -// (same visitNodes logic as useProjectSessionSelection.ts) -// --------------------------------------------------------------------------- - -type ProjectSection = { - project: { id: string; normalizedPath: string }; - groups: SessionGroup[]; -}; - -function computeProjectMeta(projectSections: ProjectSection[]) { - const metaByProject = new Map>(); - const firstSessionByProject = new Map(); - - const visitNodes = ( - projectId: string, - projectRoot: string, - fallbackDirectory: string | null, - nodes: SessionNode[], - ) => { - if (!metaByProject.has(projectId)) { - metaByProject.set(projectId, new Map()); - } - const projectMap = metaByProject.get(projectId)!; - nodes.forEach((node) => { - const sessionDirectory = ( - node.worktree?.path - ?? (node.session as Session & { directory?: string | null }).directory - ?? fallbackDirectory - ?? projectRoot - ).replace(/\\/g, '/').replace(/\/+$/, ''); - - projectMap.set(node.session.id, { directory: sessionDirectory }); - if (!firstSessionByProject.has(projectId)) { - firstSessionByProject.set(projectId, { id: node.session.id, directory: sessionDirectory }); - } - if (node.children.length > 0) { - visitNodes(projectId, projectRoot, sessionDirectory, node.children); - } - }); - }; - - projectSections.forEach((section) => { - section.groups.forEach((group) => { - visitNodes(section.project.id, section.project.normalizedPath, group.directory, group.sessions); - }); - }); - - return { metaByProject, firstSessionByProject }; -} - -// --------------------------------------------------------------------------- -// Test data -// --------------------------------------------------------------------------- - -const makeSession = (id: string, directory?: string): Session => - ({ id, directory } as unknown as Session); - -const rootSession1 = makeSession('root-session-1', '/workspace/project'); -const rootSession2 = makeSession('root-session-2', '/workspace/project'); -const worktreeSession1 = makeSession('wt-session-1', '/workspace/project-wt'); - -const project2Session1 = makeSession('project-2-session-1', '/workspace/project-2'); -const project2Session2 = makeSession('project-2-session-2', '/workspace/project-2'); - -const WORKTREE_PATH = '/workspace/project-wt'; - -// staleSections: root group only, no worktree group -const staleSections: ProjectSection[] = [ - { - project: { id: 'project-1', normalizedPath: '/workspace/project' }, - groups: [ - { - id: 'root', - label: 'Main', - branch: null, - description: null, - isMain: true, - worktree: null, - directory: '/workspace/project', - sessions: [ - { session: rootSession1, children: [], worktree: null }, - { session: rootSession2, children: [], worktree: null }, - ], - }, - ], - }, -]; - -// updatedSections: includes the worktree group -const updatedSections: ProjectSection[] = [ - { - project: { id: 'project-1', normalizedPath: '/workspace/project' }, - groups: [ - { - id: 'root', - label: 'Main', - branch: null, - description: null, - isMain: true, - worktree: null, - directory: '/workspace/project', - sessions: [ - { session: rootSession1, children: [], worktree: null }, - { session: rootSession2, children: [], worktree: null }, - ], - }, - { - id: 'wt-group', - label: 'feature-branch', - branch: 'feature-branch', - description: 'Worktree at ' + WORKTREE_PATH, - isMain: false, - worktree: { path: WORKTREE_PATH, projectDirectory: '/workspace/project', branch: 'feature-branch', label: 'feature-branch' }, - directory: WORKTREE_PATH, - sessions: [ - { session: worktreeSession1, children: [], worktree: { path: WORKTREE_PATH, projectDirectory: '/workspace/project', branch: 'feature-branch', label: 'feature-branch' } }, - ], - }, - ], - }, -]; - -// project-2Sections: separate project for project-switching tests -const project2Sections: ProjectSection[] = [ - { - project: { id: 'project-2', normalizedPath: '/workspace/project-2' }, - groups: [ - { - id: 'root', - label: 'Main', - branch: null, - description: null, - isMain: true, - worktree: null, - directory: '/workspace/project-2', - sessions: [ - { session: project2Session1, children: [], worktree: null }, - { session: project2Session2, children: [], worktree: null }, - ], - }, - ], - }, -]; - -// --------------------------------------------------------------------------- -// Tests -// --------------------------------------------------------------------------- - -describe('useProjectSessionSelection — worktree session click race', () => { - test('stale projectSections (no worktree group) excludes worktree sessions from projectMap', () => { - const { metaByProject } = computeProjectMeta(staleSections); - const projectMap = metaByProject.get('project-1'); - - // Root sessions are present - expect(projectMap?.has('root-session-1')).toBe(true); - expect(projectMap?.has('root-session-2')).toBe(true); - - // Worktree session is NOT present — this is what triggers the bug - expect(projectMap?.has('wt-session-1')).toBe(false); - }); - - test('stale data firstSessionByProject points to first root session, not worktree session', () => { - const { firstSessionByProject } = computeProjectMeta(staleSections); - - // Path C would fall back to firstSessionByProject, which is the first ROOT session - const first = firstSessionByProject.get('project-1'); - expect(first?.id).toBe('root-session-1'); - expect(first?.id).not.toBe('wt-session-1'); - }); - - test('updated projectSections includes all sessions including worktree', () => { - const { metaByProject } = computeProjectMeta(updatedSections); - const projectMap = metaByProject.get('project-1'); - - expect(projectMap?.has('root-session-1')).toBe(true); - expect(projectMap?.has('root-session-2')).toBe(true); - expect(projectMap?.has('wt-session-1')).toBe(true); - }); - - test('second click works correctly when projectSections is updated', () => { - const { metaByProject } = computeProjectMeta(updatedSections); - const projectMap = metaByProject.get('project-1')!; - const currentSessionId = 'wt-session-1'; - - // After data arrives, Path A succeeds — no guard needed - const pathAHit = Boolean(currentSessionId && projectMap?.has(currentSessionId)); - expect(pathAHit).toBe(true); - }); - - test('project switch: Path A succeeds when currentSessionId matches the new project', () => { - const { metaByProject } = computeProjectMeta(project2Sections); - const projectMap = metaByProject.get('project-2')!; - const currentSessionId = 'project-2-session-1'; - - const pathAHit = Boolean(currentSessionId && projectMap?.has(currentSessionId)); - expect(pathAHit).toBe(true); - }); -}); - -describe('resolveMissingProjectSessionSelection', () => { - test('A → B selects B remembered session when the current session is owned by A', () => { - const projectBMap = new Map([ - ['project-b-first-session', null], - ['project-b-remembered-session', null], - ]); - - expect(resolveMissingProjectSessionSelection({ - activeProjectId: 'project-b', - currentSessionId: 'stale-worktree-session-a', - currentSessionOwnerProjectId: 'project-a', - projectMap: projectBMap, - metaByProject: new Map([['project-b', projectBMap]]), - rememberedSessionId: 'project-b-remembered-session', - fallbackSessionId: 'project-b-first-session', - })).toEqual({ kind: 'select-session', sessionId: 'project-b-remembered-session' }); - }); - - test('A → B falls back to B first session when none is remembered', () => { - const projectAMap = new Map([['project-a-session', null]]); - const projectBMap = new Map([['project-b-first-session', null]]); - const metaByProject = new Map([ - ['project-a', projectAMap], - ['project-b', projectBMap], - ]); - - expect(resolveMissingProjectSessionSelection({ - activeProjectId: 'project-b', - currentSessionId: 'project-a-session', - currentSessionOwnerProjectId: 'project-a', - projectMap: projectBMap, - metaByProject, - rememberedSessionId: undefined, - fallbackSessionId: 'project-b-first-session', - })).toEqual({ kind: 'select-session', sessionId: 'project-b-first-session' }); - }); - - test('A → B opens a B-scoped draft when B is empty', () => { - expect(resolveMissingProjectSessionSelection({ - activeProjectId: 'project-b', - currentSessionId: 'project-a-session', - currentSessionOwnerProjectId: 'project-a', - projectMap: undefined, - metaByProject: new Map([['project-a', new Map([['project-a-session', null]])]]), - rememberedSessionId: undefined, - fallbackSessionId: null, - })).toEqual({ kind: 'open-draft' }); - }); - - test('preserves a same-project worktree session missing from a stale projectMap', () => { - const projectMap = new Map([['root-session-1', null]]); - const metaByProject = new Map([['project-1', projectMap]]); - - expect(resolveMissingProjectSessionSelection({ - activeProjectId: 'project-1', - currentSessionId: 'wt-session-1', - currentSessionOwnerProjectId: 'project-1', - projectMap, - metaByProject, - rememberedSessionId: undefined, - fallbackSessionId: 'root-session-1', - })).toEqual({ kind: 'preserve-current' }); - }); - - test('preserves an unknown session while worktree metadata may still be loading', () => { - const projectMap = new Map([['root-session-1', null]]); - const metaByProject = new Map([['project-1', projectMap]]); - - expect(resolveMissingProjectSessionSelection({ - activeProjectId: 'project-1', - currentSessionId: 'wt-session-1', - currentSessionOwnerProjectId: null, - projectMap, - metaByProject, - rememberedSessionId: undefined, - fallbackSessionId: 'root-session-1', - })).toEqual({ kind: 'preserve-current' }); - }); - - test('unknown ownership still switches when the session already appears under another project', () => { - const projectAMap = new Map([['project-a-session', null]]); - const projectBMap = new Map([ - ['project-b-first-session', null], - ['project-b-remembered-session', null], - ]); - const metaByProject = new Map([ - ['project-a', projectAMap], - ['project-b', projectBMap], - ]); - - expect(resolveMissingProjectSessionSelection({ - activeProjectId: 'project-b', - currentSessionId: 'project-a-session', - currentSessionOwnerProjectId: null, - projectMap: projectBMap, - metaByProject, - rememberedSessionId: 'project-b-remembered-session', - fallbackSessionId: 'project-b-first-session', - })).toEqual({ kind: 'select-session', sessionId: 'project-b-remembered-session' }); - }); - - test('deleted or missing currentSessionId falls through to remembered/fallback selection', () => { - const projectMap = new Map([['root-session-1', null]]); - - expect(resolveMissingProjectSessionSelection({ - activeProjectId: 'project-1', - currentSessionId: null, - currentSessionOwnerProjectId: null, - projectMap, - metaByProject: new Map([['project-1', projectMap]]), - rememberedSessionId: undefined, - fallbackSessionId: 'root-session-1', - })).toEqual({ kind: 'select-session', sessionId: 'root-session-1' }); - }); - - test('empty projects resolve to opening a draft', () => { - expect(resolveMissingProjectSessionSelection({ - activeProjectId: 'empty-project', - currentSessionId: 'some-session-id', - currentSessionOwnerProjectId: null, - projectMap: undefined, - metaByProject: new Map>(), - rememberedSessionId: undefined, - fallbackSessionId: null, - })).toEqual({ kind: 'open-draft' }); - }); -}); - -// --------------------------------------------------------------------------- -// Hook-level: ProjectSessionSelectionEffect recovery / preserve -// --------------------------------------------------------------------------- - -const installMinimalDom = () => { - const descriptors = new Map(); - const setGlobal = (name: string, value: unknown) => { - descriptors.set(name, Object.getOwnPropertyDescriptor(globalThis, name)); - Object.defineProperty(globalThis, name, { configurable: true, writable: true, value }); - }; - class ElementStub {} - const documentStub: Record = { - nodeType: 9, - defaultView: globalThis, - activeElement: null, - addEventListener: () => undefined, - removeEventListener: () => undefined, - }; - const container = { - nodeType: 1, - tagName: 'DIV', - nodeName: 'DIV', - namespaceURI: 'http://www.w3.org/1999/xhtml', - ownerDocument: documentStub, - addEventListener: () => undefined, - removeEventListener: () => undefined, - }; - documentStub.documentElement = container; - documentStub.body = container; - setGlobal('document', documentStub); - setGlobal('window', globalThis); - setGlobal('location', { search: '', protocol: 'http:', hostname: 'localhost' }); - setGlobal('Element', ElementStub); - setGlobal('HTMLElement', ElementStub); - setGlobal('HTMLIFrameElement', ElementStub); - setGlobal('IS_REACT_ACT_ENVIRONMENT', true); - setGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => setTimeout(() => callback(Date.now()), 0)); - setGlobal('cancelAnimationFrame', (id: ReturnType) => clearTimeout(id)); - return { - container: container as unknown as Element, - restore: () => { - for (const [name, descriptor] of descriptors) { - if (descriptor) Object.defineProperty(globalThis, name, descriptor); - else Reflect.deleteProperty(globalThis, name); - } - }, - }; -}; - -type SelectionEffectProps = React.ComponentProps; - -const bothProjectSections: ProjectSection[] = [staleSections[0]!, project2Sections[0]!]; - -function mountSelectionEffect(initial: { - activeProjectId: string; - projectSections: ProjectSection[]; - sessionId: string | null; - sessionOwnerBySessionId?: ReadonlyMap; - rememberedByProject?: Map; -}) { - currentSessionId = initial.sessionId; - newSessionDraftOpen = false; - isNewWorktreeDialogOpen = false; - - const sessionSelectCalls: Array<[string, string | null]> = []; - const draftCalls: Array<{ selectedProjectId?: string | null; directoryOverride?: string | null } | undefined> = []; - const dom = installMinimalDom(); - const root: Root = createRoot(dom.container); - - const props: SelectionEffectProps = { - projectSections: initial.projectSections, - activeProjectId: initial.activeProjectId, - initialActiveSessionByProject: initial.rememberedByProject ?? new Map(), - persistActiveSessionByProject: () => undefined, - handleSessionSelect: (sessionId, sessionDirectory) => { - sessionSelectCalls.push([sessionId, sessionDirectory]); - }, - mobileVariant: false, - openNewSessionDraft: (options) => { - draftCalls.push(options); - }, - setActiveMainTab: () => undefined, - setSessionSwitcherOpen: () => undefined, - sessionOwnerBySessionId: initial.sessionOwnerBySessionId, - }; - - act(() => { - root.render(React.createElement(ProjectSessionSelectionEffect, props)); - }); - - return { - sessionSelectCalls, - draftCalls, - rerender: (next: Partial & { sessionId?: string | null }) => { - const { sessionId, ...effectProps } = next; - if (sessionId !== undefined) currentSessionId = sessionId; - Object.assign(props, effectProps); - act(() => { - root.render(React.createElement(ProjectSessionSelectionEffect, props)); - }); - }, - teardown: () => { - act(() => { - root.unmount(); - }); - dom.restore(); - }, - }; -} - -describe('ProjectSessionSelectionEffect — ownership recovery', () => { - let teardown: (() => void) | null = null; - - afterEach(() => { - teardown?.(); - teardown = null; - currentSessionId = null; - newSessionDraftOpen = false; - isNewWorktreeDialogOpen = false; - }); - - test('A → B with later foreign ownership selects B remembered session', () => { - const missingASessionId = 'session-a-missing-from-maps'; - const mounted = mountSelectionEffect({ - activeProjectId: 'project-1', - projectSections: bothProjectSections, - sessionId: missingASessionId, - sessionOwnerBySessionId: new Map([[missingASessionId, { projectId: 'project-1' }]]), - rememberedByProject: new Map([['project-2', 'project-2-session-2']]), - }); - teardown = mounted.teardown; - - expect(mounted.sessionSelectCalls).toEqual([]); - - mounted.rerender({ - activeProjectId: 'project-2', - sessionOwnerBySessionId: new Map(), - }); - expect(mounted.sessionSelectCalls).toEqual([]); - - mounted.rerender({ - sessionOwnerBySessionId: new Map([[missingASessionId, { projectId: 'project-1' }]]), - }); - expect(mounted.sessionSelectCalls).toEqual([ - ['project-2-session-2', '/workspace/project-2'], - ]); - }); - - test('A → B with known foreign ownership selects B remembered session', () => { - const mounted = mountSelectionEffect({ - activeProjectId: 'project-1', - projectSections: bothProjectSections, - sessionId: 'root-session-1', - sessionOwnerBySessionId: new Map([['root-session-1', { projectId: 'project-1' }]]), - rememberedByProject: new Map([['project-2', 'project-2-session-2']]), - }); - teardown = mounted.teardown; - - expect(mounted.sessionSelectCalls).toEqual([]); - - mounted.rerender({ activeProjectId: 'project-2' }); - expect(mounted.sessionSelectCalls).toEqual([ - ['project-2-session-2', '/workspace/project-2'], - ]); - }); - - test('stale same-project worktree selection stays put when ownership arrives', () => { - const mounted = mountSelectionEffect({ - activeProjectId: 'project-1', - projectSections: staleSections, - sessionId: 'wt-session-1', - sessionOwnerBySessionId: new Map(), - rememberedByProject: new Map([['project-1', 'root-session-1']]), - }); - teardown = mounted.teardown; - - expect(mounted.sessionSelectCalls).toEqual([]); - expect(mounted.draftCalls).toEqual([]); - - mounted.rerender({ - sessionOwnerBySessionId: new Map([['wt-session-1', { projectId: 'project-1' }]]), - }); - expect(mounted.sessionSelectCalls).toEqual([]); - expect(mounted.draftCalls).toEqual([]); - }); -}); diff --git a/packages/ui/src/components/session/sidebar/hooks/useProjectSessionSelection.ts b/packages/ui/src/components/session/sidebar/hooks/useProjectSessionSelection.ts index 7c540c7d..cdbf2960 100644 --- a/packages/ui/src/components/session/sidebar/hooks/useProjectSessionSelection.ts +++ b/packages/ui/src/components/session/sidebar/hooks/useProjectSessionSelection.ts @@ -254,7 +254,6 @@ export const useProjectSessionSelection = (args: Args): void => { return next; }); }, [activeProjectId, currentSessionId, projectSessionMeta, setActiveSessionByProject]); - }; type ProjectSessionSelectionEffectProps = Omit< diff --git a/packages/ui/src/components/session/sidebar/hooks/useSessionPrefetch.ts b/packages/ui/src/components/session/sidebar/hooks/useSessionPrefetch.ts index 66bdda18..9cf895c3 100644 --- a/packages/ui/src/components/session/sidebar/hooks/useSessionPrefetch.ts +++ b/packages/ui/src/components/session/sidebar/hooks/useSessionPrefetch.ts @@ -28,7 +28,7 @@ const sessionDirectory = (session: Session | null | undefined): string | null => return typeof directory === 'string' && directory.trim() ? directory : null; }; -export const useSessionPrefetch = ({ enabled = true, currentSessionId, sortedSessions, recentSessions = [], prefetchSession }: Args): void => { +const useSessionPrefetch = ({ enabled = true, currentSessionId, sortedSessions, recentSessions = [], prefetchSession }: Args): void => { const sessionPrefetchTimersRef = React.useRef>(new Map()); const sessionPrefetchQueueRef = React.useRef([]); const sessionPrefetchInFlightRef = React.useRef>(new Set()); diff --git a/packages/ui/src/components/ui/dropdown-trigger.ts b/packages/ui/src/components/ui/dropdown-trigger.ts index b570f421..8761c546 100644 --- a/packages/ui/src/components/ui/dropdown-trigger.ts +++ b/packages/ui/src/components/ui/dropdown-trigger.ts @@ -1,4 +1,4 @@ -import { cva, type VariantProps } from 'class-variance-authority'; +import { cva } from 'class-variance-authority'; /** * Single source of truth for every dropdown-style trigger surface in the app: @@ -34,6 +34,3 @@ export const dropdownTriggerVariants = cva( }, }, ); - -export type DropdownTriggerVariantProps = VariantProps; -export type DropdownTriggerSize = NonNullable; diff --git a/packages/ui/src/components/views/PierreDiffViewer.tsx b/packages/ui/src/components/views/PierreDiffViewer.tsx index e8afc695..5375ac21 100644 --- a/packages/ui/src/components/views/PierreDiffViewer.tsx +++ b/packages/ui/src/components/views/PierreDiffViewer.tsx @@ -52,7 +52,7 @@ interface PierreDiffViewerProps { * and enables touch-friendly line interactions. Re-exported so plain * consumers (e.g. `MobileFilesSurface`) can inject the same. */ -export const PIERRE_RUNTIME_BASE_CSS = ` +const PIERRE_RUNTIME_BASE_CSS = ` :host { font-family: var(--font-mono); font-size: var(--text-code); diff --git a/packages/ui/src/hooks/useLocalTTS.ts b/packages/ui/src/hooks/useLocalTTS.ts index 56c74751..307356a2 100644 --- a/packages/ui/src/hooks/useLocalTTS.ts +++ b/packages/ui/src/hooks/useLocalTTS.ts @@ -41,7 +41,7 @@ const MAX_CHUNK_CHARS = 400; * Sentences are merged until MIN_CHUNK_CHARS and hard-split at * MAX_CHUNK_CHARS so a single run-on sentence cannot stall the pipeline. */ -export function splitTextForSynthesis(text: string): string[] { +function splitTextForSynthesis(text: string): string[] { const normalized = text.replace(/\s+/g, ' ').trim(); if (!normalized) { return []; diff --git a/packages/ui/src/hooks/useSessionAssist.ts b/packages/ui/src/hooks/useSessionAssist.ts index cbddcff0..09430efc 100644 --- a/packages/ui/src/hooks/useSessionAssist.ts +++ b/packages/ui/src/hooks/useSessionAssist.ts @@ -5,7 +5,7 @@ import { useUIStore } from '@/stores/useUIStore'; // How long the chat must sit untouched before the recap becomes visible. // The suggestion has no such delay — it shows as soon as it arrives. -export const RECAP_VISIBILITY_DELAY_MS = 60 * 1000; +const RECAP_VISIBILITY_DELAY_MS = 60 * 1000; interface LastMessageSnapshot { id: string; diff --git a/packages/ui/src/lib/desktop.ts b/packages/ui/src/lib/desktop.ts index cdcfaa4d..9c53d9bd 100644 --- a/packages/ui/src/lib/desktop.ts +++ b/packages/ui/src/lib/desktop.ts @@ -253,7 +253,7 @@ const getDesktopBridge = (): DesktopBridgeGlobal | null => { export const isElectronShell = (): boolean => getElectronRuntime()?.runtime === 'electron'; -export const getElectronPlatform = (): string | null => { +const getElectronPlatform = (): string | null => { if (typeof window === 'undefined') return null; const platform = (window as unknown as { __OPENCHAMBER_PLATFORM__?: string }).__OPENCHAMBER_PLATFORM__; return typeof platform === 'string' ? platform : null; diff --git a/packages/ui/src/lib/desktopRelayRestore.ts b/packages/ui/src/lib/desktopRelayRestore.ts index 7cf99e0f..38b599e2 100644 --- a/packages/ui/src/lib/desktopRelayRestore.ts +++ b/packages/ui/src/lib/desktopRelayRestore.ts @@ -28,7 +28,7 @@ let candidateRefreshInFlight = false; * (stable tunnel hostname) is never overwritten: the DHCP problem does not apply * to it and the server does not know its own public hostnames. */ -export const refreshDesktopHostCandidates = async (hostId: string): Promise => { +const refreshDesktopHostCandidates = async (hostId: string): Promise => { if (!isElectronShell() || candidateRefreshInFlight) return; const runtimeKey = `host:${hostId}`; // The candidates fetch rides the active runtime's transport — only meaningful diff --git a/packages/ui/src/lib/gitApi.ts b/packages/ui/src/lib/gitApi.ts index 1adb1c4d..b663c8bd 100644 --- a/packages/ui/src/lib/gitApi.ts +++ b/packages/ui/src/lib/gitApi.ts @@ -9,32 +9,7 @@ import { useConfigStore } from '@/stores/useConfigStore'; import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry'; export type { - GitStatus, - GitDiffResponse, - GetGitDiffOptions, - GitBranchDetails, - GitBranch, - GitCommitResult, - GitPushResult, - GitPullResult, - GitIdentityProfile, - GitIdentityAuthType, - GitIdentitySummary, - GitLogEntry, - GitLogResponse, - GitWorktreeInfo, - CreateGitWorktreePayload, - GitWorktreeCreateResult, - RemoveGitWorktreePayload, - GitWorktreeValidationError, - GitWorktreeValidationResult, - GitDeleteBranchPayload, - GitDeleteRemoteBranchPayload, - GitRemoveRemotePayload, - DiscoveredGitCredential, GitRemote, - GitMergeResult, - GitRebaseResult, MergeConflictDetails, CommitFileDiffResponse, } from './api/types'; diff --git a/packages/ui/src/lib/hardwareKeyboard.ts b/packages/ui/src/lib/hardwareKeyboard.ts index ca7ba92f..72f4ab7a 100644 --- a/packages/ui/src/lib/hardwareKeyboard.ts +++ b/packages/ui/src/lib/hardwareKeyboard.ts @@ -139,9 +139,9 @@ export const resetHardwareKeyboardDetection = (): void => { setHardwareKeyboardAttached(false); }; -export const isHardwareKeyboardAttached = (): boolean => hardwareKeyboardAttached; +const isHardwareKeyboardAttached = (): boolean => hardwareKeyboardAttached; -export const subscribeHardwareKeyboard = (listener: () => void): (() => void) => { +const subscribeHardwareKeyboard = (listener: () => void): (() => void) => { subscribers.add(listener); return () => { subscribers.delete(listener); diff --git a/packages/ui/src/lib/i18n/messages/fr.ts b/packages/ui/src/lib/i18n/messages/fr.ts index d7f5dabb..fe2a1692 100644 --- a/packages/ui/src/lib/i18n/messages/fr.ts +++ b/packages/ui/src/lib/i18n/messages/fr.ts @@ -3036,5 +3036,3 @@ export const dict = { 'settings.mcp.page.connection.hintCommand': 'S’exécute sur cette machine. Collez une commande entière : elle est découpée en un argument par ligne.', 'settings.mcp.page.connection.hintLink': 'Se connecte à un serveur hébergé par quelqu’un d’autre. Collez son adresse https.', } as const; - -export type I18nKey = keyof typeof dict; diff --git a/packages/ui/src/lib/opencode/provider-tracker.ts b/packages/ui/src/lib/opencode/provider-tracker.ts index 0cc9e761..f2143648 100644 --- a/packages/ui/src/lib/opencode/provider-tracker.ts +++ b/packages/ui/src/lib/opencode/provider-tracker.ts @@ -1,8 +1,7 @@ /** - * Provider Circuit-Breaker & Retry Tracker + * Provider Circuit-Breaker Tracker * * Tracks per-provider error state to enable: - * - Transparent retry with exponential backoff for transient errors * - Circuit breaking (pause requests to a provider during error storms) * * Inspired by HiveMind (arXiv:2604.17111) OS-inspired scheduling primitives. @@ -12,9 +11,7 @@ import { getRuntimeKey } from '@/lib/runtime-switch' const DEFAULT_CIRCUIT_BREAK_THRESHOLD = 3 const DEFAULT_CIRCUIT_COOLDOWN_MS = 30_000 -const DEFAULT_RETRY_BASE_DELAY_MS = 1000 const DEFAULT_RETRY_MAX_DELAY_MS = 32_000 -const DEFAULT_RETRY_MAX_ATTEMPTS = 3 const PROVIDER_EVICTION_TTL_MS = 60 * 60 * 1000 const PROVIDER_EVICTION_INTERVAL_MS = 10 * 60 * 1000 const PROVIDER_MAX_ENTRIES = 200 @@ -113,19 +110,7 @@ function isCircuitOpen(providerID: string): boolean { return true } -export function shouldRetry(providerID: string, status: number, attempt: number): boolean { - if (!RETRYABLE_STATUS_CODES.has(status)) return false - if (attempt >= DEFAULT_RETRY_MAX_ATTEMPTS - 1) return false - if (isCircuitOpen(providerID)) return false - return true -} - export function assertProviderCircuitClosed(providerID: string): void { if (!providerID || !isCircuitOpen(providerID)) return throw new Error(`Provider ${providerID} is temporarily unavailable after repeated errors. Please retry shortly.`) } - -export function getRetryDelayMs(attempt: number): number { - const delay = DEFAULT_RETRY_BASE_DELAY_MS * 2 ** attempt - return Math.min(delay, DEFAULT_RETRY_MAX_DELAY_MS) -} diff --git a/packages/ui/src/lib/relay/protocol.ts b/packages/ui/src/lib/relay/protocol.ts index d1569175..b14d34df 100644 --- a/packages/ui/src/lib/relay/protocol.ts +++ b/packages/ui/src/lib/relay/protocol.ts @@ -83,10 +83,6 @@ export interface TunnelWsOpenPayload { protocols?: string[]; } -export interface TunnelWsOpenedPayload { - protocol?: string; -} - export interface TunnelWsClosePayload { code: number; reason: string; @@ -111,13 +107,6 @@ export interface E2eeReadyMessage { batch?: boolean; } -// Layer 1 control messages (relay <-> host control socket). -export type RelayControlMessage = - | { type: 'sync'; connectionIds: string[] } - | { type: 'connected'; connectionId: string } - | { type: 'disconnected'; connectionId: string } - | { type: 'limit'; reason: string }; - // Relay-assigned WebSocket close codes. export const RelayCloseCode = { ControlReplaced: 4001, diff --git a/packages/ui/src/lib/runtime-switch.ts b/packages/ui/src/lib/runtime-switch.ts index babc4a1a..8b11ef90 100644 --- a/packages/ui/src/lib/runtime-switch.ts +++ b/packages/ui/src/lib/runtime-switch.ts @@ -3,12 +3,9 @@ import { configureRuntimeUrlResolver } from '@/lib/runtime-url'; import { activateRelayTunnel, deactivateRelayTunnel, - getActiveRelayTunnel, type RelayRuntimeDescriptor, } from '@/lib/relay/runtime-tunnel'; -export { getActiveRelayTunnel }; - export type RuntimeEndpointChangedDetail = { apiBaseUrl: string; previousApiBaseUrl: string; diff --git a/packages/ui/src/lib/runtimeSurface.ts b/packages/ui/src/lib/runtimeSurface.ts index bf86de01..dddeaa1d 100644 --- a/packages/ui/src/lib/runtimeSurface.ts +++ b/packages/ui/src/lib/runtimeSurface.ts @@ -29,7 +29,7 @@ const isTouchOrCoarsePointer = (): boolean => { * shell (always the mobile surface) → desktop shells → phone heuristic * gated by the stored mobile layout preference. */ -export const detectHostedSurface = (): HostedSurface => { +const detectHostedSurface = (): HostedSurface => { if (typeof window === 'undefined') return 'desktop'; const explicitSurface = window.__OPENCHAMBER_SURFACE__; diff --git a/packages/ui/src/stores/DOCUMENTATION.md b/packages/ui/src/stores/DOCUMENTATION.md index a578d61f..a650e008 100644 --- a/packages/ui/src/stores/DOCUMENTATION.md +++ b/packages/ui/src/stores/DOCUMENTATION.md @@ -189,7 +189,6 @@ Good: - `useGitBranches(directory)` - `useGitBranchLabel(directory)` - `useGitRepoStatusMap(directories)` -- `usePrVisualSummaryByKeys(keys)` Bad: diff --git a/packages/ui/src/stores/useGitHubPrStatusStore.ts b/packages/ui/src/stores/useGitHubPrStatusStore.ts index b9dc9e5c..32307518 100644 --- a/packages/ui/src/stores/useGitHubPrStatusStore.ts +++ b/packages/ui/src/stores/useGitHubPrStatusStore.ts @@ -932,11 +932,8 @@ const deriveSummary = (entry: PrStatusEntry): PrVisualSummary | null => { const summarySignature = (s: PrVisualSummary): string => `${s.number}:${s.visualState}:${s.prState}:${s.draft}:${s.title ?? ''}:${s.url ?? ''}:${s.base ?? ''}:${s.head ?? ''}:${s.canMerge ?? ''}:${s.mergeableState ?? ''}:${s.checks?.state ?? ''}:${s.checks?.total ?? ''}:${s.checks?.success ?? ''}:${s.checks?.failure ?? ''}:${s.checks?.pending ?? ''}:${s.repo?.owner ?? ''}:${s.repo?.repo ?? ''}`; -let prKeyedCacheSigs = new Map(); -let prKeyedCacheResult: Map = new Map(); - // Per-key summary cache so many independent row subscribers (one key each) -// keep referential stability without fighting over the multi-key cache above. +// keep referential stability. // Practically bounded by the number of worktree branches observed in a // session; the explicit cap below guards long-running documents that rotate // through many branches/runtimes (entries are tiny; insertion-order eviction @@ -964,34 +961,3 @@ export const usePrVisualSummary = (key: string | null): PrVisualSummary | null = return summary; }); }; - -export const usePrVisualSummaryByKeys = (keys: string[]) => { - return useGitHubPrStatusStore((state) => { - // Derive summaries for requested keys only - const nextSigs = new Map(); - const nextSummaries = new Map(); - - for (const key of keys) { - const entry = state.entries[key]; - if (!entry) continue; - const summary = deriveSummary(entry); - if (!summary) continue; - const sig = summarySignature(summary); - nextSigs.set(key, sig); - nextSummaries.set(key, summary); - } - - // Compare with cached signatures - if (nextSigs.size === prKeyedCacheSigs.size) { - let same = true; - for (const [k, sig] of nextSigs) { - if (prKeyedCacheSigs.get(k) !== sig) { same = false; break; } - } - if (same) return prKeyedCacheResult; - } - - prKeyedCacheSigs = nextSigs; - prKeyedCacheResult = nextSummaries; - return nextSummaries; - }); -}; diff --git a/packages/ui/src/stores/useSessionDisplayStore.ts b/packages/ui/src/stores/useSessionDisplayStore.ts index 0a2578bb..0cb7ca84 100644 --- a/packages/ui/src/stores/useSessionDisplayStore.ts +++ b/packages/ui/src/stores/useSessionDisplayStore.ts @@ -77,4 +77,4 @@ export const useSessionDisplayStore = create()( ), ); -export type { ProjectSortOrder, SessionGroupingMode }; +export type { ProjectSortOrder }; diff --git a/packages/ui/src/sync/__tests__/event-pipeline.test.js b/packages/ui/src/sync/__tests__/event-pipeline.test.js index 712a5e90..8f0dd1c1 100644 --- a/packages/ui/src/sync/__tests__/event-pipeline.test.js +++ b/packages/ui/src/sync/__tests__/event-pipeline.test.js @@ -1,5 +1,15 @@ -import { afterEach, describe, expect, it } from 'bun:test'; -import { createEventPipeline } from '../event-pipeline'; +import { afterEach, describe, expect, it, mock } from 'bun:test'; + +// A WebSocket attempt mints an `oc_url_token` before connecting, because a WS +// upgrade cannot carry an Authorization header. Stub only that mint so the +// socket assertions below exercise the transport rather than the auth round-trip. +const actualRuntimeAuth = await import('@/lib/runtime-auth'); +mock.module('@/lib/runtime-auth', () => ({ + ...actualRuntimeAuth, + refreshRuntimeUrlAuthToken: async () => 'test-url-token', +})); + +const { createEventPipeline } = await import('../event-pipeline'); const originalDocument = globalThis.document; const originalWindow = globalThis.window; @@ -48,9 +58,9 @@ class FakeWebSocket { this.onmessage?.({ data: JSON.stringify(payload) }); } - emitClose() { + emitClose(code = 1006, reason = '') { this.readyState = 3; - this.onclose?.(); + this.onclose?.({ code, reason }); } } diff --git a/packages/ui/src/sync/__tests__/live-aggregate.test.js b/packages/ui/src/sync/__tests__/live-aggregate.test.js index f67741f5..05b2d2dd 100644 --- a/packages/ui/src/sync/__tests__/live-aggregate.test.js +++ b/packages/ui/src/sync/__tests__/live-aggregate.test.js @@ -7,7 +7,6 @@ import { findLiveSession, findLiveSessionStatus, } from '../live-aggregate.ts' -import { deriveRecentSessions, RECENT_SESSION_MAX_AGE_MS } from '../../components/session/sidebar/activitySections.ts' const session = (id, directory, updated, extra = {}) => ({ id, @@ -94,19 +93,4 @@ describe('live aggregate', () => { )).toBe(false) }) - it('derives recent sessions from the 48h window, excluding archived/subtasks', () => { - const now = 1_000_000_000 - const sessions = [ - session('ses-1', '/a', now - 1_000), - session('ses-2', '/b', now - 500), - session('ses-3', '/c', now - 10, { time: { created: now - 11, updated: now - 10, archived: now - 5 } }), - session('ses-4', '/d', now - 200, { parentID: 'ses-parent' }), - session('ses-5', '/e', now - RECENT_SESSION_MAX_AGE_MS - 1), - ] - - const recent = deriveRecentSessions(sessions, now) - - // ses-3 archived, ses-4 subtask, ses-5 older than 48h -> excluded; rest newest-first - expect(recent.map((item) => item.id)).toEqual(['ses-2', 'ses-1']) - }) }) diff --git a/packages/ui/src/sync/session-actions.ts b/packages/ui/src/sync/session-actions.ts index b499d5e6..8030abc9 100644 --- a/packages/ui/src/sync/session-actions.ts +++ b/packages/ui/src/sync/session-actions.ts @@ -236,7 +236,7 @@ function updateLiveSession(session: Session, directory?: string): boolean { return false } -export function mirrorSessionIntoLiveStores(session: Session, directory?: string): void { +function mirrorSessionIntoLiveStores(session: Session, directory?: string): void { if (directory && updateLiveSession(session, directory)) { return } diff --git a/packages/ui/src/sync/session-ui-store.ts b/packages/ui/src/sync/session-ui-store.ts index 94d875bc..6c1ada6c 100644 --- a/packages/ui/src/sync/session-ui-store.ts +++ b/packages/ui/src/sync/session-ui-store.ts @@ -255,9 +255,6 @@ function notifyMessageSent(sessionId: string): void { // Types // --------------------------------------------------------------------------- -export type { SyntheticContextPart } from "./input-store" -export type { SessionMemoryState } from "./viewport-store" - export type NewSessionDraftState = { open: boolean selectedProjectId?: string | null diff --git a/packages/ui/src/sync/session-worktree-contract.test.js b/packages/ui/src/sync/session-worktree-contract.test.js index f37a9c3b..d61c2846 100644 --- a/packages/ui/src/sync/session-worktree-contract.test.js +++ b/packages/ui/src/sync/session-worktree-contract.test.js @@ -6,30 +6,9 @@ import { formatSessionWorktreeBadge, getSessionWorktreeRepairActions, getMutationBlockingReasons, - isWithinWorktreeRoot, buildSessionTargetOptions, } from './session-worktree-contract'; -describe('isWithinWorktreeRoot', () => { - test('returns true when candidate equals root', () => { - expect(isWithinWorktreeRoot('/repo/worktrees/feat-a', '/repo/worktrees/feat-a')).toBe(true); - }); - - test('returns true when candidate is a subdirectory of root', () => { - expect(isWithinWorktreeRoot('/repo/worktrees/feat-a/src', '/repo/worktrees/feat-a')).toBe(true); - }); - - test('returns false when candidate is outside root', () => { - expect(isWithinWorktreeRoot('/tmp/outside', '/repo/worktrees/feat-a')).toBe(false); - }); - - test('returns false when either is null/empty', () => { - expect(isWithinWorktreeRoot(null, '/repo')).toBe(false); - expect(isWithinWorktreeRoot('/repo', null)).toBe(false); - expect(isWithinWorktreeRoot('', '/repo')).toBe(false); - }); -}); - describe('getAttachedSessionDirectory', () => { test('prefers canonical cwd when attachment is healthy', () => { expect(getAttachedSessionDirectory({ diff --git a/packages/ui/src/sync/sync-context.tsx b/packages/ui/src/sync/sync-context.tsx index 288796d5..28f8f807 100644 --- a/packages/ui/src/sync/sync-context.tsx +++ b/packages/ui/src/sync/sync-context.tsx @@ -2769,26 +2769,6 @@ export function useChildStoreManager() { return useSyncSystem().childStores } -export type SessionTextMessage = { - id: string - role: string | null - text: string -} - -const getPartText = (part: Part): string => { - if (part?.type !== "text") return "" - const text = (part as { text?: unknown }).text - return typeof text === "string" ? text : "" -} - -const getConcatenatedTextFromParts = (parts: Part[]): string => { - let text = "" - for (const part of parts) { - text += getPartText(part) - } - return text -} - type SessionMessageRecord = { info: Message; parts: Part[] } const EMPTY_SESSION_MESSAGE_RECORDS: SessionMessageRecord[] = [] @@ -3114,19 +3094,6 @@ export function useSessionRenderable(sessionID: string, directory?: string): boo return React.useSyncExternalStore(subscribe, getSnapshot, getSnapshot) } -export function useSessionTextMessages(sessionID: string, directory?: string): SessionTextMessage[] { - const records = useSessionMessageRecords(sessionID, directory) - - return useMemo( - () => records.map((record) => ({ - id: record.info.id, - role: typeof record.info.role === "string" ? record.info.role : null, - text: getConcatenatedTextFromParts(record.parts), - })), - [records], - ) -} - export function useUserMessageHistory(sessionID: string, directory?: string): string[] { const store = useDirectoryStore(directory) const snapshotRef = useRef(EMPTY_USER_MESSAGE_HISTORY_SNAPSHOT) diff --git a/packages/vscode/package.json b/packages/vscode/package.json index 38616410..51c3f874 100644 --- a/packages/vscode/package.json +++ b/packages/vscode/package.json @@ -230,6 +230,7 @@ "watch:webview": "vite build --watch", "type-check": "tsc --noEmit -p tsconfig.json && tsc --noEmit -p tsconfig.webview.json", "lint": "bun x eslint --ext .ts,.tsx src webview", + "test": "node ../../scripts/run-isolated-tests.mjs src webview", "package": "vsce package --no-dependencies" }, "devDependencies": { diff --git a/packages/vscode/src/bridge-system-runtime.test.js b/packages/vscode/src/bridge-system-runtime.test.js index 09a17d26..eadf82ca 100644 --- a/packages/vscode/src/bridge-system-runtime.test.js +++ b/packages/vscode/src/bridge-system-runtime.test.js @@ -31,6 +31,7 @@ mock.module('vscode', () => ({ mock.module('./opencodeConfig', () => ({ removeProviderConfig: mock(), getProviderSources: mock(), + upsertProviderConfig: mock(), })); mock.module('./opencodeAuth', () => ({ getProviderAuth: mock(), diff --git a/packages/web/bin/lib/commands-models.js b/packages/web/bin/lib/commands-models.js index 42256ac1..58449a62 100644 --- a/packages/web/bin/lib/commands-models.js +++ b/packages/web/bin/lib/commands-models.js @@ -60,4 +60,4 @@ async function modelsCommand(options = {}, action = 'show') { process.stdout.write(formatModelsOutput(result)); } -export { modelsCommand, formatModelsOutput, formatDefaultLine, formatModelRef }; +export { modelsCommand, formatModelsOutput }; diff --git a/packages/web/server/lib/git/credentials.js b/packages/web/server/lib/git/credentials.js index 1607f142..422e2bfe 100644 --- a/packages/web/server/lib/git/credentials.js +++ b/packages/web/server/lib/git/credentials.js @@ -39,36 +39,3 @@ export function discoverGitCredentials() { return credentials; } - -export function getCredentialForHost(host) { - if (!fs.existsSync(GIT_CREDENTIALS_PATH)) { - return null; - } - - try { - const content = fs.readFileSync(GIT_CREDENTIALS_PATH, 'utf8'); - const lines = content.split('\n').filter(line => line.trim()); - - for (const line of lines) { - try { - const url = new URL(line.trim()); - const hostname = url.hostname; - const pathname = url.pathname && url.pathname !== '/' ? url.pathname : ''; - const credHost = hostname + pathname; - - if (credHost === host) { - return { - username: url.username || '', - token: url.password || '' - }; - } - } catch { - continue; - } - } - } catch (error) { - console.error('Failed to read .git-credentials for host lookup:', error); - } - - return null; -} diff --git a/packages/web/server/lib/github/DOCUMENTATION.md b/packages/web/server/lib/github/DOCUMENTATION.md index 2eb32532..4b7ce46e 100644 --- a/packages/web/server/lib/github/DOCUMENTATION.md +++ b/packages/web/server/lib/github/DOCUMENTATION.md @@ -7,7 +7,7 @@ ## Entrypoints and structure -- `packages/web/server/lib/github/index.js`: public server entrypoint. +- `packages/web/server/lib/github/index.js`: public server entrypoint. `routes.js` loads it lazily with `await import('./index.js')` and destructures the handler it needs, so a re-export removed from here breaks a route at request time rather than at build time. Static "unused export" reports do not see these consumers. - `packages/web/server/lib/github/routes.js`: Express route registration for `/api/github/*` endpoints. - `packages/web/server/lib/github/auth.js`: auth storage, multi-account support, client id, scope config. - `packages/web/server/lib/github/device-flow.js`: OAuth device flow. diff --git a/packages/web/server/lib/walkthrough/index.js b/packages/web/server/lib/walkthrough/index.js index f65d3e2f..601ca0dc 100644 --- a/packages/web/server/lib/walkthrough/index.js +++ b/packages/web/server/lib/walkthrough/index.js @@ -111,8 +111,6 @@ const jobKey = (repoRoot, sourceKeyValue) => `${repoRoot}\0${sourceKeyValue}`; * would imply progress where there is none. `retrying` appears only when a * provider rejects the schema and the prompt-side fallback runs. */ -export const GENERATION_STAGES = ['collecting', 'asking', 'retrying', 'assembling']; - const setStage = (repoRoot, sourceKeyValue, stage) => { const job = jobs.get(jobKey(repoRoot, sourceKeyValue)); if (job) job.stage = stage; diff --git a/packages/web/server/sse-routes.test.js b/packages/web/server/sse-routes.test.js index 46551644..ed640ebb 100644 --- a/packages/web/server/sse-routes.test.js +++ b/packages/web/server/sse-routes.test.js @@ -17,6 +17,9 @@ const createRouteRegistry = () => { put(path, handler) { routes.set(`PUT ${path}`, handler); }, + patch(path, handler) { + routes.set(`PATCH ${path}`, handler); + }, delete(path, handler) { routes.set(`DELETE ${path}`, handler); }, diff --git a/packages/web/src/api/git.test.ts b/packages/web/src/api/git.test.ts index 8fa3bee9..79435fa1 100644 --- a/packages/web/src/api/git.test.ts +++ b/packages/web/src/api/git.test.ts @@ -1,74 +1,12 @@ import { describe, expect, it, vi } from 'vitest'; -vi.mock('@openchamber/ui/lib/gitApiHttp', () => ({ - checkIsGitRepository: vi.fn(), - getGitStatus: vi.fn(), - getGitDiff: vi.fn(), - getGitFileDiff: vi.fn(), - revertGitFile: vi.fn(), - stageGitFile: vi.fn(), - stageGitFiles: vi.fn(), - unstageGitFile: vi.fn(), - unstageGitFiles: vi.fn(), - stageGitHunk: vi.fn(), - unstageGitHunk: vi.fn(), - revertGitHunk: vi.fn(), - isLinkedWorktree: vi.fn(), - getGitBranches: vi.fn(), - deleteGitBranch: vi.fn(), - deleteRemoteBranch: vi.fn(), - removeRemote: vi.fn(), - generateCommitMessage: vi.fn(), - generatePullRequestDescription: vi.fn(), - listGitWorktrees: vi.fn(), - validateGitWorktree: vi.fn(), - createGitWorktree: vi.fn(), - deleteGitWorktree: vi.fn(), - validateWorktreeDirectory: vi.fn(), - canonicalizeWorktreeState: vi.fn(), - createGitCommit: vi.fn(), - gitPush: vi.fn(), - gitPull: vi.fn(), - gitFetch: vi.fn(), - listGitStashes: vi.fn(), - countGitStashFiles: vi.fn(), - stashGitChanges: vi.fn(), - applyGitStash: vi.fn(), - popGitStash: vi.fn(), - dropGitStash: vi.fn(), - checkoutBranch: vi.fn(), - createBranch: vi.fn(), - renameBranch: vi.fn(), - getGitLog: vi.fn(), - getCommitFiles: vi.fn(), - getCurrentGitIdentity: vi.fn(), - hasLocalIdentity: vi.fn(), - setGitIdentity: vi.fn(), - getGitIdentities: vi.fn(), - createGitIdentity: vi.fn(), - updateGitIdentity: vi.fn(), - deleteGitIdentity: vi.fn(), - getRemotes: vi.fn(), - rebase: vi.fn(), - abortRebase: vi.fn(), - continueRebase: vi.fn(), - merge: vi.fn(), - abortMerge: vi.fn(), - continueMerge: vi.fn(), - stash: vi.fn(), - stashPop: vi.fn(), - getConflictDetails: vi.fn(), - checkoutCommit: vi.fn(), - cherryPick: vi.fn(), - revertCommit: vi.fn(), - resetToCommit: vi.fn(), - getCommitFileDiff: vi.fn(), - previewGitWorktree: vi.fn(), - getGitWorktreeBootstrapStatus: vi.fn(), - discoverGitCredentials: vi.fn(), - getGlobalGitIdentity: vi.fn(), - getRemoteUrl: vi.fn(), -})); +// Every export is auto-stubbed from the real module. The previous hand-written +// list of ~70 names silently fell behind the source: `getGitRangeDiff` was added +// upstream, the list was not, and the whole file failed on an unrelated change. +vi.mock('@openchamber/ui/lib/gitApiHttp', async (importOriginal) => { + const actual = await importOriginal>(); + return Object.fromEntries(Object.keys(actual).map((name) => [name, vi.fn()])); +}); describe('createWebGitAPI', () => { it('exposes bulk stage and unstage methods', async () => { diff --git a/packages/web/vitest.config.ts b/packages/web/vitest.config.ts index 599b01a2..62923862 100644 --- a/packages/web/vitest.config.ts +++ b/packages/web/vitest.config.ts @@ -1,11 +1,31 @@ +import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { defineConfig } from 'vitest/config'; +const here = path.dirname(fileURLToPath(import.meta.url)); + export default defineConfig({ resolve: { - alias: { - 'bun:test': fileURLToPath(new URL('./test/bun-test-shim.ts', import.meta.url)), - '@openchamber/ui': fileURLToPath(new URL('../ui/src', import.meta.url)), - }, + alias: [ + { find: 'bun:test', replacement: path.resolve(here, './test/bun-test-shim.ts') }, + // The same shared-UI aliases the app build uses. Without them a test can + // reference `@openchamber/ui/...` in a mock factory but not resolve the + // real module behind it, which is what forced mocks to hand-copy export + // lists that then fell behind the source. + { find: '@opencode-ai/sdk/v2', replacement: path.resolve(here, '../../node_modules/@opencode-ai/sdk/dist/v2/client.js') }, + { find: '@openchamber/ui', replacement: path.resolve(here, '../ui/src') }, + { find: '@web', replacement: path.resolve(here, './src') }, + // Anchored to `@/` on purpose: a bare `@` prefix would also swallow + // scoped dependencies the server tests rely on, such as `@octokit/rest`. + { find: /^@\//, replacement: `${path.resolve(here, '../ui/src')}/` }, + ], + }, + test: { + // The Git suites drive a real `git` binary against temporary repositories. + // Those subprocess round-trips routinely pass the 5s default, and which + // cases exceed it shifts with machine load, so the default made a valid + // suite fail differently on every run. + testTimeout: 30_000, + hookTimeout: 30_000, }, }); diff --git a/scripts/repro/issue-2638/README.md b/scripts/repro/issue-2638/README.md deleted file mode 100644 index 357ee8af..00000000 --- a/scripts/repro/issue-2638/README.md +++ /dev/null @@ -1,47 +0,0 @@ -# Reproduction: Chat UI stops updating until the desktop app is restarted (#2638) - -Reproduces https://github.com/openchamber/openchamber/issues/2638 using the -real server modules (`lifecycle.js`, `global-hub.js`, `network-runtime.js`), -real processes, and real ports — no mocks. - -## Run - -```sh -# Windows-orphan scenario (the reported bug): -node scripts/repro/issue-2638/reproduce-2638.mjs - -# Control: healthy restart on Linux (hub reconnects, UI keeps updating): -node scripts/repro/issue-2638/reproduce-2638.mjs --baseline -``` - -Requires `lsof` (used only for cleanup). The default run simulates Windows -(`process.platform` is temporarily overridden to `win32`) because the bug is -specific to the Windows process-lifecycle path. - -## What it demonstrates - -1. A managed OpenCode process starts; the global message-stream hub connects to - its `/global/event` SSE stream and chat events flow to the UI. -2. The managed process "exits" but the actual server process survives on the - old port (on Windows `killProcessOnPort` is a no-op and `taskkill` cannot - reach the orphaned tree — the report shows leftover `opencode.exe serve` - processes on historical ports). -3. `restartOpenCode()` gives up after 5 s — logs - `Timed out waiting for OpenCode port to be released` — and spawns a - fresh server on a NEW port, leaving the orphaned process running. -4. HTTP/proxy traffic follows `state.openCodePort` to the new server, but the - hub's upstream SSE reader stays pinned to the OLD server's `/global/event` - stream (that connection never closed), so events emitted by the new server - never reach the UI: the chat UI goes stale while the new server keeps - persisting session data — visible only after restarting the app. - -The `--baseline` control proves the reconnect logic itself is fine: when the -old process dies and the port is properly released, the hub reconnects to the -new port and events are delivered. - -## Files - -- `reproduce-2638.mjs` — the reproduction driver (assertions + summary). -- `fake-opencode-serve.mjs` — a fake `opencode serve` binary whose launcher - spawns a detached server core that survives the launcher's death - (Windows-style orphan), plus an in-process mode for the baseline control. diff --git a/scripts/repro/issue-2638/fake-opencode-serve.mjs b/scripts/repro/issue-2638/fake-opencode-serve.mjs deleted file mode 100755 index 0fc36dc0..00000000 --- a/scripts/repro/issue-2638/fake-opencode-serve.mjs +++ /dev/null @@ -1,151 +0,0 @@ -#!/usr/bin/env node -// Fake `opencode serve` used to reproduce https://github.com/openchamber/openchamber/issues/2638 -// -// Modes (controlled by env): -// FAKE_OPENCODE_CORE=1 – server-core mode: binds the port, serves -// /global/health + SSE /global/event, ignores -// SIGTERM so it survives its launcher's death -// (Windows-style orphaned server process). -// FAKE_OPENCODE_BASELINE=1 – in-process mode: the server runs inside the -// managed process and dies with it (normal -// Linux behavior used as a control). -// default (launcher) – spawns a detached core grandchild, waits for -// it to bind, prints the `opencode server -// listening on ...` line the lifecycle greps -// for, stays alive, and on SIGTERM exits WITHOUT -// killing the core (mimics opencode.exe dying -// while its server child survives). -import http from 'node:http'; -import net from 'node:net'; -import fs from 'node:fs'; -import path from 'node:path'; -import { spawn } from 'node:child_process'; - -const args = process.argv.slice(2); -const portIndex = args.indexOf('--port'); -const hostnameIndex = args.indexOf('--hostname'); -const port = portIndex >= 0 ? Number(args[portIndex + 1]) : 0; -const hostname = hostnameIndex >= 0 ? args[hostnameIndex + 1] : '127.0.0.1'; -const pidDir = process.env.FAKE_OPENCODE_PID_DIR || null; - -function writePidFile(label) { - if (!pidDir) return; - try { - fs.mkdirSync(pidDir, { recursive: true }); - fs.writeFileSync(path.join(pidDir, `${label}-${port}.pid`), String(process.pid)); - } catch { - // best effort - } -} - -function createServer() { - const clients = new Set(); - const emitted = []; - const server = http.createServer((req, res) => { - const url = new URL(req.url, `http://${hostname}:${port}`); - if (url.pathname === '/global/health') { - res.writeHead(200, { 'content-type': 'application/json' }); - res.end(JSON.stringify({ healthy: true })); - return; - } - if (url.pathname === '/global/event') { - res.writeHead(200, { - 'content-type': 'text/event-stream', - 'cache-control': 'no-cache', - connection: 'keep-alive', - }); - clients.add(res); - req.on('close', () => clients.delete(res)); - // SSE keep-alive comments — exactly what a real OpenCode server sends, - // which prevents the upstream reader's 20s stall timer from firing. - const keepalive = setInterval(() => { - res.write(': keepalive\n\n'); - }, 1000); - req.on('close', () => clearInterval(keepalive)); - return; - } - if (url.pathname === '/emit') { - const type = url.searchParams.get('type') || 'session.updated'; - const id = url.searchParams.get('id') || `evt-${Date.now()}`; - emitted.push({ id, type }); - const block = `id: ${id}\ndata: ${JSON.stringify({ type, id })}\n\n`; - for (const client of clients) client.write(block); - res.writeHead(200, { 'content-type': 'application/json' }); - res.end(JSON.stringify({ ok: true, id })); - return; - } - if (url.pathname === '/events') { - res.writeHead(200, { 'content-type': 'application/json' }); - res.end(JSON.stringify(emitted)); - return; - } - res.writeHead(404); - res.end('not found'); - }); - return { server }; -} - -const runServer = (label) => { - createServer().server.listen(port, hostname, () => { - console.log(`opencode server listening on http://${hostname}:${port}`); - }); - writePidFile(label); - setInterval(() => {}, 1 << 30); -}; - -const main = async () => { - // Server-core mode: the orphaned server. Survives its launcher's death. - if (process.env.FAKE_OPENCODE_CORE === '1') { - runServer('core'); - process.on('SIGTERM', () => {}); - process.on('SIGINT', () => {}); - return; - } - - // Baseline in-process mode (control): dies with the managed process, so the - // port is properly released on restart. - if (process.env.FAKE_OPENCODE_BASELINE === '1') { - runServer('baseline'); - return; - } - - // Launcher mode: spawn a detached core grandchild, wait for it to bind, - // print the listening line, and on SIGTERM exit leaving the core running. - const core = spawn(process.execPath, [process.argv[1], ...args], { - detached: true, - stdio: ['ignore', 'ignore', 'ignore'], - env: { ...process.env, FAKE_OPENCODE_CORE: '1' }, - }); - core.unref(); - - for (let i = 0; i < 200; i += 1) { - if (core.exitCode !== null) throw new Error('core exited early'); - const ok = await new Promise((resolve) => { - const socket = net.connect({ port, host: hostname }); - const timer = setTimeout(() => { - socket.destroy(); - resolve(false); - }, 200); - socket.once('connect', () => { - clearTimeout(timer); - socket.destroy(); - resolve(true); - }); - socket.once('error', () => { - clearTimeout(timer); - resolve(false); - }); - }); - if (ok) break; - await new Promise((r) => setTimeout(r, 50)); - } - - writePidFile('launcher'); - console.log(`opencode server listening on http://${hostname}:${port}`); - // On SIGTERM exit ourselves, leaving the detached core running. - process.on('SIGTERM', () => process.exit(0)); - process.on('SIGINT', () => process.exit(0)); - setInterval(() => {}, 1 << 30); -}; - -await main(); diff --git a/scripts/repro/issue-2638/reproduce-2638.mjs b/scripts/repro/issue-2638/reproduce-2638.mjs deleted file mode 100644 index d4f99af4..00000000 --- a/scripts/repro/issue-2638/reproduce-2638.mjs +++ /dev/null @@ -1,376 +0,0 @@ -// Reproduction for https://github.com/openchamber/openchamber/issues/2638 -// "[Bug] Chat UI stops updating until the desktop app is restarted" -// -// Run with: node reproduce-2638.mjs (Windows-orphan scenario) -// node reproduce-2638.mjs --baseline (control: healthy restart) -// -// What it wires up (real repo modules, real processes, real ports): -// - createOpenCodeLifecycleRuntime (packages/web/server/lib/opencode/lifecycle.js) -// - createGlobalMessageStreamHub (packages/web/server/lib/event-stream/global-hub.js) -// - createOpenCodeNetworkRuntime (packages/web/server/lib/opencode/network-runtime.js) -// - a fake `opencode serve` binary (fake-opencode-serve.mjs) -// -// Scenario (issue #2638): -// 1. OpenCode starts; the global message-stream hub connects to its -// /global/event SSE stream. Chat UI updates flow (baseline event e1 -// reaches the hub). -// 2. The managed OpenCode process "exits" but the actual server process -// survives on the old port (on Windows killProcessOnPort is a no-op and -// taskkill cannot reach the orphaned tree — the report shows leftover -// `opencode.exe serve` processes on historical ports). -// 3. restartOpenCode() gives up after 5 s -// ("Timed out waiting for OpenCode port to be released") and -// spawns a fresh server on a NEW port. -// 4. HTTP/proxy traffic follows state.openCodePort to the NEW server, but -// the hub's upstream SSE reader is still pinned to the OLD server's -// /global/event stream (that connection never closed), so events from -// the new server never reach the UI. Chat UI goes stale while the new -// server keeps persisting session data — visible only after restarting -// the app, exactly as reported. - -import { spawnSync } from 'node:child_process'; -import net from 'node:net'; -import fs from 'node:fs'; -import path from 'node:path'; -import os from 'node:os'; -import { fileURLToPath } from 'node:url'; - -const __dirname = path.dirname(fileURLToPath(import.meta.url)); - -// Repository root (override with REPO=/path/to/openchamber if needed). Default: -// walk up from this script until we find the repo root (AGENTS.md + package.json). -const findRepoRoot = () => { - let dir = __dirname; - for (let i = 0; i < 8; i += 1) { - if (fs.existsSync(path.join(dir, 'AGENTS.md')) && fs.existsSync(path.join(dir, 'package.json'))) { - return dir; - } - const parent = path.dirname(dir); - if (parent === dir) break; - dir = parent; - } - return '/home/runner/work/openchamber/openchamber'; -}; -const REPO = process.env.REPO || findRepoRoot(); - -const BASELINE = process.argv.includes('--baseline'); - -// Simulate the Windows behavior reported in #2638 (process.platform is read -// at call time inside lifecycle.js: killProcessOnPort no-ops on win32 and -// terminateChildProcess takes the taskkill path, which cannot exist here). -if (!BASELINE) { - Object.defineProperty(process, 'platform', { value: 'win32' }); -} - -const { createOpenCodeLifecycleRuntime } = await import( - path.join(REPO, 'packages/web/server/lib/opencode/lifecycle.js') -); - -// --- shared state + real network runtime ----------------------------------- -const state = { - openCodeWorkingDirectory: '/tmp', - openCodeProcess: null, - openCodePort: null, - openCodeBaseUrl: null, - currentRestartPromise: null, - isRestartingOpenCode: false, - openCodeApiPrefix: '', - openCodeApiPrefixDetected: false, - openCodeApiDetectionTimer: null, - lastOpenCodeError: null, - isOpenCodeReady: false, - openCodeNotReadySince: 0, - isExternalOpenCode: false, - isShuttingDown: false, - healthCheckInterval: null, - expressApp: null, - useWslForOpencode: false, - resolvedWslBinary: null, - resolvedWslOpencodePath: null, - resolvedWslDistro: null, - lastOpenCodeLaunchDiagnostics: null, -}; - -const { createOpenCodeNetworkRuntime } = await import( - path.join(REPO, 'packages/web/server/lib/opencode/network-runtime.js') -); -const networkRuntime = createOpenCodeNetworkRuntime({ - state, - getOpenCodeAuthHeaders: () => ({}), - configuredOpenCodeHostname: '127.0.0.1', -}); - -const fakeBinary = path.join(__dirname, 'fake-opencode-serve.mjs'); -const pidDir = fs.mkdtempSync(path.join(os.tmpdir(), 'oc-repro-2638-')); -process.env.OPENCODE_BINARY = fakeBinary; -process.env.FAKE_OPENCODE_PID_DIR = pidDir; -if (BASELINE) process.env.FAKE_OPENCODE_BASELINE = '1'; - -const lifecycle = createOpenCodeLifecycleRuntime({ - state, - env: { - ENV_CONFIGURED_OPENCODE_PORT: 0, - ENV_CONFIGURED_OPENCODE_HOST: null, - ENV_EFFECTIVE_PORT: 0, - ENV_CONFIGURED_OPENCODE_HOSTNAME: '127.0.0.1', - ENV_SKIP_OPENCODE_START: false, - }, - syncToHmrState: () => {}, - syncFromHmrState: () => {}, - getOpenCodeAuthHeaders: () => ({}), - buildOpenCodeUrl: (...args) => networkRuntime.buildOpenCodeUrl(...args), - waitForReady: (...args) => networkRuntime.waitForReady(...args), - normalizeApiPrefix: (...args) => networkRuntime.normalizeApiPrefix(...args), - applyOpencodeBinaryFromSettings: async () => {}, - ensureOpencodeCliEnv: () => {}, - ensureLocalOpenCodeServerPassword: async () => 'password', - resolveManagedOpenCodeLaunchSpec: (binary) => ({ binary, args: [], wrapperType: null }), - setOpenCodePort: (port) => { state.openCodePort = port; }, - setDetectedOpenCodeApiPrefix: () => {}, - setupProxy: () => {}, - ensureOpenCodeApiPrefix: () => {}, - clearResolvedOpenCodeBinary: () => {}, - buildAugmentedPath: () => process.env.PATH, - buildManagedOpenCodePath: () => process.env.PATH, - getManagedOpenCodeShellEnvSnapshot: async () => ({}), - getManagedOpenCodeEnv: async () => ({}), - reapManagedOrphanedProcesses: async () => ({ reaped: 0 }), - getWarmupDirectories: async () => [], - // Production index.js wires this to the message-stream runtime's - // rebindUpstream(); mirror it here so the harness exercises the fix. - onOpenCodeRestarted: () => { - try { - rebindHub?.(); - } catch { - } - }, -}); - -const { createGlobalMessageStreamHub } = await import( - path.join(REPO, 'packages/web/server/lib/event-stream/global-hub.js') -); - -// The hub represents the server→OpenCode SSE push pipeline that feeds the -// renderer (both the server-side PushWatcher and the browser WS bridge). -const received = []; -const statuses = []; -let rebindHub = null; -const hub = createGlobalMessageStreamHub({ - buildOpenCodeUrl: (p) => networkRuntime.buildOpenCodeUrl(p, ''), - getOpenCodeAuthHeaders: () => ({}), - upstreamStallTimeoutMs: 20000, - upstreamReconnectDelayMs: 250, -}); -hub.subscribeEvent(({ eventId, payload }) => { - received.push({ eventId, type: payload?.type, id: payload?.id }); -}); -hub.subscribeStatus((status) => statuses.push(status)); -rebindHub = () => { - hub.stop(); - hub.start(); -}; - -// --- helpers ---------------------------------------------------------------- -const warnLog = []; -const origWarn = console.warn; -console.warn = (...args) => { - warnLog.push(args.map(String).join(' ')); - origWarn(...args); -}; - -const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); - -async function waitFor(fn, timeoutMs, what) { - const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline) { - if (await fn()) return true; - await sleep(50); - } - throw new Error(`timeout waiting for ${what}`); -} - -const emitEvent = async (port, id, type = 'session.updated') => { - const res = await fetch(`http://127.0.0.1:${port}/emit?type=${type}&id=${id}`); - if (!res.ok) throw new Error(`emit ${id} failed on port ${port}`); - return res.json(); -}; - -const persistedEvents = async (port) => { - const res = await fetch(`http://127.0.0.1:${port}/events`); - return res.ok ? res.json() : []; -}; - -const portOpen = (port) => new Promise((resolve) => { - const socket = net.connect({ port, host: '127.0.0.1' }); - const timer = setTimeout(() => { socket.destroy(); resolve(false); }, 300); - socket.once('connect', () => { clearTimeout(timer); socket.destroy(); resolve(true); }); - socket.once('error', () => { clearTimeout(timer); resolve(false); }); -}); - -const pidFilePids = () => { - const out = []; - for (const file of fs.readdirSync(pidDir)) { - if (!file.endsWith('.pid')) continue; - try { - out.push({ label: file.replace(/\.pid$/, ''), pid: Number(fs.readFileSync(path.join(pidDir, file), 'utf8')) }); - } catch { - // ignore - } - } - return out; -}; - -const killPortPids = (port) => { - try { - const result = spawnSync('lsof', ['-ti', `:${port}`], { encoding: 'utf8' }); - const pids = String(result.stdout || '').trim().split(/\s+/).map(Number).filter(Boolean); - for (const pid of pids) { - if (pid === process.pid) continue; // never kill ourselves (TIME_WAIT client sockets) - try { process.kill(pid, 'SIGKILL'); } catch { /* already gone */ } - } - } catch { /* lsof unavailable */ } -}; - -const cleanup = async () => { - for (const { label, pid } of pidFilePids()) { - if (label.startsWith('launcher') || label.startsWith('baseline')) { - try { process.kill(pid, 'SIGTERM'); } catch { /* gone */ } - } else { - try { process.kill(pid, 'SIGKILL'); } catch { /* gone */ } - } - } - if (state.openCodePort) killPortPids(state.openCodePort); - // Belt and braces: kill any surviving fake-opencode processes from this run. - try { - const result = spawnSync('pgrep', ['-f', 'fake-opencode-serve.mjs'], { encoding: 'utf8' }); - const pids = String(result.stdout || '').trim().split(/\s+/).map(Number).filter(Boolean); - for (const pid of pids) { - if (pid === process.pid) continue; - try { process.kill(pid, 'SIGKILL'); } catch { /* gone */ } - } - } catch { /* pgrep unavailable */ } - await sleep(300); - try { fs.rmSync(pidDir, { recursive: true, force: true }); } catch { /* ignore */ } - console.warn = origWarn; -}; - -// --- run --------------------------------------------------------------------- -console.log(`\n=== reproduce-2638 (${BASELINE ? 'BASELINE control' : 'Windows-orphan scenario'}) ===\n`); -let failures = 0; -const check = (label, ok, detail = '') => { - console.log(` ${ok ? 'PASS' : 'FAIL'} ${label}${detail ? ` — ${detail}` : ''}`); - if (!ok) failures += 1; -}; - -try { - // 1. Bootstrapping starts the managed OpenCode (launcher + server core) on P1. - await lifecycle.bootstrapOpenCodeAtStartup(); - const p1 = state.openCodePort; - console.log(`[1] bootstrap OK — managed OpenCode listening on port ${p1} (pid ${state.openCodeProcess?.pid})`); - - // 2. Connect the message-stream hub (server→OpenCode SSE push pipeline). - hub.start(); - await waitFor(() => statuses.some((s) => s.type === 'connect'), 10000, 'hub connect to P1'); - console.log('[2] message-stream hub connected to /global/event'); - - // 3. Baseline delivery: an event emitted by P1 reaches the UI pipeline. - await emitEvent(p1, 'evt-before-restart'); - await waitFor(() => received.some((r) => r.eventId === 'evt-before-restart'), 5000, 'event delivery'); - check('events flow to the UI before the restart (baseline)', received.some((r) => r.eventId === 'evt-before-restart')); - - // 4. The managed process "exits" while the actual server survives on P1 - // (simulates the Windows orphan: launcher dies, server core keeps the - // port and the SSE stream). In baseline mode the server runs in-process - // and dies with the managed process instead. - const launcherPid = state.openCodeProcess.pid; - process.kill(launcherPid, 'SIGTERM'); - await waitFor(async () => { - try { process.kill(launcherPid, 0); return false; } catch { return true; } - }, 5000, 'launcher exit'); - console.log(`[4] managed process (pid ${launcherPid}) exited; ${BASELINE ? 'server process died with it' : `orphaned server core still listening on ${p1}`}`); - - // 5. Trigger the reported restart path ("Refreshing OpenCode after manual - // configuration reload" / periodic health check). - console.log('[5] triggering restart (refreshOpenCodeAfterConfigChange)...'); - await lifecycle.refreshOpenCodeAfterConfigChange('manual configuration reload'); - const p2 = state.openCodePort; - console.log(`[5] restarted — new managed OpenCode listening on port ${p2}`); - - // 6. Assert the reported log line: the old port was never released. In the - // baseline control the port IS released, so the warning must be absent. - const timeoutWarn = warnLog.find((line) => line.includes('Timed out waiting for OpenCode port') && line.includes(String(p1))); - if (BASELINE) { - check(`no "Timed out waiting for OpenCode port ${p1}" warning in baseline control`, !timeoutWarn); - } else { - check(`"Timed out waiting for OpenCode port ${p1} to be released" is logged`, Boolean(timeoutWarn), timeoutWarn || ''); - } - check('new port differs from old port (leaked process pinned the old one)', p2 !== p1, `p1=${p1} p2=${p2}`); - - // 7. Assert the orphaned old server is still running (process pile-up from - // the report: "six opencode.exe serve processes were still running"). - // In baseline mode we instead expect the port to be properly released. - const oldCoreStillUp = await portOpen(p1); - const pids = pidFilePids(); - const orphanPids = pids.filter(({ label }) => label.startsWith('core')).map(({ pid }) => pid); - const orphanAlive = orphanPids.length > 0 && orphanPids.every((pid) => { - try { process.kill(pid, 0); return true; } catch { return false; } - }); - if (BASELINE) { - check('old port properly released (no orphan in baseline control)', !oldCoreStillUp, - `old port ${p1} ${oldCoreStillUp ? 'still open' : 'released'}`); - } else { - check('orphaned server process still running on the old port', oldCoreStillUp && orphanAlive, - `old port ${p1} still open; orphan core pids ${orphanPids.join(', ')}`); - } - - // 8. The stale-UI reproduction: the new server persists events, but in the - // orphan scenario they never reach the UI because the hub is still pinned - // to the old SSE stream. In baseline mode the hub must reconnect to the - // new port and deliver them (wait for the reconnect before emitting — - // the upstream reader only learns about the new port on its next attempt). - const connectsBefore = statuses.filter((s) => s.type === 'connect').length; - if (!BASELINE) { - await sleep(1000); - } else { - await waitFor(() => statuses.filter((s) => s.type === 'connect').length >= connectsBefore + 1, 15000, 'hub reconnect to new port'); - } - await emitEvent(p2, 'evt-after-restart'); - console.log(`[8] emitted evt-after-restart on new port ${p2} — waiting to see if the UI receives it...`); - await sleep(2500); - const deliveredAfter = received.filter((r) => r.eventId === 'evt-after-restart').length; - if (BASELINE) { - check('NEW server event IS delivered to the UI (hub reconnected in baseline)', deliveredAfter === 1, - `delivered=${deliveredAfter}, total hub events=${received.length}`); - } else { - // Fixed: the lifecycle hook rebinds the hub after the managed restart, - // so the UI receives events from the new port even when the old port is - // orphaned. The wait mirrors the baseline branch (the reader re-dials on - // its next attempt after the rebind). - await waitFor(() => statuses.filter((s) => s.type === 'connect').length >= connectsBefore + 1, 15000, 'hub reconnect to new port after rebind'); - check('NEW server event IS delivered to the UI (rebound after restart)', deliveredAfter === 1, - `delivered=${deliveredAfter}, total hub events=${received.length}`); - } - - const persistedOnNew = await persistedEvents(p2); - check('NEW server persisted the event (data survives, UI does not show it)', persistedOnNew.some((e) => e.id === 'evt-after-restart'), - `persisted on port ${p2}: ${JSON.stringify(persistedOnNew)}`); - - // 9. Orphan scenario: with the rebind the hub is no longer pinned to the - // old server — events emitted by the old (zombie) server must NOT reach - // the UI anymore (it left the previous upstream behind). - if (!BASELINE) { - await emitEvent(p1, 'evt-zombie-old-server'); - await sleep(2000); - check('OLD zombie server events no longer reach the UI (hub rebound to new upstream)', !received.some((r) => r.eventId === 'evt-zombie-old-server')); - } - - console.log(`\n=== ${failures === 0 ? 'REPRODUCED (all checks passed)' : `${failures} check(s) FAILED`} ===\n`); - console.log(`hub statuses observed: ${JSON.stringify(statuses)}`); -} catch (error) { - console.error('\nReproduction script error:', error); - failures += 1; -} finally { - await cleanup(); -} - -process.exit(failures === 0 ? 0 : 1); diff --git a/scripts/run-isolated-tests.mjs b/scripts/run-isolated-tests.mjs new file mode 100644 index 00000000..bfc24df2 --- /dev/null +++ b/scripts/run-isolated-tests.mjs @@ -0,0 +1,117 @@ +#!/usr/bin/env node +// Runs every test file under the given roots, each in its own process. +// +// Two properties of this repository make that the working arrangement rather +// than a preference: +// +// - The shared UI and extension suites keep module-level singletons (runtime +// endpoint, relay tunnel, stores, registries). Executed in one process they +// leak state into each other and fail by load order, which is why the relay +// guidance already says to run those files one at a time. +// - The same directories mix `bun:test` and `node:test` files, so no single +// runner command covers them. The framework is read from the file's imports +// instead of being listed here, so adding a test never requires editing a +// list that then rots. +// +// Usage: node scripts/run-isolated-tests.mjs [...roots] + +import { spawn } from 'node:child_process'; +import { readdirSync, readFileSync, statSync } from 'node:fs'; +import path from 'node:path'; + +const TEST_FILE = /\.(test|spec)\.(js|cjs|mjs|jsx|ts|tsx)$/; +const SKIP_DIRS = new Set(['node_modules', 'dist', 'dist-bundle', 'build', 'out', '.git', 'ios', 'android']); +const MAX_PARALLEL = 4; + +const collect = (root, found = []) => { + for (const entry of readdirSync(root, { withFileTypes: true })) { + if (entry.name.startsWith('.') && entry.name !== '.') continue; + const full = path.join(root, entry.name); + if (entry.isDirectory()) { + if (!SKIP_DIRS.has(entry.name)) collect(full, found); + continue; + } + if (TEST_FILE.test(entry.name)) found.push(full); + } + return found; +}; + +/** `null` when the file names no known runner, so it is reported instead of skipped silently. */ +const resolveCommand = (file) => { + const source = readFileSync(file, 'utf8'); + const isTypeScript = /\.tsx?$/.test(file); + // TypeScript goes to Bun even when the file imports `node:test`, which Bun + // implements. Node's ESM loader cannot resolve the extensionless local + // specifiers these files use (`./sseProxy`), so it never ran them at all. + if (isTypeScript || /from\s+['"]bun:test['"]/.test(source)) { + return { label: 'bun', command: 'bun', args: ['test', file] }; + } + if (/from\s+['"]node:test['"]/.test(source)) { + return { label: 'node', command: 'node', args: ['--test', file] }; + } + return null; +}; + +const run = ({ command, args }) => new Promise((resolve) => { + const child = spawn(command, args, { stdio: ['ignore', 'pipe', 'pipe'] }); + let output = ''; + child.stdout.on('data', (chunk) => { output += chunk; }); + child.stderr.on('data', (chunk) => { output += chunk; }); + child.on('error', (error) => resolve({ code: 1, output: `${output}${error.message}` })); + child.on('close', (code) => resolve({ code: code ?? 1, output })); +}); + +const roots = process.argv.slice(2); +if (roots.length === 0) { + console.error('run-isolated-tests: expected at least one root directory'); + process.exit(1); +} + +const files = []; +for (const root of roots) { + const resolved = path.resolve(root); + if (!statSync(resolved).isDirectory()) { + console.error(`run-isolated-tests: not a directory: ${root}`); + process.exit(1); + } + files.push(...collect(resolved)); +} +files.sort(); + +const failures = []; +const unknown = []; +let passed = 0; +let next = 0; + +const worker = async () => { + while (next < files.length) { + const file = files[next++]; + const relative = path.relative(process.cwd(), file); + const resolved = resolveCommand(file); + if (!resolved) { + unknown.push(relative); + continue; + } + const { code, output } = await run(resolved); + if (code === 0) { + passed += 1; + } else { + failures.push({ relative, label: resolved.label, output }); + console.error(`FAIL (${resolved.label}) ${relative}`); + } + } +}; + +await Promise.all(Array.from({ length: Math.min(MAX_PARALLEL, files.length) }, worker)); + +for (const failure of failures) { + console.error(`\n===== ${failure.relative} (${failure.label}) =====\n${failure.output}`); +} + +for (const file of unknown) { + console.error(`UNKNOWN RUNNER ${file}: imports neither bun:test nor node:test`); +} + +console.log(`\n${passed}/${files.length} test files passed${failures.length ? `, ${failures.length} failed` : ''}${unknown.length ? `, ${unknown.length} with no known runner` : ''}`); + +process.exit(failures.length > 0 || unknown.length > 0 ? 1 : 0);