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);