mirror of
https://github.com/stablyai/orca.git
synced 2026-10-17 00:03:08 +00:00
Merge origin/main (browser copy, TOC, mobile IME, i18n, Windows startup) + add version_suffix input to release-cut
rc kind can now append an extra prerelease identifier (rc.N.perf): sorts above its base rc.N but below rc.N+1, so perf-branch RCs are identifiable by clients without outranking the main RC series. Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
@@ -9,22 +9,8 @@ on:
|
||||
- 'mobile-android-v*'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
bump_patch_version:
|
||||
description: 'Bump the mobile marketing version patch number before release'
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
release_version:
|
||||
description: 'Optional exact mobile marketing version override, e.g. 0.0.15'
|
||||
required: false
|
||||
type: string
|
||||
bump_android_version_code:
|
||||
description: 'Increment the Android versionCode before release'
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
android_version_code:
|
||||
description: 'Optional exact Android versionCode override'
|
||||
description: 'Optional exact mobile marketing version assertion, e.g. 0.0.22'
|
||||
required: false
|
||||
type: string
|
||||
publish_github_release:
|
||||
@@ -69,9 +55,6 @@ jobs:
|
||||
id: release
|
||||
env:
|
||||
MOBILE_ANDROID_RELEASE_VERSION: ${{ github.event.inputs.release_version }}
|
||||
MOBILE_ANDROID_BUMP_PATCH_VERSION: ${{ github.event.inputs.bump_patch_version }}
|
||||
MOBILE_ANDROID_VERSION_CODE: ${{ github.event.inputs.android_version_code }}
|
||||
MOBILE_ANDROID_BUMP_VERSION_CODE: ${{ github.event.inputs.bump_android_version_code }}
|
||||
MOBILE_ANDROID_PUBLISH_RELEASE: ${{ github.event.inputs.publish_github_release }}
|
||||
run: node scripts/prepare-android-release.mjs
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
name: Cut Release
|
||||
|
||||
# Why: single entry point for manually and scheduled cutting releases.
|
||||
# Why: single entry point for manually cutting releases.
|
||||
# Replaces the old local `pnpm release:*` scripts and the standalone scheduled
|
||||
# RC workflow so releases are always reproducible from CI and can never be
|
||||
# accidentally tagged against an uncommitted or non-main working tree.
|
||||
@@ -37,16 +37,15 @@ on:
|
||||
type: string
|
||||
default: main
|
||||
dry_run:
|
||||
description: Validate a scheduled-RC run without creating a tag
|
||||
description: Validate an RC release cut without creating a tag
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
schedule:
|
||||
# Why: GitHub scheduled workflows are not guaranteed to start exactly on
|
||||
# time and can be delayed or dropped around high-load periods. We retry
|
||||
# across the full target hour and dedupe per PT slot. Cron is UTC, so run
|
||||
# during both PST and PDT equivalents and let the PT-hour gate decide.
|
||||
- cron: '*/5 10,11,22,23 * * *'
|
||||
version_suffix:
|
||||
description: Extra prerelease identifier appended to an rc version (e.g. "perf" -> 1.2.3-rc.4.perf). rc kind only.
|
||||
required: false
|
||||
type: string
|
||||
default: ''
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -182,8 +181,8 @@ jobs:
|
||||
|
||||
- name: Publish complete release-cut RC drafts from prior runs
|
||||
id: publish_drafts
|
||||
# Why: this must run even when the current slot already cut a tag; a
|
||||
# later cron retry may be the first chance to unstick a complete RC draft.
|
||||
# Why: a manual RC dispatch should unstick any complete RC draft before
|
||||
# deciding whether to cut another tag.
|
||||
if: steps.window.outputs.allowed == 'true' && !(github.event_name == 'workflow_dispatch' && inputs.dry_run)
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -198,6 +197,7 @@ jobs:
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
KIND: ${{ github.event_name == 'schedule' && 'rc' || inputs.kind }}
|
||||
VERSION_SUFFIX: ${{ github.event_name == 'schedule' && '' || inputs.version_suffix }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
@@ -372,6 +372,18 @@ jobs:
|
||||
fi
|
||||
new="${base}-rc.$((highest_rc + 1))"
|
||||
fi
|
||||
if [[ -n "${VERSION_SUFFIX:-}" ]]; then
|
||||
# Why a dot-appended identifier (rc.N.perf): it sorts just
|
||||
# above its own base rc.N but BELOW rc.N+1, so suffixed side-
|
||||
# branch builds never outrank the main RC series and cannot
|
||||
# hijack the update channel; clients find them by matching the
|
||||
# identifier ("perf") in the prerelease components.
|
||||
if [[ ! "$VERSION_SUFFIX" =~ ^[0-9A-Za-z]+$ ]]; then
|
||||
echo "::error::version_suffix must be alphanumeric, got: $VERSION_SUFFIX" >&2
|
||||
exit 1
|
||||
fi
|
||||
new="${new}.${VERSION_SUFFIX}"
|
||||
fi
|
||||
;;
|
||||
patch|minor|major)
|
||||
new="$(bump "$latest_stable" "$KIND")"
|
||||
|
||||
@@ -60,6 +60,9 @@ pnpm-debug.log*
|
||||
# Coverage
|
||||
coverage/
|
||||
|
||||
# Benchmark run output
|
||||
/tools/benchmarks/results/*.json
|
||||
|
||||
# Temp
|
||||
tmp/
|
||||
.tmp/
|
||||
|
||||
@@ -6,6 +6,51 @@ const API_VERSION = '2022-11-28'
|
||||
const MAX_RELEASE_BODY_LENGTH = 120_000
|
||||
const TRUNCATION_NOTICE =
|
||||
'\n\n---\nRelease notes were truncated because GitHub release bodies are limited to 125,000 characters.'
|
||||
const DESKTOP_RELEASE_TAG_PATTERN = /^v(\d+)\.(\d+)\.(\d+)(?:-rc\.(\d+))?$/
|
||||
|
||||
export function parseDesktopReleaseTag(tag) {
|
||||
const match = DESKTOP_RELEASE_TAG_PATTERN.exec(tag)
|
||||
if (!match) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
tag,
|
||||
major: Number(match[1]),
|
||||
minor: Number(match[2]),
|
||||
patch: Number(match[3]),
|
||||
rc: match[4] === undefined ? null : Number(match[4])
|
||||
}
|
||||
}
|
||||
|
||||
function compareDesktopReleaseTags(a, b) {
|
||||
const versionDiff = a.major - b.major || a.minor - b.minor || a.patch - b.patch
|
||||
if (versionDiff !== 0) {
|
||||
return versionDiff
|
||||
}
|
||||
if (a.rc === b.rc) {
|
||||
return 0
|
||||
}
|
||||
if (a.rc === null) {
|
||||
return 1
|
||||
}
|
||||
if (b.rc === null) {
|
||||
return -1
|
||||
}
|
||||
return a.rc - b.rc
|
||||
}
|
||||
|
||||
export function latestPreviousDesktopReleaseTag(tags, tag) {
|
||||
const current = parseDesktopReleaseTag(tag)
|
||||
if (!current) {
|
||||
return ''
|
||||
}
|
||||
const previousTags = tags
|
||||
.map((candidate) => parseDesktopReleaseTag(candidate))
|
||||
.filter((candidate) => candidate && candidate.tag !== current.tag)
|
||||
.filter((candidate) => compareDesktopReleaseTags(candidate, current) < 0)
|
||||
.sort(compareDesktopReleaseTags)
|
||||
return previousTags.at(-1)?.tag ?? ''
|
||||
}
|
||||
|
||||
function githubHeaders(token) {
|
||||
return {
|
||||
@@ -30,6 +75,29 @@ async function githubJson(fetchImpl, url, token, options = {}) {
|
||||
return res.json()
|
||||
}
|
||||
|
||||
async function fetchRepoTags(repo, token, fetchImpl) {
|
||||
const tags = []
|
||||
for (let page = 1; ; page += 1) {
|
||||
const pageTags = await githubJson(
|
||||
fetchImpl,
|
||||
`https://api.github.com/repos/${repo}/tags?per_page=100&page=${page}`,
|
||||
token
|
||||
)
|
||||
if (!Array.isArray(pageTags)) {
|
||||
throw new Error(`GitHub tags response page ${page} for ${repo} was not an array`)
|
||||
}
|
||||
for (const tag of pageTags) {
|
||||
if (typeof tag?.name === 'string') {
|
||||
tags.push(tag.name)
|
||||
}
|
||||
}
|
||||
if (pageTags.length < 100) {
|
||||
break
|
||||
}
|
||||
}
|
||||
return tags
|
||||
}
|
||||
|
||||
export function truncateReleaseBody(body, maxLength = MAX_RELEASE_BODY_LENGTH) {
|
||||
if (body.length <= maxLength) {
|
||||
return body
|
||||
@@ -60,16 +128,26 @@ export async function createDraftRelease({
|
||||
throw new Error('token is required')
|
||||
}
|
||||
|
||||
const previousTag = latestPreviousDesktopReleaseTag(
|
||||
await fetchRepoTags(repo, token, fetchImpl),
|
||||
tag
|
||||
)
|
||||
const generateNotesBody = {
|
||||
tag_name: tag,
|
||||
target_commitish: tag,
|
||||
...(previousTag ? { previous_tag_name: previousTag } : {})
|
||||
}
|
||||
|
||||
// Why: draft releases are invisible to GitHub's generate-notes baseline.
|
||||
// Passing the previous desktop tag keeps each release from accumulating
|
||||
// notes from older drafts.
|
||||
const releaseNotes = await githubJson(
|
||||
fetchImpl,
|
||||
`https://api.github.com/repos/${repo}/releases/generate-notes`,
|
||||
token,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
tag_name: tag,
|
||||
target_commitish: tag
|
||||
})
|
||||
body: JSON.stringify(generateNotesBody)
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { createDraftRelease, truncateReleaseBody } from './create-draft-release.mjs'
|
||||
import {
|
||||
createDraftRelease,
|
||||
latestPreviousDesktopReleaseTag,
|
||||
parseDesktopReleaseTag,
|
||||
truncateReleaseBody
|
||||
} from './create-draft-release.mjs'
|
||||
|
||||
function jsonResponse(body, init = {}) {
|
||||
return {
|
||||
@@ -24,10 +29,60 @@ describe('truncateReleaseBody', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseDesktopReleaseTag', () => {
|
||||
it('parses stable and rc desktop release tags only', () => {
|
||||
expect(parseDesktopReleaseTag('v1.4.36')).toMatchObject({
|
||||
tag: 'v1.4.36',
|
||||
major: 1,
|
||||
minor: 4,
|
||||
patch: 36,
|
||||
rc: null
|
||||
})
|
||||
expect(parseDesktopReleaseTag('v1.4.36-rc.2')).toMatchObject({
|
||||
tag: 'v1.4.36-rc.2',
|
||||
major: 1,
|
||||
minor: 4,
|
||||
patch: 36,
|
||||
rc: 2
|
||||
})
|
||||
expect(parseDesktopReleaseTag('mobile-v0.0.12')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('latestPreviousDesktopReleaseTag', () => {
|
||||
it('bounds stable notes to the previous rc when one exists', () => {
|
||||
expect(latestPreviousDesktopReleaseTag(['v1.4.35', 'v1.4.36-rc.0', 'v1.4.36'], 'v1.4.36')).toBe(
|
||||
'v1.4.36-rc.0'
|
||||
)
|
||||
})
|
||||
|
||||
it('bounds the first rc notes to the previous stable release', () => {
|
||||
expect(
|
||||
latestPreviousDesktopReleaseTag(['v1.4.35', 'v1.4.36-rc.0', 'mobile-v0.0.12'], 'v1.4.36-rc.0')
|
||||
).toBe('v1.4.35')
|
||||
})
|
||||
|
||||
it('bounds later rc notes to the prior rc', () => {
|
||||
expect(latestPreviousDesktopReleaseTag(['v1.4.36-rc.0', 'v1.4.36-rc.1'], 'v1.4.36-rc.1')).toBe(
|
||||
'v1.4.36-rc.0'
|
||||
)
|
||||
})
|
||||
|
||||
it('returns empty string for the first desktop release when no earlier tag exists', () => {
|
||||
expect(latestPreviousDesktopReleaseTag(['v1.4.36', 'mobile-v0.0.12'], 'v1.4.36')).toBe('')
|
||||
expect(latestPreviousDesktopReleaseTag([], 'v1.4.36')).toBe('')
|
||||
})
|
||||
|
||||
it('returns empty string when the current tag is not a desktop release tag', () => {
|
||||
expect(latestPreviousDesktopReleaseTag(['v1.4.35', 'v1.4.36'], 'mobile-v0.0.12')).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('createDraftRelease', () => {
|
||||
it('creates a draft release with bounded generated notes', async () => {
|
||||
const fetchImpl = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(jsonResponse([{ name: 'v1.4.35' }, { name: 'v1.4.36' }]))
|
||||
.mockResolvedValueOnce(jsonResponse({ name: 'v1.4.36', body: 'a'.repeat(130_000) }))
|
||||
.mockResolvedValueOnce(jsonResponse({ tag_name: 'v1.4.36', draft: true }))
|
||||
|
||||
@@ -41,17 +96,23 @@ describe('createDraftRelease', () => {
|
||||
|
||||
expect(fetchImpl).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'https://api.github.com/repos/stablyai/orca/tags?per_page=100&page=1',
|
||||
expect.any(Object)
|
||||
)
|
||||
expect(fetchImpl).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'https://api.github.com/repos/stablyai/orca/releases/generate-notes',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
tag_name: 'v1.4.36',
|
||||
target_commitish: 'v1.4.36'
|
||||
target_commitish: 'v1.4.36',
|
||||
previous_tag_name: 'v1.4.35'
|
||||
})
|
||||
})
|
||||
)
|
||||
expect(fetchImpl).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
3,
|
||||
'https://api.github.com/repos/stablyai/orca/releases',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
@@ -59,7 +120,7 @@ describe('createDraftRelease', () => {
|
||||
})
|
||||
)
|
||||
|
||||
const createBody = JSON.parse(fetchImpl.mock.calls[1][1].body)
|
||||
const createBody = JSON.parse(fetchImpl.mock.calls[2][1].body)
|
||||
expect(createBody).toMatchObject({
|
||||
tag_name: 'v1.4.36',
|
||||
name: 'v1.4.36',
|
||||
@@ -73,6 +134,7 @@ describe('createDraftRelease', () => {
|
||||
it('marks rc tags as prereleases', async () => {
|
||||
const fetchImpl = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(jsonResponse([{ name: 'v1.4.36' }, { name: 'v1.4.36-rc.1' }]))
|
||||
.mockResolvedValueOnce(jsonResponse({ name: 'v1.4.36-rc.1', body: 'notes' }))
|
||||
.mockResolvedValueOnce(jsonResponse({ tag_name: 'v1.4.36-rc.1', draft: true }))
|
||||
|
||||
@@ -84,7 +146,58 @@ describe('createDraftRelease', () => {
|
||||
log: vi.fn()
|
||||
})
|
||||
|
||||
const createBody = JSON.parse(fetchImpl.mock.calls[1][1].body)
|
||||
const createBody = JSON.parse(fetchImpl.mock.calls[2][1].body)
|
||||
expect(createBody.prerelease).toBe(true)
|
||||
})
|
||||
|
||||
it('omits previous_tag_name for the first desktop release so notes fall back to the GitHub default', async () => {
|
||||
const fetchImpl = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(jsonResponse([{ name: 'v1.4.36' }, { name: 'mobile-v0.0.12' }]))
|
||||
.mockResolvedValueOnce(jsonResponse({ name: 'v1.4.36', body: 'notes' }))
|
||||
.mockResolvedValueOnce(jsonResponse({ tag_name: 'v1.4.36', draft: true }))
|
||||
|
||||
await createDraftRelease({
|
||||
repo: 'stablyai/orca',
|
||||
tag: 'v1.4.36',
|
||||
token: 'token',
|
||||
fetchImpl,
|
||||
log: vi.fn()
|
||||
})
|
||||
|
||||
const generateNotesBody = JSON.parse(fetchImpl.mock.calls[1][1].body)
|
||||
expect(generateNotesBody).toEqual({ tag_name: 'v1.4.36', target_commitish: 'v1.4.36' })
|
||||
expect(generateNotesBody).not.toHaveProperty('previous_tag_name')
|
||||
})
|
||||
|
||||
it('paginates through every tag page before choosing the previous tag', async () => {
|
||||
const firstPage = Array.from({ length: 100 }, (_, index) => ({ name: `mobile-v0.0.${index}` }))
|
||||
const fetchImpl = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(jsonResponse(firstPage))
|
||||
.mockResolvedValueOnce(jsonResponse([{ name: 'v1.4.35' }]))
|
||||
.mockResolvedValueOnce(jsonResponse({ name: 'v1.4.36', body: 'notes' }))
|
||||
.mockResolvedValueOnce(jsonResponse({ tag_name: 'v1.4.36', draft: true }))
|
||||
|
||||
await createDraftRelease({
|
||||
repo: 'stablyai/orca',
|
||||
tag: 'v1.4.36',
|
||||
token: 'token',
|
||||
fetchImpl,
|
||||
log: vi.fn()
|
||||
})
|
||||
|
||||
expect(fetchImpl).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'https://api.github.com/repos/stablyai/orca/tags?per_page=100&page=1',
|
||||
expect.any(Object)
|
||||
)
|
||||
expect(fetchImpl).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'https://api.github.com/repos/stablyai/orca/tags?per_page=100&page=2',
|
||||
expect.any(Object)
|
||||
)
|
||||
const generateNotesBody = JSON.parse(fetchImpl.mock.calls[2][1].body)
|
||||
expect(generateNotesBody.previous_tag_name).toBe('v1.4.35')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="113" height="20" role="img" aria-label="downloads: 3.3m">
|
||||
<title>downloads: 3.3m</title>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="113" height="20" role="img" aria-label="downloads: 3.4m">
|
||||
<title>downloads: 3.4m</title>
|
||||
<linearGradient id="s" x2="0" y2="100%">
|
||||
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
|
||||
<stop offset="1" stop-opacity=".1"/>
|
||||
@@ -15,7 +15,7 @@
|
||||
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="11">
|
||||
<text x="37" y="15" fill="#010101" fill-opacity=".3">downloads</text>
|
||||
<text x="37" y="14">downloads</text>
|
||||
<text x="93.5" y="15" fill="#010101" fill-opacity=".3">3.3m</text>
|
||||
<text x="93.5" y="14">3.3m</text>
|
||||
<text x="93.5" y="15" fill="#010101" fill-opacity=".3">3.4m</text>
|
||||
<text x="93.5" y="14">3.4m</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 943 B After Width: | Height: | Size: 943 B |
Binary file not shown.
|
Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 434 KiB |
@@ -36,7 +36,7 @@
|
||||
|
||||
Supervisa y dirige a tus agentes desde el teléfono — recibe una notificación cuando un agente termine y envía instrucciones de seguimiento desde cualquier lugar.
|
||||
|
||||
[App Store de iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [APK para Android](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.18/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
|
||||
[App Store de iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [APK para Android](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.21/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
|
||||
|
||||
</td>
|
||||
<td width="50%">
|
||||
@@ -227,7 +227,7 @@ yay -S stably-orca-bin
|
||||
Vincúlala con tu app de escritorio para supervisar y dirigir a tus agentes desde el teléfono.
|
||||
|
||||
- **iOS:** [Descargar desde App Store](https://apps.apple.com/us/app/orca-ide/id6766130217)
|
||||
- **Android:** [Descargar el APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.18/app-release.apk)
|
||||
- **Android:** [Descargar el APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.21/app-release.apk)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
|
||||
スマートフォンからエージェントを監視・操作 — エージェントの完了を通知で受け取り、どこからでもフォローアップを送信できます。
|
||||
|
||||
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.18/app-release.apk) · [ドキュメント →](https://www.onorca.dev/docs/mobile)
|
||||
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.21/app-release.apk) · [ドキュメント →](https://www.onorca.dev/docs/mobile)
|
||||
|
||||
</td>
|
||||
<td width="50%">
|
||||
@@ -227,7 +227,7 @@ yay -S stably-orca-bin
|
||||
デスクトップアプリとペアリングして、スマートフォンからエージェントを監視・操作できます。
|
||||
|
||||
- **iOS:** [App Store からダウンロード](https://apps.apple.com/us/app/orca-ide/id6766130217)
|
||||
- **Android:** [APK をダウンロード](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.18/app-release.apk)
|
||||
- **Android:** [APK をダウンロード](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.21/app-release.apk)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
|
||||
휴대폰에서 에이전트를 모니터링하고 조종하세요 — 에이전트가 완료되면 알림을 받고 어디서든 후속 지시를 보낼 수 있습니다.
|
||||
|
||||
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.18/app-release.apk) · [문서 →](https://www.onorca.dev/docs/mobile)
|
||||
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.21/app-release.apk) · [문서 →](https://www.onorca.dev/docs/mobile)
|
||||
|
||||
</td>
|
||||
<td width="50%">
|
||||
@@ -227,7 +227,7 @@ yay -S stably-orca-bin
|
||||
데스크톱 앱과 페어링해 휴대폰에서 에이전트를 모니터링하고 조종하세요.
|
||||
|
||||
- **iOS:** [App Store에서 다운로드](https://apps.apple.com/us/app/orca-ide/id6766130217)
|
||||
- **Android:** [APK 다운로드](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.18/app-release.apk)
|
||||
- **Android:** [APK 다운로드](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.21/app-release.apk)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
|
||||
用手机监控并指挥你的智能体 — 智能体完成时收到通知,随时随地发送后续指令。
|
||||
|
||||
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.18/app-release.apk) · [文档 →](https://www.onorca.dev/docs/mobile)
|
||||
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.21/app-release.apk) · [文档 →](https://www.onorca.dev/docs/mobile)
|
||||
|
||||
</td>
|
||||
<td width="50%">
|
||||
@@ -227,7 +227,7 @@ yay -S stably-orca-bin
|
||||
与桌面应用配对,用手机监控并指挥你的智能体。
|
||||
|
||||
- **iOS:** [从 App Store 下载](https://apps.apple.com/us/app/orca-ide/id6766130217)
|
||||
- **Android:** [下载 APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.18/app-release.apk)
|
||||
- **Android:** [下载 APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.21/app-release.apk)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
node_modules/
|
||||
src/terminal/terminal-webview-engine.generated.ts
|
||||
.expo/
|
||||
dist/
|
||||
/android/
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||
"extends": ["../.oxlintrc.json"],
|
||||
"ignorePatterns": ["src/terminal/terminal-webview-engine.generated.ts"],
|
||||
"rules": {
|
||||
"react-hooks/exhaustive-deps": "off",
|
||||
"react/no-unescaped-entities": "off",
|
||||
|
||||
+2
-2
@@ -2,7 +2,7 @@
|
||||
"expo": {
|
||||
"name": "Orca",
|
||||
"slug": "orca-mobile",
|
||||
"version": "0.0.20",
|
||||
"version": "0.0.22",
|
||||
"orientation": "default",
|
||||
"icon": "./assets/icon.png",
|
||||
"userInterfaceStyle": "automatic",
|
||||
@@ -75,7 +75,7 @@
|
||||
"allowBackup": false,
|
||||
"permissions": ["RECORD_AUDIO", "MODIFY_AUDIO_SETTINGS"],
|
||||
"package": "com.stably.orca.mobile",
|
||||
"versionCode": 2
|
||||
"versionCode": 4
|
||||
},
|
||||
"plugins": [
|
||||
"expo-router",
|
||||
|
||||
@@ -96,6 +96,7 @@ import { createTerminalLiveAccessoryInput } from '../../../../src/terminal/termi
|
||||
import { getTerminalLiveAccessoryRawSendTarget } from '../../../../src/terminal/terminal-live-accessory-raw-send-target'
|
||||
import {
|
||||
clearTerminalLiveInputFocusTimer,
|
||||
focusTerminalLiveInputTarget,
|
||||
isTerminalLiveInputWithinByteLimit,
|
||||
scheduleTerminalLiveInputFocus
|
||||
} from '../../../../src/terminal/terminal-live-input'
|
||||
@@ -3134,8 +3135,12 @@ export default function SessionScreen() {
|
||||
if (!canSend || !liveInputEnabled) {
|
||||
return
|
||||
}
|
||||
liveInputRef.current?.focus()
|
||||
}, [canSend, liveInputEnabled])
|
||||
focusTerminalLiveInputTarget(liveInputRef.current, {
|
||||
keyboardHeight,
|
||||
refocus: () =>
|
||||
scheduleTerminalLiveInputFocus(liveInputFocusTimerRef, () => liveInputRef.current?.focus())
|
||||
})
|
||||
}, [canSend, keyboardHeight, liveInputEnabled])
|
||||
|
||||
const clearSessionTabActionSheetKeyboardListener = useCallback(() => {
|
||||
sessionTabActionSheetKeyboardHideSubRef.current?.remove()
|
||||
@@ -4947,10 +4952,16 @@ export default function SessionScreen() {
|
||||
{liveInputEnabled ? (
|
||||
<View style={[styles.inputBar, styles.liveInputBar]}>
|
||||
<Pressable
|
||||
style={styles.liveInputFocusTarget}
|
||||
style={({ pressed }) => [
|
||||
styles.liveInputFocusTarget,
|
||||
pressed && styles.liveInputFocusTargetPressed,
|
||||
!canSend && styles.liveInputFocusTargetDisabled
|
||||
]}
|
||||
disabled={!canSend}
|
||||
onPress={focusLiveInput}
|
||||
accessibilityLabel="Focus live terminal input"
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Show keyboard for live terminal input"
|
||||
accessibilityHint="Typed text is sent directly to the active terminal"
|
||||
>
|
||||
<KeyboardIcon size={16} color={colors.textSecondary} strokeWidth={2} />
|
||||
<MobileTerminalLiveInputStatus
|
||||
@@ -4981,6 +4992,7 @@ export default function SessionScreen() {
|
||||
onKeyPress={handleLiveInputKeyPress}
|
||||
onSubmitEditing={handleLiveInputSubmit}
|
||||
placeholder=""
|
||||
showSoftInputOnFocus
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
spellCheck={false}
|
||||
|
||||
@@ -141,7 +141,20 @@ export const mobileSessionCommandInputStyles = StyleSheet.create({
|
||||
minHeight: 34,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm
|
||||
gap: spacing.sm,
|
||||
backgroundColor: colors.bgRaised,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.borderSubtle,
|
||||
borderRadius: radii.input,
|
||||
paddingHorizontal: spacing.sm + 2
|
||||
},
|
||||
|
||||
liveInputFocusTargetPressed: {
|
||||
backgroundColor: colors.borderSubtle
|
||||
},
|
||||
|
||||
liveInputFocusTargetDisabled: {
|
||||
opacity: 0.45
|
||||
},
|
||||
|
||||
liveInputCapture: {
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
"start": "node scripts/start-expo.mjs",
|
||||
"android": "expo run:android",
|
||||
"ios": "expo run:ios",
|
||||
"postinstall": "node scripts/build-terminal-webview-engine.mjs",
|
||||
"test": "vitest run",
|
||||
"lint": "oxlint",
|
||||
"format": "oxfmt --write .",
|
||||
@@ -18,6 +19,9 @@
|
||||
"dependencies": {
|
||||
"@orca/expo-two-way-audio": "file:./packages/expo-two-way-audio",
|
||||
"@react-native-async-storage/async-storage": "^2.2.0",
|
||||
"@xterm/addon-unicode11": "0.10.0-beta.285",
|
||||
"@xterm/addon-webgl": "0.20.0-beta.284",
|
||||
"@xterm/xterm": "6.1.0-beta.285",
|
||||
"buffer": "^6.0.3",
|
||||
"expo": "^55.0.23",
|
||||
"expo-build-properties": "^55.0.13",
|
||||
@@ -62,6 +66,8 @@
|
||||
"@types/react-native": "^0.73.0",
|
||||
"@types/react-test-renderer": "19.1.0",
|
||||
"@types/ws": "^8.18.1",
|
||||
"acorn": "8.15.0",
|
||||
"esbuild": "0.25.4",
|
||||
"expo-module-scripts": "^55.0.2",
|
||||
"oxfmt": "^0.52.0",
|
||||
"oxlint": "^1.71.0",
|
||||
|
||||
Generated
+317
-47
@@ -14,6 +14,15 @@ importers:
|
||||
'@react-native-async-storage/async-storage':
|
||||
specifier: ^2.2.0
|
||||
version: 2.2.0(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))
|
||||
'@xterm/addon-unicode11':
|
||||
specifier: 0.10.0-beta.285
|
||||
version: 0.10.0-beta.285(@xterm/xterm@6.1.0-beta.285)
|
||||
'@xterm/addon-webgl':
|
||||
specifier: 0.20.0-beta.284
|
||||
version: 0.20.0-beta.284(@xterm/xterm@6.1.0-beta.285)
|
||||
'@xterm/xterm':
|
||||
specifier: 6.1.0-beta.285
|
||||
version: 6.1.0-beta.285
|
||||
buffer:
|
||||
specifier: ^6.0.3
|
||||
version: 6.0.3
|
||||
@@ -141,9 +150,15 @@ importers:
|
||||
'@types/ws':
|
||||
specifier: ^8.18.1
|
||||
version: 8.18.1
|
||||
acorn:
|
||||
specifier: 8.15.0
|
||||
version: 8.15.0
|
||||
esbuild:
|
||||
specifier: 0.25.4
|
||||
version: 0.25.4
|
||||
expo-module-scripts:
|
||||
specifier: ^55.0.2
|
||||
version: 55.0.2(@babel/core@7.29.7)(@babel/runtime@7.29.2)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(esbuild@0.28.1)(eslint@9.39.4)(expo@55.0.23)(jest@29.7.0(@types/node@25.9.3))(prettier@2.8.8)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react-refresh@0.14.2)(react-test-renderer@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
version: 55.0.2(@babel/core@7.29.7)(@babel/runtime@7.29.2)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(esbuild@0.25.4)(eslint@9.39.4)(expo@55.0.23)(jest@29.7.0(@types/node@25.9.3))(prettier@2.8.8)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react-refresh@0.14.2)(react-test-renderer@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
oxfmt:
|
||||
specifier: ^0.52.0
|
||||
version: 0.52.0
|
||||
@@ -161,10 +176,10 @@ importers:
|
||||
version: 5.9.3
|
||||
vite:
|
||||
specifier: ^8.0.16
|
||||
version: 8.1.0(@types/node@25.9.3)(esbuild@0.28.1)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0)
|
||||
version: 8.1.0(@types/node@25.9.3)(esbuild@0.25.4)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0)
|
||||
vitest:
|
||||
specifier: ^4.1.9
|
||||
version: 4.1.9(@types/node@25.9.3)(jsdom@20.0.3)(vite@8.1.0(@types/node@25.9.3)(esbuild@0.28.1)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0))
|
||||
version: 4.1.9(@types/node@25.9.3)(jsdom@20.0.3)(vite@8.1.0(@types/node@25.9.3)(esbuild@0.25.4)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0))
|
||||
|
||||
packages:
|
||||
|
||||
@@ -1157,126 +1172,252 @@ packages:
|
||||
'@emnapi/wasi-threads@1.2.2':
|
||||
resolution: {integrity: sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==}
|
||||
|
||||
'@esbuild/aix-ppc64@0.25.4':
|
||||
resolution: {integrity: sha512-1VCICWypeQKhVbE9oW/sJaAmjLxhVqacdkvPLEjwlttjfwENRSClS8EjBz0KzRyFSCPDIkuXW34Je/vk7zdB7Q==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [ppc64]
|
||||
os: [aix]
|
||||
|
||||
'@esbuild/aix-ppc64@0.28.1':
|
||||
resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [ppc64]
|
||||
os: [aix]
|
||||
|
||||
'@esbuild/android-arm64@0.25.4':
|
||||
resolution: {integrity: sha512-bBy69pgfhMGtCnwpC/x5QhfxAz/cBgQ9enbtwjf6V9lnPI/hMyT9iWpR1arm0l3kttTr4L0KSLpKmLp/ilKS9A==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [android]
|
||||
|
||||
'@esbuild/android-arm64@0.28.1':
|
||||
resolution: {integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [android]
|
||||
|
||||
'@esbuild/android-arm@0.25.4':
|
||||
resolution: {integrity: sha512-QNdQEps7DfFwE3hXiU4BZeOV68HHzYwGd0Nthhd3uCkkEKK7/R6MTgM0P7H7FAs5pU/DIWsviMmEGxEoxIZ+ZQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm]
|
||||
os: [android]
|
||||
|
||||
'@esbuild/android-arm@0.28.1':
|
||||
resolution: {integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm]
|
||||
os: [android]
|
||||
|
||||
'@esbuild/android-x64@0.25.4':
|
||||
resolution: {integrity: sha512-TVhdVtQIFuVpIIR282btcGC2oGQoSfZfmBdTip2anCaVYcqWlZXGcdcKIUklfX2wj0JklNYgz39OBqh2cqXvcQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [android]
|
||||
|
||||
'@esbuild/android-x64@0.28.1':
|
||||
resolution: {integrity: sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [android]
|
||||
|
||||
'@esbuild/darwin-arm64@0.25.4':
|
||||
resolution: {integrity: sha512-Y1giCfM4nlHDWEfSckMzeWNdQS31BQGs9/rouw6Ub91tkK79aIMTH3q9xHvzH8d0wDru5Ci0kWB8b3up/nl16g==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@esbuild/darwin-arm64@0.28.1':
|
||||
resolution: {integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@esbuild/darwin-x64@0.25.4':
|
||||
resolution: {integrity: sha512-CJsry8ZGM5VFVeyUYB3cdKpd/H69PYez4eJh1W/t38vzutdjEjtP7hB6eLKBoOdxcAlCtEYHzQ/PJ/oU9I4u0A==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@esbuild/darwin-x64@0.28.1':
|
||||
resolution: {integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@esbuild/freebsd-arm64@0.25.4':
|
||||
resolution: {integrity: sha512-yYq+39NlTRzU2XmoPW4l5Ifpl9fqSk0nAJYM/V/WUGPEFfek1epLHJIkTQM6bBs1swApjO5nWgvr843g6TjxuQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [freebsd]
|
||||
|
||||
'@esbuild/freebsd-arm64@0.28.1':
|
||||
resolution: {integrity: sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [freebsd]
|
||||
|
||||
'@esbuild/freebsd-x64@0.25.4':
|
||||
resolution: {integrity: sha512-0FgvOJ6UUMflsHSPLzdfDnnBBVoCDtBTVyn/MrWloUNvq/5SFmh13l3dvgRPkDihRxb77Y17MbqbCAa2strMQQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [freebsd]
|
||||
|
||||
'@esbuild/freebsd-x64@0.28.1':
|
||||
resolution: {integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [freebsd]
|
||||
|
||||
'@esbuild/linux-arm64@0.25.4':
|
||||
resolution: {integrity: sha512-+89UsQTfXdmjIvZS6nUnOOLoXnkUTB9hR5QAeLrQdzOSWZvNSAXAtcRDHWtqAUtAmv7ZM1WPOOeSxDzzzMogiQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-arm64@0.28.1':
|
||||
resolution: {integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-arm@0.25.4':
|
||||
resolution: {integrity: sha512-kro4c0P85GMfFYqW4TWOpvmF8rFShbWGnrLqlzp4X1TNWjRY3JMYUfDCtOxPKOIY8B0WC8HN51hGP4I4hz4AaQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-arm@0.28.1':
|
||||
resolution: {integrity: sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-ia32@0.25.4':
|
||||
resolution: {integrity: sha512-yTEjoapy8UP3rv8dB0ip3AfMpRbyhSN3+hY8mo/i4QXFeDxmiYbEKp3ZRjBKcOP862Ua4b1PDfwlvbuwY7hIGQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [ia32]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-ia32@0.28.1':
|
||||
resolution: {integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [ia32]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-loong64@0.25.4':
|
||||
resolution: {integrity: sha512-NeqqYkrcGzFwi6CGRGNMOjWGGSYOpqwCjS9fvaUlX5s3zwOtn1qwg1s2iE2svBe4Q/YOG1q6875lcAoQK/F4VA==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [loong64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-loong64@0.28.1':
|
||||
resolution: {integrity: sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [loong64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-mips64el@0.25.4':
|
||||
resolution: {integrity: sha512-IcvTlF9dtLrfL/M8WgNI/qJYBENP3ekgsHbYUIzEzq5XJzzVEV/fXY9WFPfEEXmu3ck2qJP8LG/p3Q8f7Zc2Xg==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [mips64el]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-mips64el@0.28.1':
|
||||
resolution: {integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [mips64el]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-ppc64@0.25.4':
|
||||
resolution: {integrity: sha512-HOy0aLTJTVtoTeGZh4HSXaO6M95qu4k5lJcH4gxv56iaycfz1S8GO/5Jh6X4Y1YiI0h7cRyLi+HixMR+88swag==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-ppc64@0.28.1':
|
||||
resolution: {integrity: sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-riscv64@0.25.4':
|
||||
resolution: {integrity: sha512-i8JUDAufpz9jOzo4yIShCTcXzS07vEgWzyX3NH2G7LEFVgrLEhjwL3ajFE4fZI3I4ZgiM7JH3GQ7ReObROvSUA==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-riscv64@0.28.1':
|
||||
resolution: {integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-s390x@0.25.4':
|
||||
resolution: {integrity: sha512-jFnu+6UbLlzIjPQpWCNh5QtrcNfMLjgIavnwPQAfoGx4q17ocOU9MsQ2QVvFxwQoWpZT8DvTLooTvmOQXkO51g==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-s390x@0.28.1':
|
||||
resolution: {integrity: sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-x64@0.25.4':
|
||||
resolution: {integrity: sha512-6e0cvXwzOnVWJHq+mskP8DNSrKBr1bULBvnFLpc1KY+d+irZSgZ02TGse5FsafKS5jg2e4pbvK6TPXaF/A6+CA==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-x64@0.28.1':
|
||||
resolution: {integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/netbsd-arm64@0.25.4':
|
||||
resolution: {integrity: sha512-vUnkBYxZW4hL/ie91hSqaSNjulOnYXE1VSLusnvHg2u3jewJBz3YzB9+oCw8DABeVqZGg94t9tyZFoHma8gWZQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [netbsd]
|
||||
|
||||
'@esbuild/netbsd-arm64@0.28.1':
|
||||
resolution: {integrity: sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [netbsd]
|
||||
|
||||
'@esbuild/netbsd-x64@0.25.4':
|
||||
resolution: {integrity: sha512-XAg8pIQn5CzhOB8odIcAm42QsOfa98SBeKUdo4xa8OvX8LbMZqEtgeWE9P/Wxt7MlG2QqvjGths+nq48TrUiKw==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [netbsd]
|
||||
|
||||
'@esbuild/netbsd-x64@0.28.1':
|
||||
resolution: {integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [netbsd]
|
||||
|
||||
'@esbuild/openbsd-arm64@0.25.4':
|
||||
resolution: {integrity: sha512-Ct2WcFEANlFDtp1nVAXSNBPDxyU+j7+tId//iHXU2f/lN5AmO4zLyhDcpR5Cz1r08mVxzt3Jpyt4PmXQ1O6+7A==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [openbsd]
|
||||
|
||||
'@esbuild/openbsd-arm64@0.28.1':
|
||||
resolution: {integrity: sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [openbsd]
|
||||
|
||||
'@esbuild/openbsd-x64@0.25.4':
|
||||
resolution: {integrity: sha512-xAGGhyOQ9Otm1Xu8NT1ifGLnA6M3sJxZ6ixylb+vIUVzvvd6GOALpwQrYrtlPouMqd/vSbgehz6HaVk4+7Afhw==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [openbsd]
|
||||
|
||||
'@esbuild/openbsd-x64@0.28.1':
|
||||
resolution: {integrity: sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -1289,24 +1430,48 @@ packages:
|
||||
cpu: [arm64]
|
||||
os: [openharmony]
|
||||
|
||||
'@esbuild/sunos-x64@0.25.4':
|
||||
resolution: {integrity: sha512-Mw+tzy4pp6wZEK0+Lwr76pWLjrtjmJyUB23tHKqEDP74R3q95luY/bXqXZeYl4NYlvwOqoRKlInQialgCKy67Q==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [sunos]
|
||||
|
||||
'@esbuild/sunos-x64@0.28.1':
|
||||
resolution: {integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [sunos]
|
||||
|
||||
'@esbuild/win32-arm64@0.25.4':
|
||||
resolution: {integrity: sha512-AVUP428VQTSddguz9dO9ngb+E5aScyg7nOeJDrF1HPYu555gmza3bDGMPhmVXL8svDSoqPCsCPjb265yG/kLKQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@esbuild/win32-arm64@0.28.1':
|
||||
resolution: {integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@esbuild/win32-ia32@0.25.4':
|
||||
resolution: {integrity: sha512-i1sW+1i+oWvQzSgfRcxxG2k4I9n3O9NRqy8U+uugaT2Dy7kLO9Y7wI72haOahxceMX8hZAzgGou1FhndRldxRg==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [ia32]
|
||||
os: [win32]
|
||||
|
||||
'@esbuild/win32-ia32@0.28.1':
|
||||
resolution: {integrity: sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [ia32]
|
||||
os: [win32]
|
||||
|
||||
'@esbuild/win32-x64@0.25.4':
|
||||
resolution: {integrity: sha512-nOT2vZNw6hJ+z43oP1SPea/G/6AbN6X+bGNhNuq8NtRHy4wsMhw765IKLNmnjek7GvjWBYQ8Q5VBoYTFg9y1UQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@esbuild/win32-x64@0.28.1':
|
||||
resolution: {integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -1730,56 +1895,48 @@ packages:
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@oxfmt/binding-linux-arm64-musl@0.52.0':
|
||||
resolution: {integrity: sha512-wZg6bLjDvh2KibyI3QFUYo8GTXneIFsd0JvehtvJiUmQ8WRPERgxd/VM4ctWb86U5FT1FkqgS8/wZKVB+AZScg==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@oxfmt/binding-linux-ppc64-gnu@0.52.0':
|
||||
resolution: {integrity: sha512-IngE8uxhNvxcMrLjZNDo9xNLY7rEK33AKnaMd2B46he1e/mz2CfcW6If/U1wUjdRZddm1QzQaciqZkuMkdh1FA==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@oxfmt/binding-linux-riscv64-gnu@0.52.0':
|
||||
resolution: {integrity: sha512-H3+DdFMv/efN3Efmhsv18jDrpiWWqKG7wsfAlQBqAt6z/E2Bx+TwEj2Nowe51CPOWB8/mFBC2dAMSgVFLvvowA==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@oxfmt/binding-linux-riscv64-musl@0.52.0':
|
||||
resolution: {integrity: sha512-zji+1kb7lJKohSDjzC1IsS+K/cKRs1hdVf0ZH0VbdbiakmtLvN9twBoXo/k8VdjFax7kfo+DyPxS7vv52br1aw==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@oxfmt/binding-linux-s390x-gnu@0.52.0':
|
||||
resolution: {integrity: sha512-hcLBYedpCy7ToUvvBidWk7+11Yhg1oAZ4+6hKPic/mQI6NaqXJSXMps5nFlwUuX2ewhtLZZDPg63TI042qGKBg==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@oxfmt/binding-linux-x64-gnu@0.52.0':
|
||||
resolution: {integrity: sha512-IDO2loXK2OtTOhSPchU9MW25mWL2QCDGdJbjN8MXKZVS80qXe5gMTwQWu/gMJ3juoBHbkuUZNB2N1LHzNT7DoA==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@oxfmt/binding-linux-x64-musl@0.52.0':
|
||||
resolution: {integrity: sha512-mAV2Hjn0SatJ+KoAzKUC3eJhdJ8wv+3m1KyuS0dTsbF0c5weq+QrCt/DRZZM+uj/XiKzCDEUKYsBF30e2qkcyw==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@oxfmt/binding-openharmony-arm64@0.52.0':
|
||||
resolution: {integrity: sha512-vd4npaUIwChxp7XzkqmepBWTT9YMcSe/NBApVGPC30/lLyOVaV3dvma1SKo03t8O73BPRAG7EyJzGlN5cJM5hQ==}
|
||||
@@ -1852,56 +2009,48 @@ packages:
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@oxlint/binding-linux-arm64-musl@1.71.0':
|
||||
resolution: {integrity: sha512-fJZrs5sDZtTaPIOiemRQQmo82Ezy+vOGXemPc4Ok7iVVsYsFa7SlW6Z5XN819VfsqBHRm3NJ3rTdnR8+bJYJdQ==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@oxlint/binding-linux-ppc64-gnu@1.71.0':
|
||||
resolution: {integrity: sha512-cwl7VKGERIy9p+G+AvZdfy/06q0aHXaTt/mMRReC751iuNYJgqKjB7NydXSS30nBT9vtr2tunciOtrR4fD6FUA==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@oxlint/binding-linux-riscv64-gnu@1.71.0':
|
||||
resolution: {integrity: sha512-eZ8ieVXvzGi8jr7+ybQGPK2STw3mldfxZlgA2738iflfB/rzA69sE6m5rDRpQaxC7dpm745Enlh1Tod0QAk9Gg==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@oxlint/binding-linux-riscv64-musl@1.71.0':
|
||||
resolution: {integrity: sha512-puMDbQYe6+NXwfMusojoA7CXGn2b3utukmd23PQqc1E3XhVCwyZ+FueSMzDYeNgDV2dUfIVXAAKZBcFDeCL6sA==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@oxlint/binding-linux-s390x-gnu@1.71.0':
|
||||
resolution: {integrity: sha512-4NJLxBs1ujISCt3L/1FcywLs73PWtJuw+piD6feK2V6h6OS6P7xu9/sWt1DTRLibe6QCzmfZzmM/2HPORoV/Lg==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@oxlint/binding-linux-x64-gnu@1.71.0':
|
||||
resolution: {integrity: sha512-cFDaiR8L3430qp88tfZnvFlt3KotFhR/DlbIL0nHOMMYiG/9Wy4l+6f7t8G8pTa9bd8Lt8+M0y/qjRQ/xcB74g==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@oxlint/binding-linux-x64-musl@1.71.0':
|
||||
resolution: {integrity: sha512-orfixdt76KlpNly9z0PkWBBNfwjKz+JFVLP/7wnVchlKNU9Dpt9InU/ZggeSej6fC7qwHmHNOGlhLnQXcYoGuA==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@oxlint/binding-openharmony-arm64@1.71.0':
|
||||
resolution: {integrity: sha512-9emQu2lAp6yhPB3XuI+++vR+l/o6JR1X+EpxwcumPdQXBWXEPAsquPGL7l158EqU8SebQMXTUa/S5zN98juyHw==}
|
||||
@@ -2363,42 +2512,36 @@ packages:
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rolldown/binding-linux-arm64-musl@1.1.3':
|
||||
resolution: {integrity: sha512-BO9+oPL8K9poZJBfYPsXNtYjPE5uM3qeehT3aFcW4LITOl+iSqhp0abzjR2nWBUNjIZeKXjAEWBZ64WjNoHd6w==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rolldown/binding-linux-ppc64-gnu@1.1.3':
|
||||
resolution: {integrity: sha512-f3VpLB1vQ0Eo6ecr/6cekLnvYMFF4YBFoVGkfkvPLq1bAkbAwHYQPZKoAmG6OJyTcxxoC+AvezGx/S1obNC0Mw==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rolldown/binding-linux-s390x-gnu@1.1.3':
|
||||
resolution: {integrity: sha512-AmurZ26Pqx/RI9N1gzEOCklkKXl927yjfXWUUS0O7Puh8ARM/Ob8qfrD3qnWksScdw6cSrW5PSHE9DyLu7+PtA==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rolldown/binding-linux-x64-gnu@1.1.3':
|
||||
resolution: {integrity: sha512-JJpqs8bRGITDOdbkNKnlojzBabbOHrqjSvDr0IVsZObE1lBcPjxItUEY9eWIDbxaJ3cGrXPWGfGkIxFijg/URg==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rolldown/binding-linux-x64-musl@1.1.3':
|
||||
resolution: {integrity: sha512-rSJcdjPxzA/by/6/rYs+v+bXU7UjvnbUWz8MJb6kh6+knqB1dCrtHg0uu7C/4haqJvqdkYHQ5IGn+tCH9GLW/g==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rolldown/binding-openharmony-arm64@1.1.3':
|
||||
resolution: {integrity: sha512-hQ3/PYkDJICgevvyNcVrihVeqq7k1Pp3VZ9lY+dauAYUJKO+auqApvANhvR1An9BhmqYKvW2Mu1F9u4DXSMLxQ==}
|
||||
@@ -2653,6 +2796,19 @@ packages:
|
||||
resolution: {integrity: sha512-A9gOqLdi6cV4ibazAjcQufGj0B1y/vDqYrcuP6d/6x8P27gRS8643Dj9o1dEKtB6O7fwxb2FgBmJS2mX7gpvdw==}
|
||||
engines: {node: '>=14.6'}
|
||||
|
||||
'@xterm/addon-unicode11@0.10.0-beta.285':
|
||||
resolution: {integrity: sha512-rfijFu7UcYpaFx5wzxvTpQbIyyq/amf2PuS9pktywcFQr4ITxRgid5EVzKLRG1vchkApNcQplWeYxGEtjiw0Cw==}
|
||||
peerDependencies:
|
||||
'@xterm/xterm': ^6.1.0-beta.285
|
||||
|
||||
'@xterm/addon-webgl@0.20.0-beta.284':
|
||||
resolution: {integrity: sha512-tzkUiEfdpHCY8mXCbuIaP9V67QDfBJvDr9jdxs5jjxNCIQvw+NCoKD97y5sUrQhrIlr7xrDGniPgPYThQ/1FWg==}
|
||||
peerDependencies:
|
||||
'@xterm/xterm': ^6.1.0-beta.285
|
||||
|
||||
'@xterm/xterm@6.1.0-beta.285':
|
||||
resolution: {integrity: sha512-S3K58tepMkbpWRBzOGKd0In6AVvt9QPAnNs8DJ8rPUPODYtsCYWAtINHKYtC2OpXcE5EBKM35dl+Dgv03OoE/w==}
|
||||
|
||||
abab@2.0.6:
|
||||
resolution: {integrity: sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA==}
|
||||
deprecated: Use your platform's native atob() and btoa() methods instead
|
||||
@@ -2681,8 +2837,8 @@ packages:
|
||||
resolution: {integrity: sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==}
|
||||
engines: {node: '>=0.4.0'}
|
||||
|
||||
acorn@8.16.0:
|
||||
resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==}
|
||||
acorn@8.15.0:
|
||||
resolution: {integrity: sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==}
|
||||
engines: {node: '>=0.4.0'}
|
||||
hasBin: true
|
||||
|
||||
@@ -3378,6 +3534,11 @@ packages:
|
||||
resolution: {integrity: sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
esbuild@0.25.4:
|
||||
resolution: {integrity: sha512-8pgjLUcUjcgDg+2Q4NYXnPbo/vncAY4UmyaCm0jZevERqCHZIaWwdJHkf8XQtu4AxSKCdvrUbT0XUr1IdZzI8Q==}
|
||||
engines: {node: '>=18'}
|
||||
hasBin: true
|
||||
|
||||
esbuild@0.28.1:
|
||||
resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -4660,28 +4821,24 @@ packages:
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
lightningcss-linux-arm64-musl@1.32.0:
|
||||
resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
lightningcss-linux-x64-gnu@1.32.0:
|
||||
resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
lightningcss-linux-x64-musl@1.32.0:
|
||||
resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
lightningcss-win32-arm64-msvc@1.32.0:
|
||||
resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==}
|
||||
@@ -7675,81 +7832,156 @@ snapshots:
|
||||
tslib: 2.8.1
|
||||
optional: true
|
||||
|
||||
'@esbuild/aix-ppc64@0.25.4':
|
||||
optional: true
|
||||
|
||||
'@esbuild/aix-ppc64@0.28.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/android-arm64@0.25.4':
|
||||
optional: true
|
||||
|
||||
'@esbuild/android-arm64@0.28.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/android-arm@0.25.4':
|
||||
optional: true
|
||||
|
||||
'@esbuild/android-arm@0.28.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/android-x64@0.25.4':
|
||||
optional: true
|
||||
|
||||
'@esbuild/android-x64@0.28.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/darwin-arm64@0.25.4':
|
||||
optional: true
|
||||
|
||||
'@esbuild/darwin-arm64@0.28.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/darwin-x64@0.25.4':
|
||||
optional: true
|
||||
|
||||
'@esbuild/darwin-x64@0.28.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/freebsd-arm64@0.25.4':
|
||||
optional: true
|
||||
|
||||
'@esbuild/freebsd-arm64@0.28.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/freebsd-x64@0.25.4':
|
||||
optional: true
|
||||
|
||||
'@esbuild/freebsd-x64@0.28.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-arm64@0.25.4':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-arm64@0.28.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-arm@0.25.4':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-arm@0.28.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-ia32@0.25.4':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-ia32@0.28.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-loong64@0.25.4':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-loong64@0.28.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-mips64el@0.25.4':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-mips64el@0.28.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-ppc64@0.25.4':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-ppc64@0.28.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-riscv64@0.25.4':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-riscv64@0.28.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-s390x@0.25.4':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-s390x@0.28.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-x64@0.25.4':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-x64@0.28.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/netbsd-arm64@0.25.4':
|
||||
optional: true
|
||||
|
||||
'@esbuild/netbsd-arm64@0.28.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/netbsd-x64@0.25.4':
|
||||
optional: true
|
||||
|
||||
'@esbuild/netbsd-x64@0.28.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/openbsd-arm64@0.25.4':
|
||||
optional: true
|
||||
|
||||
'@esbuild/openbsd-arm64@0.28.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/openbsd-x64@0.25.4':
|
||||
optional: true
|
||||
|
||||
'@esbuild/openbsd-x64@0.28.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/openharmony-arm64@0.28.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/sunos-x64@0.25.4':
|
||||
optional: true
|
||||
|
||||
'@esbuild/sunos-x64@0.28.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/win32-arm64@0.25.4':
|
||||
optional: true
|
||||
|
||||
'@esbuild/win32-arm64@0.28.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/win32-ia32@0.25.4':
|
||||
optional: true
|
||||
|
||||
'@esbuild/win32-ia32@0.28.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/win32-x64@0.25.4':
|
||||
optional: true
|
||||
|
||||
'@esbuild/win32-x64@0.28.1':
|
||||
optional: true
|
||||
|
||||
@@ -9327,13 +9559,13 @@ snapshots:
|
||||
chai: 6.2.2
|
||||
tinyrainbow: 3.1.0
|
||||
|
||||
'@vitest/mocker@4.1.9(vite@8.1.0(@types/node@25.9.3)(esbuild@0.28.1)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0))':
|
||||
'@vitest/mocker@4.1.9(vite@8.1.0(@types/node@25.9.3)(esbuild@0.25.4)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0))':
|
||||
dependencies:
|
||||
'@vitest/spy': 4.1.9
|
||||
estree-walker: 3.0.3
|
||||
magic-string: 0.30.21
|
||||
optionalDependencies:
|
||||
vite: 8.1.0(@types/node@25.9.3)(esbuild@0.28.1)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0)
|
||||
vite: 8.1.0(@types/node@25.9.3)(esbuild@0.25.4)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0)
|
||||
|
||||
'@vitest/pretty-format@4.1.9':
|
||||
dependencies:
|
||||
@@ -9363,6 +9595,16 @@ snapshots:
|
||||
|
||||
'@xmldom/xmldom@0.9.10': {}
|
||||
|
||||
'@xterm/addon-unicode11@0.10.0-beta.285(@xterm/xterm@6.1.0-beta.285)':
|
||||
dependencies:
|
||||
'@xterm/xterm': 6.1.0-beta.285
|
||||
|
||||
'@xterm/addon-webgl@0.20.0-beta.284(@xterm/xterm@6.1.0-beta.285)':
|
||||
dependencies:
|
||||
'@xterm/xterm': 6.1.0-beta.285
|
||||
|
||||
'@xterm/xterm@6.1.0-beta.285': {}
|
||||
|
||||
abab@2.0.6: {}
|
||||
|
||||
abort-controller@3.0.0:
|
||||
@@ -9381,7 +9623,7 @@ snapshots:
|
||||
|
||||
acorn-globals@7.0.1:
|
||||
dependencies:
|
||||
acorn: 8.16.0
|
||||
acorn: 8.15.0
|
||||
acorn-walk: 8.3.5
|
||||
|
||||
acorn-jsx@5.3.2(acorn@8.17.0):
|
||||
@@ -9390,9 +9632,9 @@ snapshots:
|
||||
|
||||
acorn-walk@8.3.5:
|
||||
dependencies:
|
||||
acorn: 8.16.0
|
||||
acorn: 8.15.0
|
||||
|
||||
acorn@8.16.0: {}
|
||||
acorn@8.15.0: {}
|
||||
|
||||
acorn@8.17.0: {}
|
||||
|
||||
@@ -10235,6 +10477,34 @@ snapshots:
|
||||
is-date-object: 1.1.0
|
||||
is-symbol: 1.1.1
|
||||
|
||||
esbuild@0.25.4:
|
||||
optionalDependencies:
|
||||
'@esbuild/aix-ppc64': 0.25.4
|
||||
'@esbuild/android-arm': 0.25.4
|
||||
'@esbuild/android-arm64': 0.25.4
|
||||
'@esbuild/android-x64': 0.25.4
|
||||
'@esbuild/darwin-arm64': 0.25.4
|
||||
'@esbuild/darwin-x64': 0.25.4
|
||||
'@esbuild/freebsd-arm64': 0.25.4
|
||||
'@esbuild/freebsd-x64': 0.25.4
|
||||
'@esbuild/linux-arm': 0.25.4
|
||||
'@esbuild/linux-arm64': 0.25.4
|
||||
'@esbuild/linux-ia32': 0.25.4
|
||||
'@esbuild/linux-loong64': 0.25.4
|
||||
'@esbuild/linux-mips64el': 0.25.4
|
||||
'@esbuild/linux-ppc64': 0.25.4
|
||||
'@esbuild/linux-riscv64': 0.25.4
|
||||
'@esbuild/linux-s390x': 0.25.4
|
||||
'@esbuild/linux-x64': 0.25.4
|
||||
'@esbuild/netbsd-arm64': 0.25.4
|
||||
'@esbuild/netbsd-x64': 0.25.4
|
||||
'@esbuild/openbsd-arm64': 0.25.4
|
||||
'@esbuild/openbsd-x64': 0.25.4
|
||||
'@esbuild/sunos-x64': 0.25.4
|
||||
'@esbuild/win32-arm64': 0.25.4
|
||||
'@esbuild/win32-ia32': 0.25.4
|
||||
'@esbuild/win32-x64': 0.25.4
|
||||
|
||||
esbuild@0.28.1:
|
||||
optionalDependencies:
|
||||
'@esbuild/aix-ppc64': 0.28.1
|
||||
@@ -10688,7 +10958,7 @@ snapshots:
|
||||
expo: 55.0.23(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3)
|
||||
expo-json-utils: 55.0.2
|
||||
|
||||
expo-module-scripts@55.0.2(@babel/core@7.29.7)(@babel/runtime@7.29.2)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(esbuild@0.28.1)(eslint@9.39.4)(expo@55.0.23)(jest@29.7.0(@types/node@25.9.3))(prettier@2.8.8)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react-refresh@0.14.2)(react-test-renderer@19.2.6(react@19.2.6))(react@19.2.6):
|
||||
expo-module-scripts@55.0.2(@babel/core@7.29.7)(@babel/runtime@7.29.2)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(esbuild@0.25.4)(eslint@9.39.4)(expo@55.0.23)(jest@29.7.0(@types/node@25.9.3))(prettier@2.8.8)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react-refresh@0.14.2)(react-test-renderer@19.2.6(react@19.2.6))(react@19.2.6):
|
||||
dependencies:
|
||||
'@babel/cli': 7.28.6(@babel/core@7.29.7)
|
||||
'@babel/plugin-transform-export-namespace-from': 7.27.1(@babel/core@7.29.7)
|
||||
@@ -10708,7 +10978,7 @@ snapshots:
|
||||
jest-snapshot-prettier: prettier@2.8.8
|
||||
jest-watch-typeahead: 2.2.1(jest@29.7.0(@types/node@25.9.3))
|
||||
resolve-workspace-root: 2.0.1
|
||||
ts-jest: 29.0.5(@babel/core@7.29.7)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(esbuild@0.28.1)(jest@29.7.0(@types/node@25.9.3))(typescript@5.9.3)
|
||||
ts-jest: 29.0.5(@babel/core@7.29.7)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(esbuild@0.25.4)(jest@29.7.0(@types/node@25.9.3))(typescript@5.9.3)
|
||||
typescript: 5.9.3
|
||||
transitivePeerDependencies:
|
||||
- '@babel/core'
|
||||
@@ -11832,7 +12102,7 @@ snapshots:
|
||||
jsdom@20.0.3:
|
||||
dependencies:
|
||||
abab: 2.0.6
|
||||
acorn: 8.16.0
|
||||
acorn: 8.15.0
|
||||
acorn-globals: 7.0.1
|
||||
cssom: 0.5.0
|
||||
cssstyle: 2.3.0
|
||||
@@ -13463,7 +13733,7 @@ snapshots:
|
||||
terser@5.46.2:
|
||||
dependencies:
|
||||
'@jridgewell/source-map': 0.3.11
|
||||
acorn: 8.16.0
|
||||
acorn: 8.15.0
|
||||
commander: 2.20.3
|
||||
source-map-support: 0.5.21
|
||||
|
||||
@@ -13527,7 +13797,7 @@ snapshots:
|
||||
picomatch: 4.0.4
|
||||
typescript: 5.9.3
|
||||
|
||||
ts-jest@29.0.5(@babel/core@7.29.7)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(esbuild@0.28.1)(jest@29.7.0(@types/node@25.9.3))(typescript@5.9.3):
|
||||
ts-jest@29.0.5(@babel/core@7.29.7)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(esbuild@0.25.4)(jest@29.7.0(@types/node@25.9.3))(typescript@5.9.3):
|
||||
dependencies:
|
||||
bs-logger: 0.2.6
|
||||
fast-json-stable-stringify: 2.1.0
|
||||
@@ -13543,7 +13813,7 @@ snapshots:
|
||||
'@babel/core': 7.29.7
|
||||
'@jest/types': 29.6.3
|
||||
babel-jest: 29.7.0(@babel/core@7.29.7)
|
||||
esbuild: 0.28.1
|
||||
esbuild: 0.25.4
|
||||
|
||||
tsconfig-paths@3.15.0:
|
||||
dependencies:
|
||||
@@ -13700,7 +13970,7 @@ snapshots:
|
||||
- '@types/react'
|
||||
- '@types/react-dom'
|
||||
|
||||
vite@8.1.0(@types/node@25.9.3)(esbuild@0.28.1)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0):
|
||||
vite@8.1.0(@types/node@25.9.3)(esbuild@0.25.4)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0):
|
||||
dependencies:
|
||||
lightningcss: 1.32.0
|
||||
picomatch: 4.0.4
|
||||
@@ -13709,16 +13979,16 @@ snapshots:
|
||||
tinyglobby: 0.2.17
|
||||
optionalDependencies:
|
||||
'@types/node': 25.9.3
|
||||
esbuild: 0.28.1
|
||||
esbuild: 0.25.4
|
||||
fsevents: 2.3.3
|
||||
terser: 5.48.0
|
||||
tsx: 4.22.4
|
||||
yaml: 2.9.0
|
||||
|
||||
vitest@4.1.9(@types/node@25.9.3)(jsdom@20.0.3)(vite@8.1.0(@types/node@25.9.3)(esbuild@0.28.1)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0)):
|
||||
vitest@4.1.9(@types/node@25.9.3)(jsdom@20.0.3)(vite@8.1.0(@types/node@25.9.3)(esbuild@0.25.4)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0)):
|
||||
dependencies:
|
||||
'@vitest/expect': 4.1.9
|
||||
'@vitest/mocker': 4.1.9(vite@8.1.0(@types/node@25.9.3)(esbuild@0.28.1)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0))
|
||||
'@vitest/mocker': 4.1.9(vite@8.1.0(@types/node@25.9.3)(esbuild@0.25.4)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0))
|
||||
'@vitest/pretty-format': 4.1.9
|
||||
'@vitest/runner': 4.1.9
|
||||
'@vitest/snapshot': 4.1.9
|
||||
@@ -13735,7 +14005,7 @@ snapshots:
|
||||
tinyexec: 1.1.2
|
||||
tinyglobby: 0.2.17
|
||||
tinyrainbow: 3.1.0
|
||||
vite: 8.1.0(@types/node@25.9.3)(esbuild@0.28.1)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0)
|
||||
vite: 8.1.0(@types/node@25.9.3)(esbuild@0.25.4)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0)
|
||||
why-is-node-running: 2.3.0
|
||||
optionalDependencies:
|
||||
'@types/node': 25.9.3
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import { readFile, writeFile } from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import { createRequire } from 'node:module'
|
||||
import * as esbuild from 'esbuild'
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
const scriptDir = import.meta.dirname
|
||||
const mobileRoot = path.resolve(scriptDir, '..')
|
||||
const outputPath = path.join(mobileRoot, 'src', 'terminal', 'terminal-webview-engine.generated.ts')
|
||||
const target = 'chrome74'
|
||||
|
||||
const packages = ['@xterm/xterm', '@xterm/addon-unicode11', '@xterm/addon-webgl']
|
||||
|
||||
async function readPackageVersion(packageName) {
|
||||
// Why: a package.json module specifier must use '/' — path.join emits '\' on
|
||||
// Windows, yielding an unresolvable bare specifier that fails postinstall there.
|
||||
const packageJsonPath = require.resolve(`${packageName}/package.json`)
|
||||
const packageJson = JSON.parse(await readFile(packageJsonPath, 'utf8'))
|
||||
return `${packageName}@${packageJson.version}`
|
||||
}
|
||||
|
||||
function htmlText(value, closingTag) {
|
||||
return value.replace(new RegExp(`</${closingTag}`, 'gi'), `<\\/${closingTag}`)
|
||||
}
|
||||
|
||||
async function buildEngineJs() {
|
||||
const result = await esbuild.build({
|
||||
stdin: {
|
||||
contents: `
|
||||
import { Terminal } from '@xterm/xterm'
|
||||
import { Unicode11Addon } from '@xterm/addon-unicode11'
|
||||
import { WebglAddon } from '@xterm/addon-webgl'
|
||||
|
||||
// Why: xterm reaches for these runtime APIs on the terminal-bringup path,
|
||||
// and esbuild lowers syntax but not runtime APIs. Guarded shims let the
|
||||
// chrome74-syntax bundle actually run on old WebViews (the #7030 goal)
|
||||
// instead of throwing at construction and only surfacing the error overlay.
|
||||
|
||||
// WeakRef (Chrome 84+): lazily constructed in window-tracking paths.
|
||||
// Strong retention is fine for a single-document terminal WebView.
|
||||
if (typeof window.WeakRef === 'undefined') {
|
||||
window.WeakRef = function WeakRefShim(target) { this.__target = target }
|
||||
window.WeakRef.prototype.deref = function () { return this.__target }
|
||||
}
|
||||
|
||||
// structuredClone (Chrome 98+): xterm clones its plain-data DEC-mode default
|
||||
// objects at Terminal construction; a JSON round-trip clones those correctly
|
||||
// (any undefined-valued keys drop, but every reader treats absent == undefined).
|
||||
if (typeof window.structuredClone === 'undefined') {
|
||||
window.structuredClone = function (value) { return JSON.parse(JSON.stringify(value)) }
|
||||
}
|
||||
|
||||
// Element.prototype.replaceChildren (Chrome 86+): used on the row/selection
|
||||
// render path; polyfill via remove-all + append (Chrome 54+, under the floor).
|
||||
if (typeof Element !== 'undefined' && !Element.prototype.replaceChildren) {
|
||||
Element.prototype.replaceChildren = function () {
|
||||
while (this.firstChild) this.removeChild(this.firstChild)
|
||||
this.append.apply(this, arguments)
|
||||
}
|
||||
}
|
||||
|
||||
window.Terminal = Terminal
|
||||
window.Unicode11Addon = { Unicode11Addon }
|
||||
window.WebglAddon = { WebglAddon }
|
||||
`,
|
||||
resolveDir: mobileRoot,
|
||||
sourcefile: 'terminal-webview-engine-entry.js'
|
||||
},
|
||||
bundle: true,
|
||||
format: 'iife',
|
||||
minify: true,
|
||||
platform: 'browser',
|
||||
target,
|
||||
legalComments: 'none',
|
||||
write: false,
|
||||
logLevel: 'silent'
|
||||
})
|
||||
|
||||
return result.outputFiles[0].text
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const [engineJs, rawEngineCss, ...versions] = await Promise.all([
|
||||
buildEngineJs(),
|
||||
readFile(require.resolve('@xterm/xterm/css/xterm.css'), 'utf8'),
|
||||
...packages.map(readPackageVersion)
|
||||
])
|
||||
// Why: the no-external-URL regression gate bans http(s):// anywhere in the
|
||||
// terminal document. These xmlns URIs live inside data: URLs (never fetched);
|
||||
// percent-encoding the scheme colon satisfies the gate and URI-decodes back
|
||||
// before the SVG is parsed.
|
||||
const engineCss = rawEngineCss
|
||||
.replace(/\/\*[\s\S]*?\*\//g, '')
|
||||
.replace(/http:\/\/www\.w3\.org\/2000\/svg/g, 'http%3A//www.w3.org/2000/svg')
|
||||
|
||||
const source = [
|
||||
'// Generated by scripts/build-terminal-webview-engine.mjs.',
|
||||
`// Packages: ${versions.join(', ')}.`,
|
||||
`// Target: ${target}. Do not edit by hand; regenerate via pnpm postinstall.`,
|
||||
`export const XTERM_ENGINE_JS = ${JSON.stringify(htmlText(engineJs, 'script'))}`,
|
||||
`export const XTERM_ENGINE_CSS = ${JSON.stringify(htmlText(engineCss, 'style'))}`,
|
||||
''
|
||||
].join('\n')
|
||||
|
||||
await writeFile(outputPath, source)
|
||||
}
|
||||
|
||||
await main()
|
||||
@@ -22,31 +22,12 @@ function fail(message) {
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
function parsePositiveInteger(value, name) {
|
||||
if (!/^\d+$/.test(value)) {
|
||||
fail(`${name} must be a positive integer`)
|
||||
}
|
||||
|
||||
const parsed = Number(value)
|
||||
if (!Number.isSafeInteger(parsed) || parsed <= 0) {
|
||||
fail(`${name} must be a positive integer`)
|
||||
}
|
||||
|
||||
return parsed
|
||||
}
|
||||
|
||||
function validateSemver(version, name) {
|
||||
if (!semverPattern.test(version)) {
|
||||
fail(`${name} must use x.y.z format`)
|
||||
}
|
||||
}
|
||||
|
||||
function bumpPatchVersion(version) {
|
||||
validateSemver(version, 'Current mobile version')
|
||||
const [major, minor, patch] = version.split('.')
|
||||
return `${major}.${minor}.${Number(patch) + 1}`
|
||||
}
|
||||
|
||||
function writeOutput(name, value) {
|
||||
const outputPath = process.env.GITHUB_OUTPUT
|
||||
if (!outputPath) {
|
||||
@@ -74,10 +55,6 @@ const tagVersion = githubRef.startsWith(androidTagRefPrefix)
|
||||
const requestedVersion = input('MOBILE_ANDROID_RELEASE_VERSION')
|
||||
const bumpPatch = truthy(input('MOBILE_ANDROID_BUMP_PATCH_VERSION'))
|
||||
|
||||
if (requestedVersion && bumpPatch) {
|
||||
fail('Use either MOBILE_ANDROID_RELEASE_VERSION or MOBILE_ANDROID_BUMP_PATCH_VERSION, not both')
|
||||
}
|
||||
|
||||
if (tagVersion) {
|
||||
validateSemver(tagVersion, 'Android release tag version')
|
||||
}
|
||||
@@ -86,43 +63,36 @@ if (requestedVersion) {
|
||||
validateSemver(requestedVersion, 'MOBILE_ANDROID_RELEASE_VERSION')
|
||||
}
|
||||
|
||||
if (tagVersion && requestedVersion && requestedVersion !== tagVersion) {
|
||||
fail('MOBILE_ANDROID_RELEASE_VERSION must match the mobile-android-v tag')
|
||||
if (bumpPatch) {
|
||||
fail('MOBILE_ANDROID_BUMP_PATCH_VERSION is no longer supported; commit mobile/app.json first')
|
||||
}
|
||||
|
||||
if (tagVersion && bumpPatch) {
|
||||
fail('MOBILE_ANDROID_BUMP_PATCH_VERSION is only supported for manual branch runs')
|
||||
if (tagVersion && tagVersion !== currentVersion) {
|
||||
fail('Android release tag version must match the committed mobile app version')
|
||||
}
|
||||
|
||||
const version =
|
||||
requestedVersion || tagVersion || (bumpPatch ? bumpPatchVersion(currentVersion) : currentVersion)
|
||||
if (requestedVersion && requestedVersion !== currentVersion) {
|
||||
fail('MOBILE_ANDROID_RELEASE_VERSION must match the committed mobile app version')
|
||||
}
|
||||
|
||||
const requestedVersionCode = input('MOBILE_ANDROID_VERSION_CODE')
|
||||
const bumpVersionCode = truthy(input('MOBILE_ANDROID_BUMP_VERSION_CODE'))
|
||||
|
||||
if (requestedVersionCode && bumpVersionCode) {
|
||||
fail('Use either MOBILE_ANDROID_VERSION_CODE or MOBILE_ANDROID_BUMP_VERSION_CODE, not both')
|
||||
if (requestedVersionCode || bumpVersionCode) {
|
||||
// Why: Android rejects lower versionCode installs; release-only bumps leave
|
||||
// committed dev builds behind shipped APKs and break local testing.
|
||||
fail('Android versionCode changes must be committed in mobile/app.json before release')
|
||||
}
|
||||
|
||||
const versionCode = requestedVersionCode
|
||||
? parsePositiveInteger(requestedVersionCode, 'MOBILE_ANDROID_VERSION_CODE')
|
||||
: bumpVersionCode || version !== currentVersion
|
||||
? currentVersionCode + 1
|
||||
: currentVersionCode
|
||||
|
||||
expo.version = version
|
||||
android.versionCode = versionCode
|
||||
fs.writeFileSync(appConfigPath, `${JSON.stringify(config, null, 2)}\n`)
|
||||
|
||||
const tag = `mobile-android-v${version}`
|
||||
const tag = `mobile-android-v${currentVersion}`
|
||||
const publishRelease =
|
||||
githubRef.startsWith(androidTagRefPrefix) || truthy(input('MOBILE_ANDROID_PUBLISH_RELEASE'))
|
||||
|
||||
writeOutput('version', version)
|
||||
writeOutput('android_version_code', String(versionCode))
|
||||
writeOutput('version', currentVersion)
|
||||
writeOutput('android_version_code', String(currentVersionCode))
|
||||
writeOutput('tag', tag)
|
||||
writeOutput('publish_release', publishRelease ? 'true' : 'false')
|
||||
|
||||
console.log(`Prepared Orca Mobile Android ${version} (${versionCode})`)
|
||||
console.log(`Prepared Orca Mobile Android ${currentVersion} (${currentVersionCode})`)
|
||||
console.log(`Release tag: ${tag}`)
|
||||
console.log(`Publish GitHub Release: ${publishRelease ? 'yes' : 'no'}`)
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { execFileSync, spawnSync } from 'node:child_process'
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
|
||||
const scriptPath = fileURLToPath(
|
||||
new URL('../../scripts/prepare-android-release.mjs', import.meta.url)
|
||||
)
|
||||
|
||||
const appConfig = {
|
||||
expo: {
|
||||
version: '0.0.22',
|
||||
android: {
|
||||
versionCode: 4
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let tempDirs: string[] = []
|
||||
|
||||
function createAppConfig() {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'orca-android-release-'))
|
||||
tempDirs.push(dir)
|
||||
const configPath = join(dir, 'app.json')
|
||||
const contents = `${JSON.stringify(appConfig, null, 2)}\n`
|
||||
writeFileSync(configPath, contents)
|
||||
return { configPath, contents }
|
||||
}
|
||||
|
||||
describe('prepare Android release script', () => {
|
||||
afterEach(() => {
|
||||
for (const dir of tempDirs) {
|
||||
rmSync(dir, { force: true, recursive: true })
|
||||
}
|
||||
tempDirs = []
|
||||
})
|
||||
|
||||
it('uses committed Android release identity without mutating app config', () => {
|
||||
const { configPath, contents } = createAppConfig()
|
||||
|
||||
const output = execFileSync(process.execPath, [scriptPath], {
|
||||
encoding: 'utf8',
|
||||
env: {
|
||||
...process.env,
|
||||
MOBILE_APP_CONFIG_PATH: configPath,
|
||||
MOBILE_ANDROID_PUBLISH_RELEASE: 'true'
|
||||
}
|
||||
})
|
||||
|
||||
expect(output).toContain('Prepared Orca Mobile Android 0.0.22 (4)')
|
||||
expect(output).toContain('Release tag: mobile-android-v0.0.22')
|
||||
expect(readFileSync(configPath, 'utf8')).toBe(contents)
|
||||
})
|
||||
|
||||
it('rejects release-only Android versionCode bumps', () => {
|
||||
const { configPath } = createAppConfig()
|
||||
|
||||
const result = spawnSync(process.execPath, [scriptPath], {
|
||||
encoding: 'utf8',
|
||||
env: {
|
||||
...process.env,
|
||||
MOBILE_APP_CONFIG_PATH: configPath,
|
||||
MOBILE_ANDROID_BUMP_VERSION_CODE: 'true'
|
||||
}
|
||||
})
|
||||
|
||||
expect(result.status).toBe(1)
|
||||
expect(result.stderr).toContain(
|
||||
'Android versionCode changes must be committed in mobile/app.json before release'
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects release versions that do not match committed app config', () => {
|
||||
const { configPath } = createAppConfig()
|
||||
|
||||
const result = spawnSync(process.execPath, [scriptPath], {
|
||||
encoding: 'utf8',
|
||||
env: {
|
||||
...process.env,
|
||||
MOBILE_APP_CONFIG_PATH: configPath,
|
||||
MOBILE_ANDROID_RELEASE_VERSION: '0.0.23'
|
||||
}
|
||||
})
|
||||
|
||||
expect(result.status).toBe(1)
|
||||
expect(result.stderr).toContain(
|
||||
'MOBILE_ANDROID_RELEASE_VERSION must match the committed mobile app version'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -27,9 +27,11 @@ export function MobileTerminalLiveInputStatus({
|
||||
? 'Tap mic to stop'
|
||||
: dictation.isProcessing
|
||||
? 'Transcribing on desktop'
|
||||
: isAttaching
|
||||
? 'Uploading image to host'
|
||||
: 'Keyboard goes directly to terminal'
|
||||
: dictation.isStarting
|
||||
? 'Preparing microphone'
|
||||
: isAttaching
|
||||
? 'Uploading image to host'
|
||||
: 'Tap to show keyboard'
|
||||
|
||||
return (
|
||||
<View style={styles.status}>
|
||||
|
||||
@@ -1,76 +1,30 @@
|
||||
import { useRef, useCallback, forwardRef, useImperativeHandle, useEffect, useMemo } from 'react'
|
||||
import { StyleSheet, type StyleProp, type ViewStyle } from 'react-native'
|
||||
import { View, type StyleProp, type ViewStyle } from 'react-native'
|
||||
import { WebView } from 'react-native-webview'
|
||||
import type { WebViewMessageEvent } from 'react-native-webview'
|
||||
import type { RuntimeMobileTerminalTheme } from '../../../src/shared/runtime-types'
|
||||
import { colors } from '../theme/mobile-theme'
|
||||
import type { TerminalOscLinkRange } from './terminal-osc-link-ranges'
|
||||
import { XTERM_HTML } from './terminal-webview-html'
|
||||
import type {
|
||||
MobileTerminalTheme,
|
||||
TerminalSelectionEvents,
|
||||
TerminalWebViewHandle
|
||||
} from './terminal-webview-contract'
|
||||
import {
|
||||
TerminalWebViewEngineErrorOverlay,
|
||||
useTerminalWebViewEngineErrorState
|
||||
} from './terminal-webview-engine-error-state'
|
||||
import { TERMINAL_WEBVIEW_FRAME_STYLES } from './terminal-webview-frame-styles'
|
||||
import { useTerminalWebReadyWatchdog } from './terminal-webview-ready-watchdog'
|
||||
import { XTERM_WEBVIEW_SOURCE } from './terminal-webview-html'
|
||||
import type { TerminalWebViewCommand } from './terminal-webview-messages'
|
||||
import { createTerminalWebViewPendingMessages } from './terminal-webview-pending-messages'
|
||||
|
||||
type TerminalMouseTrackingMode = 'none' | 'x10' | 'vt200' | 'drag' | 'any'
|
||||
type TerminalOscLinks = TerminalOscLinkRange[]
|
||||
|
||||
export type TerminalModes = {
|
||||
bracketedPasteMode: boolean
|
||||
altScreen: boolean
|
||||
mouseTrackingMode: TerminalMouseTrackingMode
|
||||
sgrMouseMode: boolean
|
||||
sgrMousePixelsMode: boolean
|
||||
}
|
||||
|
||||
export type TerminalKeyboardAvoidanceMetrics = {
|
||||
cursorY: number
|
||||
rows: number
|
||||
altScreen: boolean
|
||||
}
|
||||
|
||||
export type MobileTerminalTheme = RuntimeMobileTerminalTheme
|
||||
|
||||
export type TerminalSelectionEvents = {
|
||||
onSelectionMode?: (active: boolean) => void
|
||||
onSelectionCopy?: (text: string) => void
|
||||
onSelectionEvicted?: () => void
|
||||
onModesChanged?: (modes: TerminalModes) => void
|
||||
onKeyboardAvoidanceMetrics?: (metrics: TerminalKeyboardAvoidanceMetrics) => void
|
||||
onHaptic?: (kind: 'selection' | 'success' | 'error' | 'edge-bump') => void
|
||||
onTerminalInput?: (bytes: string) => void
|
||||
onTerminalTap?: () => void
|
||||
// Tap landed on a detected file path; RN resolves + opens it.
|
||||
onFileTap?: (pathText: string, line: number | null, column: number | null) => void
|
||||
// WebView-detected URL tap; RN chooses the mobile routing destination.
|
||||
onOpenUrl?: (url: string) => void
|
||||
// Why: pinch-to-zoom in the terminal snaps to a text-size preset and reports it
|
||||
// here so the app persists it and keeps Settings + other panes in sync.
|
||||
onTextScaleChange?: (scale: number) => void
|
||||
}
|
||||
|
||||
export type TerminalWebViewHandle = {
|
||||
write: (data: string) => void
|
||||
init: (
|
||||
cols: number,
|
||||
rows: number,
|
||||
initialData?: string,
|
||||
preserveScroll?: boolean,
|
||||
oscLinks?: TerminalOscLinks
|
||||
) => void
|
||||
resize: (cols: number, rows: number) => void
|
||||
// Why: reflow the local xterm buffer (scrollback included) to a new width
|
||||
// after a server-side PTY reflow, so older wrapped lines rewrap to match the
|
||||
// latest output. No-op on the alternate screen.
|
||||
reflow: (cols: number, rows: number) => void
|
||||
clear: () => void
|
||||
measureFitDimensions: (containerHeight?: number) => Promise<{ cols: number; rows: number } | null>
|
||||
resetZoom: () => void
|
||||
cancelSelect: () => void
|
||||
doSelectAll: () => void
|
||||
// Why: lets callers await the WebView-side `init` rAF chain (term.open
|
||||
// → renderService population → first paint) so a follow-up measure
|
||||
// doesn't race ahead and find term=null or cellWidth=0. Resolves on
|
||||
// the next 'ready' notify after the most recent init.
|
||||
awaitReady: () => Promise<void>
|
||||
}
|
||||
export type {
|
||||
MobileTerminalTheme,
|
||||
TerminalKeyboardAvoidanceMetrics,
|
||||
TerminalModes,
|
||||
TerminalSelectionEvents,
|
||||
TerminalWebViewHandle
|
||||
} from './terminal-webview-contract'
|
||||
|
||||
type Props = {
|
||||
style?: StyleProp<ViewStyle>
|
||||
@@ -79,18 +33,16 @@ type Props = {
|
||||
// scale; raw xterm fontSize can't drive apparent size because the fit cancels it.
|
||||
textScale?: number
|
||||
onWebReady?: () => void
|
||||
onEngineError?: (message: string) => void
|
||||
} & TerminalSelectionEvents
|
||||
|
||||
// Why: WebView treats source identity as page identity on some platforms; keep
|
||||
// parent/session re-renders from reloading xterm and forcing fresh snapshots.
|
||||
const XTERM_WEBVIEW_SOURCE = { html: XTERM_HTML }
|
||||
|
||||
export const TerminalWebView = forwardRef<TerminalWebViewHandle, Props>(function TerminalWebView(
|
||||
{
|
||||
style,
|
||||
terminalTheme,
|
||||
textScale = 1,
|
||||
onWebReady,
|
||||
onEngineError,
|
||||
onSelectionMode,
|
||||
onSelectionCopy,
|
||||
onSelectionEvicted,
|
||||
@@ -119,6 +71,12 @@ export const TerminalWebView = forwardRef<TerminalWebViewHandle, Props>(function
|
||||
// race ahead of term.open() / renderService population.
|
||||
const readyPromiseRef = useRef<Promise<void> | null>(null)
|
||||
const readyResolveRef = useRef<(() => void) | null>(null)
|
||||
const { clearEngineError, engineError, reportEngineError, reportNativeEngineError } =
|
||||
useTerminalWebViewEngineErrorState(onEngineError)
|
||||
const { armWebReadyWatchdog, clearWebReadyWatchdog } = useTerminalWebReadyWatchdog(
|
||||
isWebReadyRef,
|
||||
reportEngineError
|
||||
)
|
||||
|
||||
const sendToWebView = useCallback((msg: TerminalWebViewCommand) => {
|
||||
messageIdRef.current += 1
|
||||
@@ -151,6 +109,8 @@ export const TerminalWebView = forwardRef<TerminalWebViewHandle, Props>(function
|
||||
|
||||
if (msg.type === 'web-ready') {
|
||||
isWebReadyRef.current = true
|
||||
clearWebReadyWatchdog()
|
||||
clearEngineError()
|
||||
onWebReady?.()
|
||||
flushPendingMessages()
|
||||
} else if (msg.type === 'ready') {
|
||||
@@ -175,6 +135,9 @@ export const TerminalWebView = forwardRef<TerminalWebViewHandle, Props>(function
|
||||
const tag = typeof msg.tag === 'string' ? msg.tag : '[fit]'
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(tag, msg.payload)
|
||||
} else if (msg.type === 'error') {
|
||||
const message = typeof msg.message === 'string' ? msg.message : 'Unknown terminal error'
|
||||
reportEngineError(message, msg.fatal !== false)
|
||||
} else if (msg.type === 'set-select-mode') {
|
||||
onSelectionMode?.(!!msg.enabled)
|
||||
} else if (msg.type === 'selection') {
|
||||
@@ -246,6 +209,9 @@ export const TerminalWebView = forwardRef<TerminalWebViewHandle, Props>(function
|
||||
},
|
||||
[
|
||||
flushPendingMessages,
|
||||
clearEngineError,
|
||||
clearWebReadyWatchdog,
|
||||
reportEngineError,
|
||||
onWebReady,
|
||||
onSelectionMode,
|
||||
onSelectionCopy,
|
||||
@@ -263,10 +229,16 @@ export const TerminalWebView = forwardRef<TerminalWebViewHandle, Props>(function
|
||||
|
||||
const handleLoadStart = useCallback(() => {
|
||||
isWebReadyRef.current = false
|
||||
armWebReadyWatchdog()
|
||||
// Why: messages queued for a previous WebView generation are stale after a reload;
|
||||
// dropping them avoids replaying terminal chunks before the next init snapshot.
|
||||
pendingMessages.clear()
|
||||
}, [pendingMessages])
|
||||
}, [armWebReadyWatchdog, pendingMessages])
|
||||
|
||||
const handleReload = useCallback(() => {
|
||||
clearEngineError()
|
||||
webViewRef.current?.reload()
|
||||
}, [clearEngineError])
|
||||
|
||||
useEffect(() => {
|
||||
postMessage({ type: 'set-theme', terminalTheme })
|
||||
@@ -289,7 +261,7 @@ export const TerminalWebView = forwardRef<TerminalWebViewHandle, Props>(function
|
||||
rows: number,
|
||||
initialData?: string,
|
||||
preserveScroll?: boolean,
|
||||
oscLinks?: TerminalOscLinks
|
||||
oscLinks?: TerminalOscLinkRange[]
|
||||
) {
|
||||
// Why: arm a fresh ready promise BEFORE posting init. The WebView
|
||||
// resolves it via the 'ready' notify at the end of its rAF chain.
|
||||
@@ -395,29 +367,35 @@ export const TerminalWebView = forwardRef<TerminalWebViewHandle, Props>(function
|
||||
)
|
||||
|
||||
return (
|
||||
<WebView
|
||||
ref={webViewRef}
|
||||
source={XTERM_WEBVIEW_SOURCE}
|
||||
style={[styles.webview, style]}
|
||||
originWhitelist={['*']}
|
||||
javaScriptEnabled
|
||||
scrollEnabled={false}
|
||||
// Why: Android parent gesture containers can intercept vertical drags
|
||||
// before the injected xterm scroll router sees them.
|
||||
nestedScrollEnabled
|
||||
scalesPageToFit={false}
|
||||
// Why: Android WebView defaults textZoom to the system font scale, inflating
|
||||
// xterm's DOM glyphs past its canvas-measured cell grid (#4579). iOS ignores it.
|
||||
textZoom={100}
|
||||
onLoadStart={handleLoadStart}
|
||||
onMessage={handleMessage}
|
||||
/>
|
||||
<View style={[TERMINAL_WEBVIEW_FRAME_STYLES.container, style]}>
|
||||
<WebView
|
||||
ref={webViewRef}
|
||||
source={XTERM_WEBVIEW_SOURCE}
|
||||
style={TERMINAL_WEBVIEW_FRAME_STYLES.webview}
|
||||
originWhitelist={['*']}
|
||||
javaScriptEnabled
|
||||
scrollEnabled={false}
|
||||
// Why: Android parent gesture containers can intercept vertical drags
|
||||
// before the injected xterm scroll router sees them.
|
||||
nestedScrollEnabled
|
||||
scalesPageToFit={false}
|
||||
// Why: Android WebView defaults textZoom to the system font scale, inflating
|
||||
// xterm's DOM glyphs past its canvas-measured cell grid (#4579). iOS ignores it.
|
||||
textZoom={100}
|
||||
onLoadStart={handleLoadStart}
|
||||
onMessage={handleMessage}
|
||||
onError={(event) => reportNativeEngineError('Terminal WebView load failed', event)}
|
||||
onHttpError={(event) => reportNativeEngineError('Terminal WebView HTTP error', event)}
|
||||
onRenderProcessGone={(event) =>
|
||||
reportNativeEngineError('Terminal WebView render process ended', event)
|
||||
}
|
||||
onContentProcessDidTerminate={(event) =>
|
||||
reportNativeEngineError('Terminal WebView content process ended', event)
|
||||
}
|
||||
/>
|
||||
{engineError ? (
|
||||
<TerminalWebViewEngineErrorOverlay message={engineError} onReload={handleReload} />
|
||||
) : null}
|
||||
</View>
|
||||
)
|
||||
})
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
webview: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.terminalBg
|
||||
}
|
||||
})
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
buildTerminalLiveMirrorPayload,
|
||||
computeTerminalLiveMirrorStep,
|
||||
isTerminalLiveHangulCodePoint,
|
||||
type TerminalLiveMirrorStep
|
||||
} from './terminal-live-hangul-mirror'
|
||||
|
||||
type MirrorRun = {
|
||||
readonly payloads: readonly string[]
|
||||
readonly sentText: string
|
||||
readonly heldText: string
|
||||
}
|
||||
|
||||
function runMirrorSequence(
|
||||
fieldStates: readonly string[],
|
||||
options: { readonly commitAtEnd: boolean } = { commitAtEnd: false }
|
||||
): MirrorRun {
|
||||
const payloads: string[] = []
|
||||
let sentText = ''
|
||||
let heldText = ''
|
||||
for (const fieldText of fieldStates) {
|
||||
const step = computeTerminalLiveMirrorStep(sentText, fieldText, { commitHeld: false })
|
||||
const payload = buildTerminalLiveMirrorPayload(step)
|
||||
if (payload.length > 0) {
|
||||
payloads.push(payload)
|
||||
}
|
||||
sentText = step.nextSentText
|
||||
heldText = step.heldText
|
||||
}
|
||||
if (options.commitAtEnd) {
|
||||
const lastField = sentText + heldText
|
||||
const step = computeTerminalLiveMirrorStep(sentText, lastField, { commitHeld: true })
|
||||
const payload = buildTerminalLiveMirrorPayload(step)
|
||||
if (payload.length > 0) {
|
||||
payloads.push(payload)
|
||||
}
|
||||
sentText = step.nextSentText
|
||||
heldText = step.heldText
|
||||
}
|
||||
return { payloads, sentText, heldText }
|
||||
}
|
||||
|
||||
describe('terminal live hangul mirror', () => {
|
||||
it('Given single-syllable composition When steps run Then leaks no jamo and commits only the final syllable', () => {
|
||||
// Given / When
|
||||
const run = runMirrorSequence(['ㅎ', '하', '한'], { commitAtEnd: true })
|
||||
|
||||
// Then
|
||||
expect(run.payloads).toEqual(['한'])
|
||||
expect(run.sentText).toBe('한')
|
||||
expect(run.heldText).toBe('')
|
||||
})
|
||||
|
||||
it('Given multi-syllable composition When a new syllable starts Then streams the stable prefix without erases', () => {
|
||||
// Given / When
|
||||
const run = runMirrorSequence(['ㅎ', '하', '한', '한ㄱ', '한그', '한글'], { commitAtEnd: true })
|
||||
|
||||
// Then
|
||||
expect(run.payloads).toEqual(['한', '글'])
|
||||
expect(run.sentText).toBe('한글')
|
||||
})
|
||||
|
||||
it('Given dubeolsik resplit 간→가나 When steps run Then never sends the intermediate syllable', () => {
|
||||
// Given / When
|
||||
const run = runMirrorSequence(['ㄱ', '가', '간', '가나'], { commitAtEnd: true })
|
||||
|
||||
// Then
|
||||
expect(run.payloads).toEqual(['가', '나'])
|
||||
expect(run.sentText).toBe('가나')
|
||||
})
|
||||
|
||||
it('Given a timer-committed syllable When composition continues Then erases and recommits via DEL correction', () => {
|
||||
// Given: '하' was committed by the settle timer
|
||||
const commit = computeTerminalLiveMirrorStep('', '하', { commitHeld: true })
|
||||
expect(buildTerminalLiveMirrorPayload(commit)).toBe('하')
|
||||
expect(commit.nextSentText).toBe('하')
|
||||
|
||||
// When: user keeps composing '하' → '한'
|
||||
const correction = computeTerminalLiveMirrorStep(commit.nextSentText, '한', {
|
||||
commitHeld: false
|
||||
})
|
||||
|
||||
// Then: one DEL erases the stale syllable; the new one is held again
|
||||
expect(buildTerminalLiveMirrorPayload(correction)).toBe('\x7f')
|
||||
expect(correction.nextSentText).toBe('')
|
||||
expect(correction.heldText).toBe('한')
|
||||
|
||||
const recommit = computeTerminalLiveMirrorStep('', '한', { commitHeld: true })
|
||||
expect(buildTerminalLiveMirrorPayload(recommit)).toBe('한')
|
||||
})
|
||||
|
||||
it('Given pure ASCII typing When steps run Then mirrors immediately with no held text', () => {
|
||||
// Given / When
|
||||
const run = runMirrorSequence(['a', 'ab', 'abc'])
|
||||
|
||||
// Then
|
||||
expect(run.payloads).toEqual(['a', 'b', 'c'])
|
||||
expect(run.heldText).toBe('')
|
||||
})
|
||||
|
||||
it('Given a trailing space after Hangul When the step runs Then the space commits the held syllable', () => {
|
||||
// Given: '한글' typed, '한' streamed, '글' held
|
||||
const beforeSpace = runMirrorSequence(['ㅎ', '하', '한', '한ㄱ', '한그', '한글'])
|
||||
expect(beforeSpace.sentText).toBe('한')
|
||||
expect(beforeSpace.heldText).toBe('글')
|
||||
|
||||
// When
|
||||
const step = computeTerminalLiveMirrorStep(beforeSpace.sentText, '한글 ', {
|
||||
commitHeld: false
|
||||
})
|
||||
|
||||
// Then
|
||||
expect(buildTerminalLiveMirrorPayload(step)).toBe('글 ')
|
||||
expect(step.heldText).toBe('')
|
||||
expect(step.nextSentText).toBe('한글 ')
|
||||
})
|
||||
|
||||
it('Given a trailing ASCII letter after Hangul When the step runs Then Hangul is committed with the letter', () => {
|
||||
// Given
|
||||
const held = computeTerminalLiveMirrorStep('', '한', { commitHeld: false })
|
||||
expect(held.heldText).toBe('한')
|
||||
|
||||
// When
|
||||
const step = computeTerminalLiveMirrorStep(held.nextSentText, '한a', { commitHeld: false })
|
||||
|
||||
// Then
|
||||
expect(buildTerminalLiveMirrorPayload(step)).toBe('한a')
|
||||
expect(step.heldText).toBe('')
|
||||
})
|
||||
|
||||
it('Given sent text When the user deletes everything Then erases with one DEL per code point', () => {
|
||||
// Given / When
|
||||
const step = computeTerminalLiveMirrorStep('한글a', '', { commitHeld: false })
|
||||
|
||||
// Then
|
||||
expect(step).toEqual<TerminalLiveMirrorStep>({
|
||||
eraseCount: 3,
|
||||
appendText: '',
|
||||
nextSentText: '',
|
||||
heldText: ''
|
||||
})
|
||||
expect(buildTerminalLiveMirrorPayload(step)).toBe('\x7f\x7f\x7f')
|
||||
})
|
||||
|
||||
it('Given empty field and empty sent text When committing Then produces a zero step', () => {
|
||||
// Given / When
|
||||
const step = computeTerminalLiveMirrorStep('', '', { commitHeld: true })
|
||||
|
||||
// Then
|
||||
expect(buildTerminalLiveMirrorPayload(step)).toBe('')
|
||||
expect(step).toEqual<TerminalLiveMirrorStep>({
|
||||
eraseCount: 0,
|
||||
appendText: '',
|
||||
nextSentText: '',
|
||||
heldText: ''
|
||||
})
|
||||
})
|
||||
|
||||
it('Given non-Hangul IME text When the step runs Then it mirrors immediately without holding', () => {
|
||||
// Given / When
|
||||
const chinese = computeTerminalLiveMirrorStep('', '你好', { commitHeld: false })
|
||||
const vietnamese = computeTerminalLiveMirrorStep('', 'tiếng', { commitHeld: false })
|
||||
|
||||
// Then
|
||||
expect(buildTerminalLiveMirrorPayload(chinese)).toBe('你好')
|
||||
expect(chinese.heldText).toBe('')
|
||||
expect(buildTerminalLiveMirrorPayload(vietnamese)).toBe('tiếng')
|
||||
expect(vietnamese.heldText).toBe('')
|
||||
})
|
||||
|
||||
it('Given Hangul code point ranges When checked Then jamo and syllables match and ASCII does not', () => {
|
||||
expect(isTerminalLiveHangulCodePoint('ㅎ'.codePointAt(0) ?? 0)).toBe(true)
|
||||
expect(isTerminalLiveHangulCodePoint('한'.codePointAt(0) ?? 0)).toBe(true)
|
||||
expect(isTerminalLiveHangulCodePoint('a'.codePointAt(0) ?? 0)).toBe(false)
|
||||
expect(isTerminalLiveHangulCodePoint('あ'.codePointAt(0) ?? 0)).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,61 @@
|
||||
// Why: paused composition should still reach the PTY quickly; corrections make
|
||||
// a premature commit safe, so this can be short without leaking jamo forever.
|
||||
export const TERMINAL_LIVE_HELD_SYLLABLE_COMMIT_DELAY_MS = 300
|
||||
|
||||
const TERMINAL_DEL_BYTE = '\x7f'
|
||||
|
||||
export function isTerminalLiveHangulCodePoint(codePoint: number): boolean {
|
||||
return (
|
||||
(codePoint >= 0x1100 && codePoint <= 0x11ff) ||
|
||||
(codePoint >= 0x3130 && codePoint <= 0x318f) ||
|
||||
(codePoint >= 0xa960 && codePoint <= 0xa97f) ||
|
||||
(codePoint >= 0xac00 && codePoint <= 0xd7af)
|
||||
)
|
||||
}
|
||||
|
||||
export type TerminalLiveMirrorStep = {
|
||||
readonly eraseCount: number
|
||||
readonly appendText: string
|
||||
readonly nextSentText: string
|
||||
readonly heldText: string
|
||||
}
|
||||
|
||||
// Why: React Native exposes no composition events, but Hangul composition only
|
||||
// mutates the trailing syllable. Holding just that code point keeps the PTY
|
||||
// echo live while preedit jamo never leak; DEL corrections repair any commit
|
||||
// that later turns out to be premature.
|
||||
export function computeTerminalLiveMirrorStep(
|
||||
sentText: string,
|
||||
fieldText: string,
|
||||
options: { readonly commitHeld: boolean }
|
||||
): TerminalLiveMirrorStep {
|
||||
const fieldCodePoints = Array.from(fieldText)
|
||||
const lastCodePoint = fieldCodePoints.at(-1)
|
||||
const holdLast =
|
||||
!options.commitHeld &&
|
||||
lastCodePoint !== undefined &&
|
||||
isTerminalLiveHangulCodePoint(lastCodePoint.codePointAt(0) ?? 0)
|
||||
const heldText = holdLast && lastCodePoint !== undefined ? lastCodePoint : ''
|
||||
const targetCodePoints = holdLast ? fieldCodePoints.slice(0, -1) : fieldCodePoints
|
||||
const sentCodePoints = Array.from(sentText)
|
||||
|
||||
let commonPrefixLength = 0
|
||||
while (
|
||||
commonPrefixLength < sentCodePoints.length &&
|
||||
commonPrefixLength < targetCodePoints.length &&
|
||||
sentCodePoints[commonPrefixLength] === targetCodePoints[commonPrefixLength]
|
||||
) {
|
||||
commonPrefixLength += 1
|
||||
}
|
||||
|
||||
return {
|
||||
eraseCount: sentCodePoints.length - commonPrefixLength,
|
||||
appendText: targetCodePoints.slice(commonPrefixLength).join(''),
|
||||
nextSentText: targetCodePoints.join(''),
|
||||
heldText
|
||||
}
|
||||
}
|
||||
|
||||
export function buildTerminalLiveMirrorPayload(step: TerminalLiveMirrorStep): string {
|
||||
return TERMINAL_DEL_BYTE.repeat(step.eraseCount) + step.appendText
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const sessionRouteSource = readFileSync(
|
||||
new URL('../../app/h/[hostId]/session/[worktreeId].tsx', import.meta.url),
|
||||
'utf8'
|
||||
)
|
||||
const liveInputStatusSource = readFileSync(
|
||||
new URL('../session/MobileTerminalLiveInputStatus.tsx', import.meta.url),
|
||||
'utf8'
|
||||
)
|
||||
const commandInputStylesSource = readFileSync(
|
||||
new URL('../../app/h/[hostId]/session/mobile-session-command-input-styles.ts', import.meta.url),
|
||||
'utf8'
|
||||
)
|
||||
|
||||
function liveInputBarBlock(): string {
|
||||
const start = sessionRouteSource.indexOf('{liveInputEnabled ? (')
|
||||
expect(start).toBeGreaterThanOrEqual(0)
|
||||
const end = sessionRouteSource.indexOf(') : (', start)
|
||||
expect(end).toBeGreaterThan(start)
|
||||
return sessionRouteSource.slice(start, end)
|
||||
}
|
||||
|
||||
describe('terminal live input affordance', () => {
|
||||
it('keeps the live status row wired as the keyboard focus control', () => {
|
||||
const block = liveInputBarBlock()
|
||||
|
||||
expect(block).toContain('onPress={focusLiveInput}')
|
||||
expect(block).toContain('accessibilityRole="button"')
|
||||
expect(block).toContain('accessibilityLabel="Show keyboard for live terminal input"')
|
||||
expect(block).toContain(
|
||||
'accessibilityHint="Typed text is sent directly to the active terminal"'
|
||||
)
|
||||
expect(block).toContain('pressed && styles.liveInputFocusTargetPressed')
|
||||
expect(block).toContain('!canSend && styles.liveInputFocusTargetDisabled')
|
||||
expect(block).toContain('showSoftInputOnFocus')
|
||||
expect(sessionRouteSource).toContain('focusTerminalLiveInputTarget(liveInputRef.current')
|
||||
expect(sessionRouteSource).toContain('keyboardHeight')
|
||||
expect(sessionRouteSource).toContain('scheduleTerminalLiveInputFocus(liveInputFocusTimerRef')
|
||||
})
|
||||
|
||||
it('makes the live keyboard target visible instead of status-only chrome', () => {
|
||||
expect(liveInputStatusSource).toContain("'Tap to show keyboard'")
|
||||
expect(commandInputStylesSource).toContain('backgroundColor: colors.bgRaised')
|
||||
expect(commandInputStylesSource).toContain('borderWidth: 1')
|
||||
expect(commandInputStylesSource).toContain('liveInputFocusTargetPressed')
|
||||
})
|
||||
})
|
||||
@@ -5,10 +5,12 @@ import {
|
||||
clearTerminalLiveInputFocusTimer,
|
||||
defaultTerminalLiveInputHandles,
|
||||
filterTerminalLiveInputDefaultCandidates,
|
||||
focusTerminalLiveInputTarget,
|
||||
getTerminalLiveSpecialKeyBytes,
|
||||
isTerminalLiveInputWithinByteLimit,
|
||||
pruneTerminalLiveInputHandles,
|
||||
scheduleTerminalLiveInputFocus,
|
||||
type TerminalLiveInputFocusTarget,
|
||||
type TerminalLiveInputFocusTimerRef
|
||||
} from './terminal-live-input'
|
||||
|
||||
@@ -16,6 +18,14 @@ function createTimerRef(): TerminalLiveInputFocusTimerRef {
|
||||
return { current: null }
|
||||
}
|
||||
|
||||
function createFocusTarget(isFocused: () => boolean): TerminalLiveInputFocusTarget {
|
||||
return {
|
||||
blur: vi.fn(),
|
||||
focus: vi.fn(),
|
||||
isFocused
|
||||
}
|
||||
}
|
||||
|
||||
describe('terminal live input', () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
@@ -181,4 +191,37 @@ describe('terminal live input', () => {
|
||||
expect(focus).not.toHaveBeenCalled()
|
||||
expect(timerRef.current).toBeNull()
|
||||
})
|
||||
|
||||
it('refocuses an already-focused capture input when the keyboard is closed', () => {
|
||||
const input = createFocusTarget(() => true)
|
||||
const refocus = vi.fn()
|
||||
|
||||
focusTerminalLiveInputTarget(input, { keyboardHeight: 0, refocus })
|
||||
|
||||
expect(input.blur).toHaveBeenCalledTimes(1)
|
||||
expect(input.focus).not.toHaveBeenCalled()
|
||||
expect(refocus).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('focuses the capture input directly when the keyboard is open', () => {
|
||||
const input = createFocusTarget(() => true)
|
||||
const refocus = vi.fn()
|
||||
|
||||
focusTerminalLiveInputTarget(input, { keyboardHeight: 240, refocus })
|
||||
|
||||
expect(input.blur).not.toHaveBeenCalled()
|
||||
expect(input.focus).toHaveBeenCalledTimes(1)
|
||||
expect(refocus).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('focuses the capture input directly when it is not already focused', () => {
|
||||
const input = createFocusTarget(() => false)
|
||||
const refocus = vi.fn()
|
||||
|
||||
focusTerminalLiveInputTarget(input, { keyboardHeight: 0, refocus })
|
||||
|
||||
expect(input.blur).not.toHaveBeenCalled()
|
||||
expect(input.focus).toHaveBeenCalledTimes(1)
|
||||
expect(refocus).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -66,6 +66,17 @@ export type TerminalLiveInputFocusTimerRef = {
|
||||
current: ReturnType<typeof setTimeout> | null
|
||||
}
|
||||
|
||||
export type TerminalLiveInputFocusTarget = {
|
||||
readonly focus: () => void
|
||||
readonly blur: () => void
|
||||
readonly isFocused?: () => boolean
|
||||
}
|
||||
|
||||
type FocusTerminalLiveInputTargetOptions = {
|
||||
readonly keyboardHeight: number
|
||||
readonly refocus: () => void
|
||||
}
|
||||
|
||||
export type TerminalLiveInputDefaultResult = {
|
||||
enabledHandles: ReadonlySet<string>
|
||||
defaultedHandles: ReadonlySet<string>
|
||||
@@ -217,4 +228,23 @@ export function scheduleTerminalLiveInputFocus(
|
||||
}, delayMs)
|
||||
}
|
||||
|
||||
export function focusTerminalLiveInputTarget(
|
||||
input: TerminalLiveInputFocusTarget | null,
|
||||
{ keyboardHeight, refocus }: FocusTerminalLiveInputTargetOptions
|
||||
): void {
|
||||
if (!input) {
|
||||
return
|
||||
}
|
||||
|
||||
if (keyboardHeight <= 0 && input.isFocused?.()) {
|
||||
// Why: Android can keep a hidden TextInput focused after the IME is dismissed;
|
||||
// focus() is then a no-op, so force a new focus session to reopen the keyboard.
|
||||
input.blur()
|
||||
refocus()
|
||||
return
|
||||
}
|
||||
|
||||
input.focus()
|
||||
}
|
||||
|
||||
export { TERMINAL_LIVE_INPUT_MAX_BYTES }
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { sendTerminalLiveControlAfterPendingFlush } from './terminal-live-control-send-order'
|
||||
import {
|
||||
queueTerminalLivePendingFlush,
|
||||
queueTerminalLiveMirrorSend,
|
||||
waitForTerminalLivePendingFlush,
|
||||
type TerminalLivePendingFlushState
|
||||
} from './terminal-live-pending-flush-state'
|
||||
@@ -64,83 +64,54 @@ describe('terminal live pending flush state', () => {
|
||||
await expect(controlSend).resolves.toBe(false)
|
||||
expect(events).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
it('Given a queued pending flush When it resolves or rejects Then clears the barrier', async () => {
|
||||
describe('terminal live mirror send queue', () => {
|
||||
it('Given a failed previous send When a mirror send queues Then it still runs in order', async () => {
|
||||
// Given
|
||||
const resolvedState: TerminalLivePendingFlushState = { current: null }
|
||||
const rejectedState: TerminalLivePendingFlushState = { current: null }
|
||||
const state: TerminalLivePendingFlushState = { current: null }
|
||||
const order: string[] = []
|
||||
const first = queueTerminalLiveMirrorSend(state, async () => {
|
||||
order.push('first')
|
||||
return false
|
||||
})
|
||||
|
||||
// When
|
||||
await expect(queueTerminalLivePendingFlush(resolvedState, async () => true)).resolves.toBe(true)
|
||||
await expect(
|
||||
queueTerminalLivePendingFlush(rejectedState, async () => {
|
||||
throw new Error('send failed')
|
||||
})
|
||||
).resolves.toBe(false)
|
||||
const second = queueTerminalLiveMirrorSend(state, async () => {
|
||||
order.push('second')
|
||||
return true
|
||||
})
|
||||
|
||||
// Then
|
||||
await expect(first).resolves.toBe(false)
|
||||
await expect(second).resolves.toBe(true)
|
||||
expect(order).toEqual(['first', 'second'])
|
||||
})
|
||||
|
||||
it('Given a throwing send When a mirror send queues Then the promise resolves false and the chain continues', async () => {
|
||||
// Given
|
||||
const state: TerminalLivePendingFlushState = { current: null }
|
||||
const first = queueTerminalLiveMirrorSend(state, async () => {
|
||||
throw new Error('boom')
|
||||
})
|
||||
|
||||
// When
|
||||
const second = queueTerminalLiveMirrorSend(state, async () => true)
|
||||
|
||||
// Then
|
||||
await expect(first).resolves.toBe(false)
|
||||
await expect(second).resolves.toBe(true)
|
||||
})
|
||||
|
||||
it('Given a settled mirror send When it was the newest Then the state resets to null', async () => {
|
||||
// Given
|
||||
const state: TerminalLivePendingFlushState = { current: null }
|
||||
|
||||
// When
|
||||
await queueTerminalLiveMirrorSend(state, async () => true)
|
||||
await Promise.resolve()
|
||||
|
||||
// Then
|
||||
expect(resolvedState.current).toBeNull()
|
||||
expect(rejectedState.current).toBeNull()
|
||||
})
|
||||
|
||||
it('Given a current pending snapshot while another flush is in flight When queued Then sends current text after prior success', async () => {
|
||||
// Given
|
||||
const events: string[] = []
|
||||
let resolveFirstFlush: (value: boolean) => void = () => {}
|
||||
const firstFlush = new Promise<boolean>((resolve) => {
|
||||
resolveFirstFlush = resolve
|
||||
})
|
||||
const state: TerminalLivePendingFlushState = { current: firstFlush }
|
||||
|
||||
// When
|
||||
const secondFlush = queueTerminalLivePendingFlush(state, async () => {
|
||||
events.push('second-flush')
|
||||
return true
|
||||
})
|
||||
const controlSend = sendTerminalLiveControlAfterPendingFlush(
|
||||
() => waitForTerminalLivePendingFlush(state),
|
||||
async () => {
|
||||
events.push('control')
|
||||
return true
|
||||
}
|
||||
)
|
||||
await Promise.resolve()
|
||||
|
||||
// Then
|
||||
expect(events).toEqual([])
|
||||
resolveFirstFlush(true)
|
||||
await expect(secondFlush).resolves.toBe(true)
|
||||
await expect(controlSend).resolves.toBe(true)
|
||||
expect(events).toEqual(['second-flush', 'control'])
|
||||
})
|
||||
|
||||
it('Given a prior pending flush fails When another snapshot is queued Then skips the current text and control', async () => {
|
||||
// Given
|
||||
const events: string[] = []
|
||||
let resolveFirstFlush: (value: boolean) => void = () => {}
|
||||
const firstFlush = new Promise<boolean>((resolve) => {
|
||||
resolveFirstFlush = resolve
|
||||
})
|
||||
const state: TerminalLivePendingFlushState = { current: firstFlush }
|
||||
|
||||
// When
|
||||
const secondFlush = queueTerminalLivePendingFlush(state, async () => {
|
||||
events.push('second-flush')
|
||||
return true
|
||||
})
|
||||
const controlSend = sendTerminalLiveControlAfterPendingFlush(
|
||||
() => waitForTerminalLivePendingFlush(state),
|
||||
async () => {
|
||||
events.push('control')
|
||||
return true
|
||||
}
|
||||
)
|
||||
resolveFirstFlush(false)
|
||||
|
||||
// Then
|
||||
await expect(secondFlush).resolves.toBe(false)
|
||||
await expect(controlSend).resolves.toBe(false)
|
||||
expect(events).toEqual([])
|
||||
expect(state.current).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -8,22 +8,26 @@ export function waitForTerminalLivePendingFlush(
|
||||
return state.current ?? Promise.resolve(true)
|
||||
}
|
||||
|
||||
export function queueTerminalLivePendingFlush(
|
||||
// Why: mirror payloads are erase/append deltas against the PTY echo. A skipped
|
||||
// delta desyncs every later diff, so this chain runs each send even when the
|
||||
// previous one failed. state.current should never reject; the catch keeps a
|
||||
// future raw assignment from skipping a delta.
|
||||
export function queueTerminalLiveMirrorSend(
|
||||
state: TerminalLivePendingFlushState,
|
||||
sendPendingText: () => Promise<boolean>
|
||||
sendMirrorPayload: () => Promise<boolean>
|
||||
): Promise<boolean> {
|
||||
const previousFlush = state.current
|
||||
const flushPromise = (async () => {
|
||||
if (previousFlush && !(await previousFlush)) {
|
||||
return false
|
||||
const previousSend = state.current
|
||||
const sendPromise = (async () => {
|
||||
if (previousSend) {
|
||||
await previousSend.catch(() => false)
|
||||
}
|
||||
return sendPendingText()
|
||||
return sendMirrorPayload()
|
||||
})().catch(() => false)
|
||||
state.current = flushPromise
|
||||
void flushPromise.then(() => {
|
||||
if (state.current === flushPromise) {
|
||||
state.current = sendPromise
|
||||
void sendPromise.then(() => {
|
||||
if (state.current === sendPromise) {
|
||||
state.current = null
|
||||
}
|
||||
})
|
||||
return flushPromise
|
||||
return sendPromise
|
||||
}
|
||||
|
||||
@@ -1,234 +1,118 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
TERMINAL_LIVE_TEXT_COMMIT_DELAY_MS,
|
||||
getTerminalLiveAccessoryBytesDecision,
|
||||
getTerminalLiveAccessoryLocalEditText,
|
||||
getTerminalLiveDeferredTextDelayMs,
|
||||
getTerminalLiveSpecialKeyDecision,
|
||||
getTerminalLiveSubmitSequence,
|
||||
getTerminalLiveTextChangeDecision,
|
||||
isTerminalLiveTextHangulCandidate,
|
||||
isTerminalLiveTextImeCandidate
|
||||
getTerminalLiveSpecialKeyDecision
|
||||
} from './terminal-live-text-commit'
|
||||
|
||||
describe('terminal live text commit', () => {
|
||||
it('Given Korean IME changes When live text changes Then defers candidates and submits only final text before carriage return', () => {
|
||||
// Given
|
||||
const koreanCompositionSteps = ['ㅎ', '하', '한'] as const
|
||||
|
||||
// When
|
||||
const decisions = koreanCompositionSteps.map(getTerminalLiveTextChangeDecision)
|
||||
const sentImmediately = decisions.filter((decision) => decision.kind === 'send-now')
|
||||
const submitSequence = getTerminalLiveSubmitSequence('한')
|
||||
const composedWordDecision = getTerminalLiveTextChangeDecision('한글')
|
||||
const composedWordSubmitSequence = getTerminalLiveSubmitSequence('한글')
|
||||
|
||||
// Then
|
||||
expect(decisions).toEqual([
|
||||
{ kind: 'defer', text: 'ㅎ', delayMs: null },
|
||||
{ kind: 'defer', text: '하', delayMs: null },
|
||||
{ kind: 'defer', text: '한', delayMs: null }
|
||||
])
|
||||
expect(sentImmediately).toEqual([])
|
||||
expect(isTerminalLiveTextHangulCandidate('ㅎ')).toBe(true)
|
||||
expect(getTerminalLiveDeferredTextDelayMs('ㅎ')).toBeNull()
|
||||
expect(submitSequence).toEqual(['한', '\r'])
|
||||
expect(composedWordDecision).toEqual({
|
||||
kind: 'defer',
|
||||
text: '한글',
|
||||
delayMs: null
|
||||
describe('terminal live special key decision', () => {
|
||||
it('Given an unmapped key Then ignores it', () => {
|
||||
expect(getTerminalLiveSpecialKeyDecision({ key: 'ㅎ', heldText: '', sentText: '' })).toEqual({
|
||||
kind: 'ignore'
|
||||
})
|
||||
expect(composedWordSubmitSequence).toEqual(['한글', '\r'])
|
||||
})
|
||||
|
||||
it('Given non-Hangul IME text When live text changes Then keeps the bounded settle timer', () => {
|
||||
// Given
|
||||
const text = 'あ'
|
||||
|
||||
// When
|
||||
const decision = getTerminalLiveTextChangeDecision(text)
|
||||
|
||||
// Then
|
||||
expect(isTerminalLiveTextImeCandidate(text)).toBe(true)
|
||||
expect(isTerminalLiveTextHangulCandidate(text)).toBe(false)
|
||||
expect(getTerminalLiveDeferredTextDelayMs(text)).toBe(TERMINAL_LIVE_TEXT_COMMIT_DELAY_MS)
|
||||
expect(decision).toEqual({ kind: 'defer', text, delayMs: TERMINAL_LIVE_TEXT_COMMIT_DELAY_MS })
|
||||
it('Given Backspace with any field text Then edits locally so the mirror diff handles the PTY erase', () => {
|
||||
// Held syllable present
|
||||
expect(
|
||||
getTerminalLiveSpecialKeyDecision({ key: 'Backspace', heldText: '한', sentText: '' })
|
||||
).toEqual({ kind: 'local-edit' })
|
||||
// Only mirrored text present — native edit fires onChangeText and the diff erases
|
||||
expect(
|
||||
getTerminalLiveSpecialKeyDecision({ key: 'Backspace', heldText: '', sentText: 'abc' })
|
||||
).toEqual({ kind: 'local-edit' })
|
||||
})
|
||||
|
||||
it('Given Chinese and Vietnamese IME text When live text changes Then does not use Hangul-only indefinite deferral', () => {
|
||||
// Given
|
||||
const nonHangulImeTexts = ['你好', 'tiếng Việt'] as const
|
||||
|
||||
for (const text of nonHangulImeTexts) {
|
||||
// When
|
||||
const decision = getTerminalLiveTextChangeDecision(text)
|
||||
|
||||
// Then
|
||||
expect(isTerminalLiveTextImeCandidate(text)).toBe(true)
|
||||
expect(isTerminalLiveTextHangulCandidate(text)).toBe(false)
|
||||
expect(getTerminalLiveDeferredTextDelayMs(text)).toBe(TERMINAL_LIVE_TEXT_COMMIT_DELAY_MS)
|
||||
expect(decision).toEqual({
|
||||
kind: 'defer',
|
||||
text,
|
||||
delayMs: TERMINAL_LIVE_TEXT_COMMIT_DELAY_MS
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
it('Given ASCII text When live text changes Then sends immediately', () => {
|
||||
// Given
|
||||
const text = 'abc123'
|
||||
|
||||
// When
|
||||
const decision = getTerminalLiveTextChangeDecision(text)
|
||||
|
||||
// Then
|
||||
expect(isTerminalLiveTextImeCandidate(text)).toBe(false)
|
||||
expect(decision).toEqual({ kind: 'send-now', text })
|
||||
})
|
||||
|
||||
it('Given empty text When live text changes Then ignores the change', () => {
|
||||
// Given
|
||||
const text = ''
|
||||
|
||||
// When
|
||||
const decision = getTerminalLiveTextChangeDecision(text)
|
||||
|
||||
// Then
|
||||
expect(isTerminalLiveTextImeCandidate(text)).toBe(false)
|
||||
expect(decision).toEqual({ kind: 'ignore' })
|
||||
})
|
||||
|
||||
it('Given pending text When Backspace or Delete is pressed Then keeps edits local', () => {
|
||||
// Given
|
||||
const pendingText = '한'
|
||||
|
||||
// When
|
||||
const backspaceDecision = getTerminalLiveSpecialKeyDecision({ key: 'Backspace', pendingText })
|
||||
const deleteDecision = getTerminalLiveSpecialKeyDecision({ key: 'Delete', pendingText })
|
||||
|
||||
// Then
|
||||
expect(backspaceDecision).toEqual({ kind: 'local-edit' })
|
||||
expect(deleteDecision).toEqual({ kind: 'local-edit' })
|
||||
})
|
||||
|
||||
it('Given no pending text When Backspace or Delete is pressed Then sends terminal bytes', () => {
|
||||
// Given
|
||||
const pendingText = ''
|
||||
|
||||
// When
|
||||
const backspaceDecision = getTerminalLiveSpecialKeyDecision({ key: 'Backspace', pendingText })
|
||||
const deleteDecision = getTerminalLiveSpecialKeyDecision({ key: 'Delete', pendingText })
|
||||
|
||||
// Then
|
||||
expect(backspaceDecision).toEqual({ kind: 'send-now', bytes: '\x7f' })
|
||||
expect(deleteDecision).toEqual({ kind: 'send-now', bytes: '\x1b[3~' })
|
||||
})
|
||||
|
||||
it('Given pending text When a terminal special key is pressed Then flushes pending text before bytes', () => {
|
||||
// Given
|
||||
const pendingText = '한'
|
||||
|
||||
// When
|
||||
const decision = getTerminalLiveSpecialKeyDecision({ key: 'Tab', pendingText })
|
||||
|
||||
// Then
|
||||
expect(decision).toEqual({ kind: 'flush-then-send', pendingText, bytes: '\t' })
|
||||
})
|
||||
|
||||
it('Given pending text When accessory control bytes are requested Then flushes pending text before bytes', () => {
|
||||
// Given
|
||||
const pendingText = '한글'
|
||||
|
||||
// When
|
||||
const tabDecision = getTerminalLiveAccessoryBytesDecision({ bytes: '\t', pendingText })
|
||||
const escapeDecision = getTerminalLiveAccessoryBytesDecision({ bytes: '\x1b', pendingText })
|
||||
const enterDecision = getTerminalLiveAccessoryBytesDecision({ bytes: '\r', pendingText })
|
||||
|
||||
// Then
|
||||
expect(tabDecision).toEqual({ kind: 'flush-then-send', pendingText, bytes: '\t' })
|
||||
expect(escapeDecision).toEqual({ kind: 'flush-then-send', pendingText, bytes: '\x1b' })
|
||||
expect(enterDecision).toEqual({ kind: 'flush-then-send', pendingText, bytes: '\r' })
|
||||
})
|
||||
|
||||
it('Given pending text When accessory Backspace or Delete bytes are requested Then keeps edits local', () => {
|
||||
// Given
|
||||
const pendingText = '한글'
|
||||
|
||||
// When
|
||||
const backspaceDecision = getTerminalLiveAccessoryBytesDecision({
|
||||
bytes: '\x7f',
|
||||
localEdit: 'backspace',
|
||||
pendingText
|
||||
it('Given Backspace with an empty field Then sends terminal backspace bytes', () => {
|
||||
const decision = getTerminalLiveSpecialKeyDecision({
|
||||
key: 'Backspace',
|
||||
heldText: '',
|
||||
sentText: ''
|
||||
})
|
||||
const ctrlBackspaceDecision = getTerminalLiveAccessoryBytesDecision({
|
||||
bytes: '\b',
|
||||
localEdit: 'backspace',
|
||||
pendingText
|
||||
})
|
||||
const deleteDecision = getTerminalLiveAccessoryBytesDecision({
|
||||
bytes: '\x1b[3~',
|
||||
localEdit: 'delete',
|
||||
pendingText
|
||||
})
|
||||
const customDeleteByteDecision = getTerminalLiveAccessoryBytesDecision({
|
||||
bytes: '\x7f',
|
||||
pendingText
|
||||
})
|
||||
const backspaceText = getTerminalLiveAccessoryLocalEditText({
|
||||
localEdit: 'backspace',
|
||||
pendingText
|
||||
})
|
||||
const deleteText = getTerminalLiveAccessoryLocalEditText({
|
||||
localEdit: 'delete',
|
||||
pendingText
|
||||
})
|
||||
|
||||
// Then
|
||||
expect(backspaceDecision).toEqual({ kind: 'local-edit', localEdit: 'backspace' })
|
||||
expect(ctrlBackspaceDecision).toEqual({ kind: 'local-edit', localEdit: 'backspace' })
|
||||
expect(deleteDecision).toEqual({ kind: 'local-edit', localEdit: 'delete' })
|
||||
expect(customDeleteByteDecision).toEqual({
|
||||
kind: 'flush-then-send',
|
||||
pendingText,
|
||||
bytes: '\x7f'
|
||||
})
|
||||
expect(backspaceText).toBe('한')
|
||||
expect(deleteText).toBe('한글')
|
||||
expect(getTerminalLiveDeferredTextDelayMs(backspaceText)).toBeNull()
|
||||
expect(getTerminalLiveDeferredTextDelayMs(deleteText)).toBeNull()
|
||||
expect(decision.kind).toBe('send-now')
|
||||
})
|
||||
|
||||
it('Given no pending text When accessory bytes are requested Then sends terminal bytes', () => {
|
||||
// Given
|
||||
const pendingText = ''
|
||||
|
||||
// When
|
||||
const tabDecision = getTerminalLiveAccessoryBytesDecision({ bytes: '\t', pendingText })
|
||||
|
||||
// Then
|
||||
expect(tabDecision).toEqual({ kind: 'send-now', bytes: '\t' })
|
||||
it('Given a control key with a held syllable Then commits the held text before the bytes', () => {
|
||||
const decision = getTerminalLiveSpecialKeyDecision({
|
||||
key: 'Tab',
|
||||
heldText: '글',
|
||||
sentText: '한'
|
||||
})
|
||||
expect(decision.kind).toBe('commit-held-then-send')
|
||||
})
|
||||
|
||||
it('Given a non-special key When key decision is requested Then ignores it', () => {
|
||||
// Given
|
||||
const key = 'a'
|
||||
it('Given a control key with no held syllable Then sends immediately', () => {
|
||||
const decision = getTerminalLiveSpecialKeyDecision({
|
||||
key: 'ArrowUp',
|
||||
heldText: '',
|
||||
sentText: 'ls'
|
||||
})
|
||||
expect(decision.kind).toBe('send-now')
|
||||
})
|
||||
})
|
||||
|
||||
describe('terminal live accessory bytes decision', () => {
|
||||
it('Given a local-edit accessory key with field text Then edits locally', () => {
|
||||
expect(
|
||||
getTerminalLiveAccessoryBytesDecision({
|
||||
bytes: '\x7f',
|
||||
localEdit: 'backspace',
|
||||
heldText: '한',
|
||||
sentText: ''
|
||||
})
|
||||
).toEqual({ kind: 'local-edit', localEdit: 'backspace' })
|
||||
expect(
|
||||
getTerminalLiveAccessoryBytesDecision({
|
||||
bytes: '\x7f',
|
||||
localEdit: 'backspace',
|
||||
heldText: '',
|
||||
sentText: 'abc'
|
||||
})
|
||||
).toEqual({ kind: 'local-edit', localEdit: 'backspace' })
|
||||
})
|
||||
|
||||
// When
|
||||
const decision = getTerminalLiveSpecialKeyDecision({ key, pendingText: '한' })
|
||||
it('Given raw accessory bytes with a held syllable Then commits held text first', () => {
|
||||
const decision = getTerminalLiveAccessoryBytesDecision({
|
||||
bytes: '\x1b',
|
||||
heldText: '한',
|
||||
sentText: ''
|
||||
})
|
||||
expect(decision).toEqual({ kind: 'commit-held-then-send', bytes: '\x1b' })
|
||||
})
|
||||
|
||||
// Then
|
||||
expect(decision).toEqual({ kind: 'ignore' })
|
||||
it('Given raw accessory bytes with nothing held Then sends immediately', () => {
|
||||
const decision = getTerminalLiveAccessoryBytesDecision({
|
||||
bytes: '\x1b',
|
||||
heldText: '',
|
||||
sentText: 'abc'
|
||||
})
|
||||
expect(decision).toEqual({ kind: 'send-now', bytes: '\x1b' })
|
||||
})
|
||||
|
||||
it('Given no pending text When submit is requested Then sends only carriage return', () => {
|
||||
// Given
|
||||
const pendingText = ''
|
||||
it('Given a local-edit accessory key with an empty field Then sends the raw bytes', () => {
|
||||
const decision = getTerminalLiveAccessoryBytesDecision({
|
||||
bytes: '\x7f',
|
||||
localEdit: 'backspace',
|
||||
heldText: '',
|
||||
sentText: ''
|
||||
})
|
||||
expect(decision).toEqual({ kind: 'send-now', bytes: '\x7f' })
|
||||
})
|
||||
})
|
||||
|
||||
// When
|
||||
const sequence = getTerminalLiveSubmitSequence(pendingText)
|
||||
describe('terminal live accessory local edit text', () => {
|
||||
it('Given backspace Then drops the last code point of the field text', () => {
|
||||
expect(
|
||||
getTerminalLiveAccessoryLocalEditText({ localEdit: 'backspace', fieldText: '한글' })
|
||||
).toBe('한')
|
||||
expect(getTerminalLiveAccessoryLocalEditText({ localEdit: 'backspace', fieldText: '한' })).toBe(
|
||||
''
|
||||
)
|
||||
})
|
||||
|
||||
// Then
|
||||
expect(sequence).toEqual(['\r'])
|
||||
it('Given forward delete Then keeps the field text unchanged', () => {
|
||||
expect(getTerminalLiveAccessoryLocalEditText({ localEdit: 'delete', fieldText: '한글' })).toBe(
|
||||
'한글'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,23 +1,15 @@
|
||||
import { getTerminalLiveSpecialKeyBytes } from './terminal-live-input'
|
||||
|
||||
// Why: React Native does not expose portable composition events here, so
|
||||
// non-Hangul IME text gets a short settle window before being sent to the PTY.
|
||||
export const TERMINAL_LIVE_TEXT_COMMIT_DELAY_MS = 150
|
||||
|
||||
export type TerminalLiveTextChangeDecision =
|
||||
| { readonly kind: 'ignore' }
|
||||
| { readonly kind: 'send-now'; readonly text: string }
|
||||
| { readonly kind: 'defer'; readonly text: string; readonly delayMs: number | null }
|
||||
|
||||
export type TerminalLiveSpecialKeyDecision =
|
||||
| { readonly kind: 'ignore' }
|
||||
| { readonly kind: 'local-edit' }
|
||||
| { readonly kind: 'send-now'; readonly bytes: string }
|
||||
| { readonly kind: 'flush-then-send'; readonly pendingText: string; readonly bytes: string }
|
||||
| { readonly kind: 'commit-held-then-send'; readonly bytes: string }
|
||||
|
||||
export type TerminalLiveSpecialKeyDecisionInput = {
|
||||
readonly key: string
|
||||
readonly pendingText: string
|
||||
readonly heldText: string
|
||||
readonly sentText: string
|
||||
}
|
||||
|
||||
export type TerminalLiveAccessoryLocalEdit = 'backspace' | 'delete'
|
||||
@@ -25,78 +17,33 @@ export type TerminalLiveAccessoryLocalEdit = 'backspace' | 'delete'
|
||||
export type TerminalLiveAccessoryBytesDecision =
|
||||
| { readonly kind: 'local-edit'; readonly localEdit: TerminalLiveAccessoryLocalEdit }
|
||||
| { readonly kind: 'send-now'; readonly bytes: string }
|
||||
| { readonly kind: 'flush-then-send'; readonly pendingText: string; readonly bytes: string }
|
||||
| { readonly kind: 'commit-held-then-send'; readonly bytes: string }
|
||||
|
||||
export type TerminalLiveAccessoryBytesDecisionInput = {
|
||||
readonly bytes: string
|
||||
readonly localEdit?: TerminalLiveAccessoryLocalEdit
|
||||
readonly pendingText: string
|
||||
}
|
||||
|
||||
export function isTerminalLiveTextImeCandidate(text: string): boolean {
|
||||
for (const character of text) {
|
||||
const codePoint = character.codePointAt(0)
|
||||
if (codePoint !== undefined && codePoint > 0x7f) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function isHangulCodePoint(codePoint: number): boolean {
|
||||
return (
|
||||
(codePoint >= 0x1100 && codePoint <= 0x11ff) ||
|
||||
(codePoint >= 0x3130 && codePoint <= 0x318f) ||
|
||||
(codePoint >= 0xa960 && codePoint <= 0xa97f) ||
|
||||
(codePoint >= 0xac00 && codePoint <= 0xd7af)
|
||||
)
|
||||
}
|
||||
|
||||
export function isTerminalLiveTextHangulCandidate(text: string): boolean {
|
||||
for (const character of text) {
|
||||
const codePoint = character.codePointAt(0)
|
||||
if (codePoint !== undefined && isHangulCodePoint(codePoint)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
export function getTerminalLiveTextChangeDecision(text: string): TerminalLiveTextChangeDecision {
|
||||
if (text.length === 0) {
|
||||
return { kind: 'ignore' }
|
||||
}
|
||||
|
||||
if (isTerminalLiveTextHangulCandidate(text)) {
|
||||
return { kind: 'defer', text, delayMs: null }
|
||||
}
|
||||
|
||||
if (isTerminalLiveTextImeCandidate(text)) {
|
||||
return { kind: 'defer', text, delayMs: TERMINAL_LIVE_TEXT_COMMIT_DELAY_MS }
|
||||
}
|
||||
|
||||
return { kind: 'send-now', text }
|
||||
}
|
||||
|
||||
export function getTerminalLiveDeferredTextDelayMs(text: string): number | null {
|
||||
return isTerminalLiveTextHangulCandidate(text) ? null : TERMINAL_LIVE_TEXT_COMMIT_DELAY_MS
|
||||
readonly heldText: string
|
||||
readonly sentText: string
|
||||
}
|
||||
|
||||
export function getTerminalLiveSpecialKeyDecision({
|
||||
key,
|
||||
pendingText
|
||||
heldText,
|
||||
sentText
|
||||
}: TerminalLiveSpecialKeyDecisionInput): TerminalLiveSpecialKeyDecision {
|
||||
const bytes = getTerminalLiveSpecialKeyBytes(key)
|
||||
if (bytes === null) {
|
||||
return { kind: 'ignore' }
|
||||
}
|
||||
|
||||
if (pendingText.length > 0 && (key === 'Backspace' || key === 'Delete')) {
|
||||
// Why: native field edits fire onChangeText and the mirror diff emits the
|
||||
// matching PTY erase; sending raw DEL here as well would double-erase.
|
||||
if ((key === 'Backspace' || key === 'Delete') && (heldText.length > 0 || sentText.length > 0)) {
|
||||
return { kind: 'local-edit' }
|
||||
}
|
||||
|
||||
if (pendingText.length > 0) {
|
||||
return { kind: 'flush-then-send', pendingText, bytes }
|
||||
if (heldText.length > 0) {
|
||||
return { kind: 'commit-held-then-send', bytes }
|
||||
}
|
||||
|
||||
return { kind: 'send-now', bytes }
|
||||
@@ -105,14 +52,15 @@ export function getTerminalLiveSpecialKeyDecision({
|
||||
export function getTerminalLiveAccessoryBytesDecision({
|
||||
bytes,
|
||||
localEdit,
|
||||
pendingText
|
||||
heldText,
|
||||
sentText
|
||||
}: TerminalLiveAccessoryBytesDecisionInput): TerminalLiveAccessoryBytesDecision {
|
||||
if (pendingText.length > 0 && localEdit) {
|
||||
if (localEdit && (heldText.length > 0 || sentText.length > 0)) {
|
||||
return { kind: 'local-edit', localEdit }
|
||||
}
|
||||
|
||||
if (pendingText.length > 0) {
|
||||
return { kind: 'flush-then-send', pendingText, bytes }
|
||||
if (heldText.length > 0) {
|
||||
return { kind: 'commit-held-then-send', bytes }
|
||||
}
|
||||
|
||||
return { kind: 'send-now', bytes }
|
||||
@@ -120,26 +68,16 @@ export function getTerminalLiveAccessoryBytesDecision({
|
||||
|
||||
export function getTerminalLiveAccessoryLocalEditText({
|
||||
localEdit,
|
||||
pendingText
|
||||
fieldText
|
||||
}: {
|
||||
readonly localEdit: TerminalLiveAccessoryLocalEdit
|
||||
readonly pendingText: string
|
||||
readonly fieldText: string
|
||||
}): string {
|
||||
if (localEdit === 'delete') {
|
||||
// Why: accessory Delete mirrors forward-delete at the hidden input's end;
|
||||
// it stays local but does not remove the pending IME text.
|
||||
return pendingText
|
||||
// it stays local but does not remove the field text.
|
||||
return fieldText
|
||||
}
|
||||
|
||||
return Array.from(pendingText).slice(0, -1).join('')
|
||||
}
|
||||
|
||||
export type TerminalLiveSubmitSequence = readonly ['\r'] | readonly [string, '\r']
|
||||
|
||||
export function getTerminalLiveSubmitSequence(pendingText: string): TerminalLiveSubmitSequence {
|
||||
if (pendingText.length === 0) {
|
||||
return ['\r']
|
||||
}
|
||||
|
||||
return [pendingText, '\r']
|
||||
return Array.from(fieldText).slice(0, -1).join('')
|
||||
}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import type { RuntimeMobileTerminalTheme } from '../../../src/shared/runtime-types'
|
||||
import type { TerminalOscLinkRange } from './terminal-osc-link-ranges'
|
||||
|
||||
type TerminalMouseTrackingMode = 'none' | 'x10' | 'vt200' | 'drag' | 'any'
|
||||
|
||||
export type TerminalModes = {
|
||||
bracketedPasteMode: boolean
|
||||
altScreen: boolean
|
||||
mouseTrackingMode: TerminalMouseTrackingMode
|
||||
sgrMouseMode: boolean
|
||||
sgrMousePixelsMode: boolean
|
||||
}
|
||||
|
||||
export type TerminalKeyboardAvoidanceMetrics = {
|
||||
cursorY: number
|
||||
rows: number
|
||||
altScreen: boolean
|
||||
}
|
||||
|
||||
export type MobileTerminalTheme = RuntimeMobileTerminalTheme
|
||||
|
||||
export type TerminalSelectionEvents = {
|
||||
onSelectionMode?: (active: boolean) => void
|
||||
onSelectionCopy?: (text: string) => void
|
||||
onSelectionEvicted?: () => void
|
||||
onModesChanged?: (modes: TerminalModes) => void
|
||||
onKeyboardAvoidanceMetrics?: (metrics: TerminalKeyboardAvoidanceMetrics) => void
|
||||
onHaptic?: (kind: 'selection' | 'success' | 'error' | 'edge-bump') => void
|
||||
onTerminalInput?: (bytes: string) => void
|
||||
onTerminalTap?: () => void
|
||||
// Tap landed on a detected file path; RN resolves + opens it.
|
||||
onFileTap?: (pathText: string, line: number | null, column: number | null) => void
|
||||
// WebView-detected URL tap; RN chooses the mobile routing destination.
|
||||
onOpenUrl?: (url: string) => void
|
||||
// Why: pinch-to-zoom in the terminal snaps to a text-size preset and reports it
|
||||
// here so the app persists it and keeps Settings + other panes in sync.
|
||||
onTextScaleChange?: (scale: number) => void
|
||||
}
|
||||
|
||||
export type TerminalWebViewHandle = {
|
||||
write: (data: string) => void
|
||||
init: (
|
||||
cols: number,
|
||||
rows: number,
|
||||
initialData?: string,
|
||||
preserveScroll?: boolean,
|
||||
oscLinks?: TerminalOscLinkRange[]
|
||||
) => void
|
||||
resize: (cols: number, rows: number) => void
|
||||
// Why: reflow the local xterm buffer (scrollback included) to a new width
|
||||
// after a server-side PTY reflow, so older wrapped lines rewrap to match the
|
||||
// latest output. No-op on the alternate screen.
|
||||
reflow: (cols: number, rows: number) => void
|
||||
clear: () => void
|
||||
measureFitDimensions: (containerHeight?: number) => Promise<{ cols: number; rows: number } | null>
|
||||
resetZoom: () => void
|
||||
cancelSelect: () => void
|
||||
doSelectAll: () => void
|
||||
// Why: lets callers await the WebView-side `init` rAF chain (term.open
|
||||
// → renderService population → first paint) so a follow-up measure
|
||||
// doesn't race ahead and find term=null or cellWidth=0. Resolves on
|
||||
// the next 'ready' notify after the most recent init.
|
||||
awaitReady: () => Promise<void>
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import { useCallback, useState } from 'react'
|
||||
import { RefreshCw } from 'lucide-react-native'
|
||||
import { Pressable, StyleSheet, Text, View } from 'react-native'
|
||||
import { colors } from '../theme/mobile-theme'
|
||||
|
||||
export type NativeWebViewEngineEvent = {
|
||||
readonly nativeEvent?: object
|
||||
}
|
||||
|
||||
type TerminalWebViewEngineErrorOverlayProps = {
|
||||
readonly message: string
|
||||
readonly onReload: () => void
|
||||
}
|
||||
|
||||
type NativeWebViewEngineFields = {
|
||||
readonly description?: unknown
|
||||
readonly code?: unknown
|
||||
readonly statusCode?: unknown
|
||||
readonly domain?: unknown
|
||||
readonly didCrash?: unknown
|
||||
}
|
||||
|
||||
export function useTerminalWebViewEngineErrorState(onEngineError?: (message: string) => void) {
|
||||
const [engineError, setEngineError] = useState<string | null>(null)
|
||||
const clearEngineError = useCallback(() => setEngineError(null), [])
|
||||
const reportEngineError = useCallback(
|
||||
(message: string, fatal: boolean) => {
|
||||
onEngineError?.(message)
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('[terminal-webview] engine error', message)
|
||||
if (fatal) {
|
||||
// Why: the first fatal report is the root cause; later cascades (e.g. the
|
||||
// web-ready watchdog firing after a process-crash report) must not
|
||||
// overwrite its more specific diagnostics. clearEngineError resets.
|
||||
setEngineError((previous) => previous ?? message)
|
||||
}
|
||||
},
|
||||
[onEngineError]
|
||||
)
|
||||
const reportNativeEngineError = useCallback(
|
||||
(context: string, event?: NativeWebViewEngineEvent) => {
|
||||
reportEngineError(describeNativeWebViewEngineError(context, event), true)
|
||||
},
|
||||
[reportEngineError]
|
||||
)
|
||||
return { clearEngineError, engineError, reportEngineError, reportNativeEngineError }
|
||||
}
|
||||
|
||||
export function describeNativeWebViewEngineError(
|
||||
context: string,
|
||||
event?: NativeWebViewEngineEvent
|
||||
): string {
|
||||
const native = event?.nativeEvent as NativeWebViewEngineFields | undefined
|
||||
const parts = [context]
|
||||
const description = native?.description
|
||||
const statusCode = native?.statusCode
|
||||
const code = native?.code
|
||||
const domain = native?.domain
|
||||
if (typeof description === 'string') {
|
||||
parts.push(description)
|
||||
}
|
||||
if (typeof statusCode === 'number') {
|
||||
parts.push(`status ${statusCode}`)
|
||||
}
|
||||
if (typeof code === 'number') {
|
||||
parts.push(`code ${code}`)
|
||||
}
|
||||
if (typeof domain === 'string') {
|
||||
parts.push(domain)
|
||||
}
|
||||
if (native?.didCrash === true) {
|
||||
parts.push('renderer crashed')
|
||||
}
|
||||
return parts.join(' - ')
|
||||
}
|
||||
|
||||
export function TerminalWebViewEngineErrorOverlay({
|
||||
message,
|
||||
onReload
|
||||
}: TerminalWebViewEngineErrorOverlayProps) {
|
||||
return (
|
||||
<View style={styles.errorOverlay}>
|
||||
<Text style={styles.errorTitle}>Terminal failed to load</Text>
|
||||
<Text style={styles.errorDetail} numberOfLines={4}>
|
||||
{message}
|
||||
</Text>
|
||||
<Pressable accessibilityRole="button" style={styles.reloadButton} onPress={onReload}>
|
||||
<RefreshCw size={16} color={colors.terminalBg} />
|
||||
<Text style={styles.reloadButtonText}>Reload</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
errorOverlay: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 12,
|
||||
padding: 24,
|
||||
backgroundColor: colors.terminalBg
|
||||
},
|
||||
errorTitle: {
|
||||
color: colors.textPrimary,
|
||||
fontSize: 16,
|
||||
fontWeight: '700',
|
||||
textAlign: 'center'
|
||||
},
|
||||
errorDetail: {
|
||||
color: colors.textSecondary,
|
||||
fontSize: 13,
|
||||
lineHeight: 18,
|
||||
textAlign: 'center'
|
||||
},
|
||||
reloadButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
minHeight: 36,
|
||||
paddingHorizontal: 14,
|
||||
borderRadius: 6,
|
||||
backgroundColor: colors.surfaceBright
|
||||
},
|
||||
reloadButtonText: {
|
||||
color: colors.terminalBg,
|
||||
fontSize: 14,
|
||||
fontWeight: '700'
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,178 @@
|
||||
import { createElement } from 'react'
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { TerminalWebView } from './TerminalWebView'
|
||||
|
||||
vi.mock('react-native', () => ({
|
||||
AppState: { currentState: 'active' },
|
||||
Pressable: 'Pressable',
|
||||
StyleSheet: {
|
||||
absoluteFillObject: {
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
position: 'absolute',
|
||||
right: 0,
|
||||
top: 0
|
||||
},
|
||||
create: (styles: unknown) => styles
|
||||
},
|
||||
Text: 'Text',
|
||||
View: 'View'
|
||||
}))
|
||||
|
||||
vi.mock('react-native-webview', () => ({
|
||||
WebView: 'WebView',
|
||||
default: 'WebView'
|
||||
}))
|
||||
|
||||
vi.mock('lucide-react-native', () => ({
|
||||
RefreshCw: 'RefreshCw'
|
||||
}))
|
||||
|
||||
function suppressReactTestRendererDeprecationWarning(): () => void {
|
||||
const originalConsoleError = console.error
|
||||
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation((...args) => {
|
||||
const firstArg = args[0]
|
||||
if (typeof firstArg === 'string' && firstArg.includes('react-test-renderer is deprecated')) {
|
||||
return
|
||||
}
|
||||
originalConsoleError(...args)
|
||||
})
|
||||
return () => consoleErrorSpy.mockRestore()
|
||||
}
|
||||
|
||||
// Why: mounting TerminalWebView arms the web-ready watchdog; tests must
|
||||
// unmount so the timer can't survive into later tests and fire in teardown.
|
||||
let activeRenderer: ReactTestRenderer | null = null
|
||||
|
||||
function createTerminalWebViewRenderer(onEngineError = vi.fn()) {
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
const restoreConsoleError = suppressReactTestRendererDeprecationWarning()
|
||||
try {
|
||||
act(() => {
|
||||
renderer = create(createElement(TerminalWebView, { onEngineError }))
|
||||
})
|
||||
} finally {
|
||||
restoreConsoleError()
|
||||
}
|
||||
if (!renderer) {
|
||||
throw new Error('TerminalWebView did not render')
|
||||
}
|
||||
activeRenderer = renderer
|
||||
return { onEngineError, renderer }
|
||||
}
|
||||
|
||||
function postWebViewMessage(renderer: ReactTestRenderer, payload: Record<string, unknown>) {
|
||||
const webView = renderer.root.findByType('WebView')
|
||||
act(() => {
|
||||
webView.props.onMessage({ nativeEvent: { data: JSON.stringify(payload) } })
|
||||
})
|
||||
}
|
||||
|
||||
function renderedText(renderer: ReactTestRenderer): string {
|
||||
return renderer.root
|
||||
.findAllByType('Text')
|
||||
.flatMap((node) => node.props.children)
|
||||
.join(' ')
|
||||
}
|
||||
|
||||
describe('TerminalWebView engine errors', () => {
|
||||
afterEach(() => {
|
||||
if (activeRenderer) {
|
||||
act(() => {
|
||||
activeRenderer?.unmount()
|
||||
})
|
||||
activeRenderer = null
|
||||
}
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('renders the reload overlay for fatal engine errors from the WebView', () => {
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
const { onEngineError, renderer } = createTerminalWebViewRenderer()
|
||||
|
||||
postWebViewMessage(renderer, {
|
||||
fatal: true,
|
||||
message: 'terminal engine missing - SyntaxError - Chrome 74',
|
||||
type: 'error'
|
||||
})
|
||||
|
||||
expect(onEngineError).toHaveBeenCalledWith('terminal engine missing - SyntaxError - Chrome 74')
|
||||
expect(renderedText(renderer)).toContain('Terminal failed to load')
|
||||
expect(renderedText(renderer)).toContain('terminal engine missing - SyntaxError - Chrome 74')
|
||||
expect(renderedText(renderer)).toContain('Reload')
|
||||
})
|
||||
|
||||
it('reports non-fatal engine errors without covering a live terminal', () => {
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
const { onEngineError, renderer } = createTerminalWebViewRenderer()
|
||||
|
||||
postWebViewMessage(renderer, {
|
||||
fatal: false,
|
||||
message: 'terminal message failed - malformed chunk',
|
||||
type: 'error'
|
||||
})
|
||||
|
||||
expect(onEngineError).toHaveBeenCalledWith('terminal message failed - malformed chunk')
|
||||
expect(renderedText(renderer)).not.toContain('Terminal failed to load')
|
||||
})
|
||||
|
||||
it('keeps the first fatal diagnostics when later fatal reports cascade', () => {
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
const { renderer } = createTerminalWebViewRenderer()
|
||||
|
||||
postWebViewMessage(renderer, {
|
||||
fatal: true,
|
||||
message: 'terminal engine missing - root cause',
|
||||
type: 'error'
|
||||
})
|
||||
postWebViewMessage(renderer, {
|
||||
fatal: true,
|
||||
message: 'Terminal did not initialize - watchdog cascade',
|
||||
type: 'error'
|
||||
})
|
||||
|
||||
expect(renderedText(renderer)).toContain('terminal engine missing - root cause')
|
||||
expect(renderedText(renderer)).not.toContain('watchdog cascade')
|
||||
})
|
||||
|
||||
// Why: if the document dies before the glue can post anything (or the RN
|
||||
// bridge never comes up), no message and no native error handler fires —
|
||||
// the watchdog must convert that silence into the visible fatal overlay.
|
||||
it('paints the fatal overlay when web-ready never arrives', () => {
|
||||
vi.useFakeTimers()
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
try {
|
||||
const { onEngineError, renderer } = createTerminalWebViewRenderer()
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(15000)
|
||||
})
|
||||
|
||||
expect(onEngineError).toHaveBeenCalledWith(
|
||||
'Terminal did not initialize - no ready signal from the terminal view'
|
||||
)
|
||||
expect(renderedText(renderer)).toContain('Terminal failed to load')
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('does not fire the watchdog once web-ready has arrived', () => {
|
||||
vi.useFakeTimers()
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
try {
|
||||
const { onEngineError, renderer } = createTerminalWebViewRenderer()
|
||||
|
||||
postWebViewMessage(renderer, { type: 'web-ready' })
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(60000)
|
||||
})
|
||||
|
||||
expect(onEngineError).not.toHaveBeenCalled()
|
||||
expect(renderedText(renderer)).not.toContain('Terminal failed to load')
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,109 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { Script } from 'node:vm'
|
||||
import { parse } from 'acorn'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { XTERM_ENGINE_CSS, XTERM_ENGINE_JS } from './terminal-webview-engine.generated'
|
||||
import { XTERM_HTML } from './terminal-webview-html'
|
||||
|
||||
const terminalHtmlSource = readFileSync(
|
||||
new URL('./terminal-webview-html.ts', import.meta.url),
|
||||
'utf8'
|
||||
)
|
||||
|
||||
describe('terminal WebView bundled engine', () => {
|
||||
it('keeps the assembled terminal HTML free of external engine URLs', () => {
|
||||
expect(XTERM_HTML).not.toMatch(/\bhttps?:\/\//)
|
||||
expect(XTERM_HTML).not.toContain('cdn.jsdelivr.net')
|
||||
expect(XTERM_HTML).not.toContain('<script src=')
|
||||
expect(XTERM_HTML).not.toContain('rel="stylesheet" href=')
|
||||
})
|
||||
|
||||
it('parses the bundled engine at the Chrome 74 syntax floor', () => {
|
||||
expect(() => parse(XTERM_ENGINE_JS, { ecmaVersion: 2019 })).not.toThrow()
|
||||
})
|
||||
|
||||
// Why: the context deliberately omits WeakRef (Chrome 84+) / structuredClone
|
||||
// (Chrome 98+) and supplies an Element without replaceChildren (Chrome 86+) —
|
||||
// the engine must evaluate on older WebViews via its own guarded runtime shims,
|
||||
// which are the linchpin of the old-WebView support (esbuild lowers syntax only).
|
||||
it('exposes the xterm globals and installs the old-WebView runtime shims', () => {
|
||||
const window: Record<string, unknown> = {}
|
||||
class ElementStub {}
|
||||
const context = {
|
||||
window,
|
||||
self: window,
|
||||
document: {},
|
||||
Element: ElementStub,
|
||||
navigator: {
|
||||
platform: 'Linux armv8l',
|
||||
userAgent: 'Mozilla/5.0 Chrome/74.0.3729.157'
|
||||
},
|
||||
console,
|
||||
setTimeout,
|
||||
clearTimeout,
|
||||
queueMicrotask,
|
||||
URL
|
||||
}
|
||||
|
||||
new Script(XTERM_ENGINE_JS).runInNewContext(context)
|
||||
|
||||
expect(window).toMatchObject({
|
||||
Terminal: expect.any(Function),
|
||||
Unicode11Addon: { Unicode11Addon: expect.any(Function) },
|
||||
WebglAddon: { WebglAddon: expect.any(Function) }
|
||||
})
|
||||
|
||||
const weakRef = window.WeakRef as (new (target: unknown) => { deref(): unknown }) | undefined
|
||||
expect(typeof weakRef).toBe('function')
|
||||
const token = {}
|
||||
expect(new weakRef!(token).deref()).toBe(token)
|
||||
expect(typeof window.structuredClone).toBe('function')
|
||||
expect(typeof (ElementStub.prototype as { replaceChildren?: unknown }).replaceChildren).toBe(
|
||||
'function'
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps the bundled engine from breaking out of its inline script/style tags', () => {
|
||||
// Why: the engine JS/CSS are inlined into <script>/<style> blocks. </script
|
||||
// and </style are neutralized at build time; the tokenizer-escape openers that
|
||||
// could swallow the rest of the document must also be absent from the bundle.
|
||||
expect(XTERM_ENGINE_JS).not.toMatch(/<\/script/i)
|
||||
expect(XTERM_ENGINE_JS).not.toMatch(/<script/i)
|
||||
expect(XTERM_ENGINE_JS).not.toContain('<!--')
|
||||
expect(XTERM_ENGINE_CSS).not.toMatch(/<\/style/i)
|
||||
})
|
||||
|
||||
it('reports WebView message handler failures instead of swallowing them', () => {
|
||||
const start = terminalHtmlSource.indexOf('function handleIncomingMessage')
|
||||
const end = terminalHtmlSource.indexOf("window.addEventListener('resize'", start)
|
||||
expect(start).toBeGreaterThanOrEqual(0)
|
||||
expect(end).toBeGreaterThan(start)
|
||||
const handlerSource = terminalHtmlSource.slice(start, end)
|
||||
|
||||
expect(handlerSource).toContain('reportEngineError(')
|
||||
expect(handlerSource).toContain("'terminal init failed'")
|
||||
expect(handlerSource).toContain("'terminal message failed'")
|
||||
expect(handlerSource).not.toContain('catch(ex) {}')
|
||||
})
|
||||
|
||||
it('classifies runtime errors by a document-scoped ever-ready latch', () => {
|
||||
// Why: init() flips `ready` false on every re-init (live width reflow keeps the
|
||||
// old surface visible meanwhile), so the fatal default and the init-catch must
|
||||
// key off `everReady` — otherwise a transient reflow error blanks a live
|
||||
// terminal behind the fatal overlay. The latch stays set for the document.
|
||||
expect(terminalHtmlSource).toContain('var everReady = false;')
|
||||
expect(terminalHtmlSource).toContain('everReady = true;')
|
||||
expect(terminalHtmlSource).toContain('fatal === undefined ? !everReady : !!fatal')
|
||||
expect(terminalHtmlSource).toContain("msg.type === 'init' && !everReady")
|
||||
expect(terminalHtmlSource).not.toMatch(/fatal === undefined \? !ready\b/)
|
||||
})
|
||||
|
||||
it('bounds error capture and non-fatal reporting on a degraded engine', () => {
|
||||
// Why: a constructed-but-broken engine can throw per render frame; both
|
||||
// onerror capture sites must cap the buffer and non-fatal notifies must
|
||||
// stop flooding RN while fatal reports always emit.
|
||||
const capSites = terminalHtmlSource.match(/__engineErrors\.length < 20/g) ?? []
|
||||
expect(capSites.length).toBe(2)
|
||||
expect(terminalHtmlSource).toContain('nonFatalErrorNotifies > 5')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,13 @@
|
||||
import { StyleSheet } from 'react-native'
|
||||
import { colors } from '../theme/mobile-theme'
|
||||
|
||||
export const TERMINAL_WEBVIEW_FRAME_STYLES = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.terminalBg
|
||||
},
|
||||
webview: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.terminalBg
|
||||
}
|
||||
})
|
||||
@@ -4,6 +4,7 @@ import type { RuntimeMobileTerminalTheme } from '../../../src/shared/runtime-typ
|
||||
import { colors } from '../theme/mobile-theme'
|
||||
import { TERMINAL_TEXT_SCALES } from '../storage/preferences'
|
||||
import { TERMINAL_PATH_TAP_JS } from './terminal-path-tap-injected'
|
||||
import { XTERM_ENGINE_CSS, XTERM_ENGINE_JS } from './terminal-webview-engine.generated'
|
||||
import { TERMINAL_REFLOW_JS } from './terminal-webview-reflow-injected'
|
||||
import { TERMINAL_TAP_DISPATCH_JS } from './terminal-webview-tap-dispatch-injected'
|
||||
import { URL_TAP_WEBVIEW_JS } from './terminal-webview-url-tap'
|
||||
@@ -47,7 +48,15 @@ export const XTERM_HTML = `<!DOCTYPE html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@xterm/xterm@6.1.0-beta.285/css/xterm.min.css">
|
||||
<script>
|
||||
window.__engineErrors = [];
|
||||
window.onerror = function(msg) {
|
||||
// Why: a degraded engine can throw per frame; cap so the capture buffer
|
||||
// and downstream reporting stay bounded for the document's lifetime.
|
||||
if (window.__engineErrors.length < 20) window.__engineErrors.push(String(msg));
|
||||
};
|
||||
</script>
|
||||
<style>${XTERM_ENGINE_CSS}</style>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
html, body {
|
||||
@@ -192,7 +201,7 @@ export const XTERM_HTML = `<!DOCTYPE html>
|
||||
<button id="sel-menu-all">Select All</button>
|
||||
</div>
|
||||
</div>
|
||||
<script src="https://cdn.jsdelivr.net/npm/@xterm/xterm@6.1.0-beta.285/lib/xterm.min.js"></script><script src="https://cdn.jsdelivr.net/npm/@xterm/addon-unicode11@0.10.0-beta.285/lib/addon-unicode11.js"></script><script src="https://cdn.jsdelivr.net/npm/@xterm/addon-webgl@0.20.0-beta.284/lib/addon-webgl.js"></script>
|
||||
<script>${XTERM_ENGINE_JS}</script>
|
||||
<script>
|
||||
(function() {
|
||||
var surface = document.getElementById('terminal-surface');
|
||||
@@ -214,6 +223,10 @@ export const XTERM_HTML = `<!DOCTYPE html>
|
||||
var afterDrainCallbacks = [];
|
||||
var termObserverDisposables = [];
|
||||
var ready = false;
|
||||
// Why: init() flips ready false on every re-init (live width reflow included)
|
||||
// while the old surface stays visible; a document-scoped latch drives the
|
||||
// fatal/non-fatal decision so a transient reflow cannot blank a live terminal.
|
||||
var everReady = false;
|
||||
var currentScale = 1;
|
||||
// Why: userScale is transient pinch zoom (CSS) for smooth feedback DURING a
|
||||
// gesture only; it resets to 1 on release. The persistent "text size" is the
|
||||
@@ -745,6 +758,7 @@ export const XTERM_HTML = `<!DOCTYPE html>
|
||||
requestAnimationFrame(function() {
|
||||
if (gen !== terminalGeneration) return;
|
||||
ready = true;
|
||||
everReady = true;
|
||||
afterWritesDrained(function() {
|
||||
if (gen !== terminalGeneration) return;
|
||||
if (nextSurface && oldSurface) {
|
||||
@@ -804,6 +818,47 @@ export const XTERM_HTML = `<!DOCTYPE html>
|
||||
}
|
||||
}
|
||||
|
||||
function engineErrorText(err) {
|
||||
if (!err) return '';
|
||||
if (typeof err === 'string') return err;
|
||||
if (err && typeof err.message === 'string') return err.message;
|
||||
try { return String(err); } catch (e) { return ''; }
|
||||
}
|
||||
|
||||
function chromeVersionText() {
|
||||
var match = String(navigator.userAgent || '').match(/(?:Chrome|Chromium)\\/([0-9.]+)/);
|
||||
return match ? 'Chrome ' + match[1] : 'Chrome version unknown';
|
||||
}
|
||||
|
||||
var nonFatalErrorNotifies = 0;
|
||||
|
||||
function reportEngineError(context, err, fatal) {
|
||||
var isFatal = fatal === undefined ? !everReady : !!fatal;
|
||||
if (!isFatal) {
|
||||
// Why: a constructed-but-degraded engine can throw per frame; cap
|
||||
// non-fatal notifies so RN isn't flooded. Fatal reports always emit.
|
||||
nonFatalErrorNotifies++;
|
||||
if (nonFatalErrorNotifies > 5) return;
|
||||
}
|
||||
var parts = [context];
|
||||
var errText = engineErrorText(err);
|
||||
if (errText) parts.push(errText);
|
||||
if (window.__engineErrors && window.__engineErrors.length) {
|
||||
parts.push('captured: ' + window.__engineErrors.join(' | '));
|
||||
}
|
||||
parts.push(chromeVersionText());
|
||||
notify({
|
||||
type: 'error',
|
||||
fatal: isFatal,
|
||||
message: parts.join(' - ')
|
||||
});
|
||||
}
|
||||
|
||||
window.onerror = function(msg, source, line, column, err) {
|
||||
if (window.__engineErrors.length < 20) window.__engineErrors.push(String(msg));
|
||||
reportEngineError('terminal runtime error', err || msg);
|
||||
};
|
||||
|
||||
function measureFitDimensions(containerHeightPx, retriesLeft) {
|
||||
if (typeof retriesLeft !== 'number') retriesLeft = 30;
|
||||
// Why: init and measure are posted back-to-back from React, but
|
||||
@@ -1801,17 +1856,27 @@ export const XTERM_HTML = `<!DOCTYPE html>
|
||||
|
||||
attachSurfaceEventHandlers(surface);
|
||||
|
||||
window.addEventListener('message', function(e) {
|
||||
function handleIncomingMessage(e) {
|
||||
var msg;
|
||||
try {
|
||||
handleMsg(typeof e.data === 'string' ? JSON.parse(e.data) : e.data);
|
||||
} catch(ex) {}
|
||||
});
|
||||
msg = typeof e.data === 'string' ? JSON.parse(e.data) : e.data;
|
||||
} catch (ex) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
handleMsg(msg);
|
||||
} catch(ex) {
|
||||
reportEngineError(
|
||||
msg && msg.type === 'init' ? 'terminal init failed' : 'terminal message failed',
|
||||
ex,
|
||||
msg && msg.type === 'init' && !everReady
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('message', function(e) {
|
||||
try {
|
||||
handleMsg(typeof e.data === 'string' ? JSON.parse(e.data) : e.data);
|
||||
} catch(ex) {}
|
||||
});
|
||||
window.addEventListener('message', handleIncomingMessage);
|
||||
|
||||
document.addEventListener('message', handleIncomingMessage);
|
||||
|
||||
window.addEventListener('resize', function() {
|
||||
// Why: viewport changed (keyboard open/close, orientation, RN container
|
||||
@@ -1828,9 +1893,13 @@ export const XTERM_HTML = `<!DOCTYPE html>
|
||||
if (window.Terminal) {
|
||||
notify({ type: 'web-ready' });
|
||||
} else {
|
||||
notify({ type: 'error', message: 'xterm failed to load' });
|
||||
reportEngineError('terminal engine missing', 'xterm failed to load', true);
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>`
|
||||
|
||||
// Why: WebView treats source identity as page identity on some platforms; keep
|
||||
// parent/session re-renders from reloading xterm and forcing fresh snapshots.
|
||||
export const XTERM_WEBVIEW_SOURCE = { html: XTERM_HTML }
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { useCallback, useEffect, useRef, type RefObject } from 'react'
|
||||
import { AppState } from 'react-native'
|
||||
|
||||
// Why: if the document dies before the glue can post anything (or the RN
|
||||
// message bridge never comes up), no webview error and no native handler
|
||||
// fires — without a native watchdog that failure is a silent blank pane.
|
||||
const WEB_READY_WATCHDOG_MS = 15000
|
||||
|
||||
export function useTerminalWebReadyWatchdog(
|
||||
isWebReadyRef: RefObject<boolean>,
|
||||
reportEngineError: (message: string, fatal: boolean) => void
|
||||
) {
|
||||
const watchdogRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
const clearWebReadyWatchdog = useCallback(() => {
|
||||
if (watchdogRef.current) {
|
||||
clearTimeout(watchdogRef.current)
|
||||
watchdogRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
const armWebReadyWatchdog = useCallback(() => {
|
||||
clearWebReadyWatchdog()
|
||||
const fire = () => {
|
||||
watchdogRef.current = null
|
||||
if (isWebReadyRef.current) {
|
||||
return
|
||||
}
|
||||
if (AppState.currentState !== 'active') {
|
||||
// Why: backgrounded WebViews legitimately stall; only judge foreground loads.
|
||||
watchdogRef.current = setTimeout(fire, WEB_READY_WATCHDOG_MS)
|
||||
return
|
||||
}
|
||||
reportEngineError(
|
||||
'Terminal did not initialize - no ready signal from the terminal view',
|
||||
true
|
||||
)
|
||||
}
|
||||
watchdogRef.current = setTimeout(fire, WEB_READY_WATCHDOG_MS)
|
||||
}, [clearWebReadyWatchdog, isWebReadyRef, reportEngineError])
|
||||
|
||||
useEffect(() => {
|
||||
armWebReadyWatchdog()
|
||||
return clearWebReadyWatchdog
|
||||
}, [armWebReadyWatchdog, clearWebReadyWatchdog])
|
||||
|
||||
return { armWebReadyWatchdog, clearWebReadyWatchdog }
|
||||
}
|
||||
@@ -14,7 +14,7 @@ function iifeSource(): string {
|
||||
|
||||
function bodyMarkup(): string {
|
||||
const start = XTERM_HTML.indexOf('<body>') + '<body>'.length
|
||||
const end = XTERM_HTML.indexOf('<script src=')
|
||||
const end = XTERM_HTML.indexOf('<script>', start)
|
||||
return XTERM_HTML.slice(start, end)
|
||||
}
|
||||
|
||||
|
||||
@@ -48,7 +48,7 @@ describe('TerminalWebView text zoom', () => {
|
||||
const end = terminalWebViewSource.indexOf('/>', start)
|
||||
expect(end).toBeGreaterThan(start)
|
||||
const webViewProps = terminalWebViewSource.slice(start, end)
|
||||
expect(terminalWebViewSource).toContain('const XTERM_WEBVIEW_SOURCE = { html: XTERM_HTML }')
|
||||
expect(terminalHtmlSource).toContain('export const XTERM_WEBVIEW_SOURCE = { html: XTERM_HTML }')
|
||||
expect(webViewProps).toContain('source={XTERM_WEBVIEW_SOURCE}')
|
||||
expect(webViewProps).not.toContain('source={{ html: XTERM_HTML }}')
|
||||
})
|
||||
@@ -113,8 +113,8 @@ describe('TerminalWebView text zoom', () => {
|
||||
})
|
||||
|
||||
it('loads Unicode 11 before replaying mobile terminal bytes', () => {
|
||||
expect(terminalHtmlSource).toContain('@xterm/xterm@6.1.0-beta.285')
|
||||
expect(terminalHtmlSource).toContain('@xterm/addon-unicode11@0.10.0-beta.285')
|
||||
expect(terminalHtmlSource).toContain('XTERM_ENGINE_JS')
|
||||
expect(terminalHtmlSource).toContain('window.Unicode11Addon.Unicode11Addon')
|
||||
const open = terminalHtmlSource.indexOf('term.open(surface)')
|
||||
const unicode = terminalHtmlSource.indexOf("term.unicode.activeVersion = '11'")
|
||||
const replay = terminalHtmlSource.indexOf('enqueueWrite(replayData)')
|
||||
@@ -123,8 +123,9 @@ describe('TerminalWebView text zoom', () => {
|
||||
expect(replay).toBeGreaterThan(unicode)
|
||||
})
|
||||
|
||||
it('uses the newer WebGL-capable xterm stack and desktop font fallbacks', () => {
|
||||
expect(terminalHtmlSource).toContain('@xterm/addon-webgl@0.20.0-beta.284')
|
||||
it('uses the bundled WebGL-capable xterm stack and desktop font fallbacks', () => {
|
||||
expect(terminalHtmlSource).not.toContain('cdn.jsdelivr.net')
|
||||
expect(terminalHtmlSource).toContain('window.WebglAddon.WebglAddon')
|
||||
expect(terminalHtmlSource).toContain('"SF Mono", "Menlo", "Monaco", "Cascadia Mono"')
|
||||
expect(terminalHtmlSource).toContain("fontWeight: '300'")
|
||||
expect(terminalHtmlSource).toContain("fontWeightBold: '500'")
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { getTerminalLiveAccessoryInactiveInputCommitResult } from './use-terminal-live-accessory-input-commit'
|
||||
import { createElement, type RefObject } from 'react'
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
|
||||
import type { TextInput } from 'react-native'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { TerminalLiveAccessoryInput } from './terminal-live-accessory-input'
|
||||
import type { TerminalLiveInputSender } from './terminal-live-input-sender'
|
||||
import {
|
||||
getTerminalLiveAccessoryInactiveInputCommitResult,
|
||||
useTerminalLiveAccessoryInputCommit,
|
||||
type TerminalLiveAccessoryInputCommitResult
|
||||
} from './use-terminal-live-accessory-input-commit'
|
||||
|
||||
type DeferredBoolean = {
|
||||
readonly promise: Promise<boolean>
|
||||
@@ -16,7 +25,111 @@ function createDeferredBoolean(): DeferredBoolean {
|
||||
return { promise, resolve: resolvePromise }
|
||||
}
|
||||
|
||||
describe('terminal live accessory input commit', () => {
|
||||
function suppressReactTestRendererDeprecationWarning(): () => void {
|
||||
const originalConsoleError = console.error
|
||||
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation((...args) => {
|
||||
const firstArg = args[0]
|
||||
if (typeof firstArg === 'string' && firstArg.includes('react-test-renderer is deprecated')) {
|
||||
return
|
||||
}
|
||||
originalConsoleError(...args)
|
||||
})
|
||||
return () => consoleErrorSpy.mockRestore()
|
||||
}
|
||||
|
||||
type AccessoryInputCommitHarnessOptions = {
|
||||
readonly heldText?: string
|
||||
readonly sentText?: string
|
||||
readonly pendingHandle?: string | null
|
||||
readonly sendResult?: boolean
|
||||
readonly flushResult?: boolean
|
||||
readonly waitResult?: boolean
|
||||
}
|
||||
|
||||
type AccessoryInputCommitHarness = {
|
||||
readonly commit: (
|
||||
input: TerminalLiveAccessoryInput
|
||||
) => Promise<TerminalLiveAccessoryInputCommitResult>
|
||||
readonly sent: readonly string[]
|
||||
readonly applyLiveInputMirror: ReturnType<typeof vi.fn>
|
||||
readonly flushPendingLiveInputText: ReturnType<typeof vi.fn>
|
||||
readonly waitForPendingLiveInputFlush: ReturnType<typeof vi.fn>
|
||||
readonly unmount: () => void
|
||||
}
|
||||
|
||||
function createAccessoryInputCommitHarness({
|
||||
heldText = '',
|
||||
sentText = '',
|
||||
pendingHandle = null,
|
||||
sendResult = true,
|
||||
flushResult = true,
|
||||
waitResult = true
|
||||
}: AccessoryInputCommitHarnessOptions = {}): AccessoryInputCommitHarness {
|
||||
const activeHandle = 'terminal-a'
|
||||
const heldLiveInputTextRef: RefObject<string> = { current: heldText }
|
||||
const sentLiveInputTextRef: RefObject<string> = { current: sentText }
|
||||
const pendingLiveInputHandleRef: RefObject<string | null> = { current: pendingHandle }
|
||||
const liveInputRef: RefObject<TextInput | null> = { current: null }
|
||||
const liveInputTerminalHandles = new Set([activeHandle])
|
||||
const sent: string[] = []
|
||||
const sendLiveTerminalInputRef: RefObject<TerminalLiveInputSender> = {
|
||||
current: async (_handle, bytes) => {
|
||||
sent.push(bytes)
|
||||
return sendResult
|
||||
}
|
||||
}
|
||||
const applyLiveInputMirror = vi.fn((_handle: string, _fieldText: string) => {})
|
||||
const clearPendingLiveInputCommit = vi.fn(() => {})
|
||||
const flushPendingLiveInputText = vi.fn(async (_expectedHandle: string | null) => flushResult)
|
||||
const waitForPendingLiveInputFlush = vi.fn(async () => waitResult)
|
||||
const setLiveInputCapture = vi.fn((_text: string) => {})
|
||||
|
||||
let commit: AccessoryInputCommitHarness['commit'] | null = null
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
|
||||
function Harness(): null {
|
||||
commit = useTerminalLiveAccessoryInputCommit({
|
||||
activeHandle,
|
||||
applyLiveInputMirror,
|
||||
clearPendingLiveInputCommit,
|
||||
flushPendingLiveInputText,
|
||||
heldLiveInputTextRef,
|
||||
liveInputRef,
|
||||
liveInputTerminalHandles,
|
||||
pendingLiveInputHandleRef,
|
||||
sentLiveInputTextRef,
|
||||
sendLiveTerminalInputRef,
|
||||
setLiveInputCapture,
|
||||
waitForPendingLiveInputFlush
|
||||
})
|
||||
return null
|
||||
}
|
||||
|
||||
const restoreConsoleError = suppressReactTestRendererDeprecationWarning()
|
||||
try {
|
||||
act(() => {
|
||||
renderer = create(createElement(Harness))
|
||||
})
|
||||
} finally {
|
||||
restoreConsoleError()
|
||||
}
|
||||
if (!commit || !renderer) {
|
||||
throw new Error('terminal live accessory input hook did not render')
|
||||
}
|
||||
|
||||
return {
|
||||
commit,
|
||||
sent,
|
||||
applyLiveInputMirror,
|
||||
flushPendingLiveInputText,
|
||||
waitForPendingLiveInputFlush,
|
||||
unmount: () => {
|
||||
act(() => renderer?.unmount())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('terminal live accessory inactive input commit result', () => {
|
||||
it('Given live input is disabled with an active flush When accessory raw fallback is requested Then waits before allowing raw send', async () => {
|
||||
// Given
|
||||
const deferredFlush = createDeferredBoolean()
|
||||
@@ -50,3 +163,68 @@ describe('terminal live accessory input commit', () => {
|
||||
expect(result).toEqual({ kind: 'suppress-raw' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('terminal live accessory input commit hook', () => {
|
||||
it('Given raw accessory bytes with a held syllable When committed Then flushes held text before sending bytes', async () => {
|
||||
// Given
|
||||
const harness = createAccessoryInputCommitHarness({
|
||||
heldText: '한',
|
||||
sentText: '',
|
||||
pendingHandle: 'terminal-a'
|
||||
})
|
||||
|
||||
// When
|
||||
const result = await harness.commit({ bytes: '\x1b' })
|
||||
|
||||
// Then
|
||||
expect(harness.flushPendingLiveInputText).toHaveBeenCalledWith('terminal-a')
|
||||
expect(harness.sent).toEqual(['\x1b'])
|
||||
expect(result).toEqual({ kind: 'handled' })
|
||||
})
|
||||
|
||||
it('Given raw accessory bytes with no held text When committed Then allows the raw send without flushing', async () => {
|
||||
// Given
|
||||
const harness = createAccessoryInputCommitHarness({ pendingHandle: null })
|
||||
|
||||
// When
|
||||
const result = await harness.commit({ bytes: '\x1b' })
|
||||
|
||||
// Then
|
||||
expect(result).toEqual({ kind: 'allow-raw' })
|
||||
expect(harness.flushPendingLiveInputText).not.toHaveBeenCalled()
|
||||
expect(harness.sent).toEqual([])
|
||||
})
|
||||
|
||||
it('Given accessory backspace with a held syllable When committed Then mirrors the emptied field without terminal bytes', async () => {
|
||||
// Given
|
||||
const harness = createAccessoryInputCommitHarness({
|
||||
heldText: '한',
|
||||
sentText: '',
|
||||
pendingHandle: 'terminal-a'
|
||||
})
|
||||
|
||||
// When
|
||||
const result = await harness.commit({ bytes: '\x7f', localEdit: 'backspace' })
|
||||
|
||||
// Then
|
||||
expect(harness.applyLiveInputMirror).toHaveBeenCalledWith('terminal-a', '')
|
||||
expect(result).toEqual({ kind: 'handled' })
|
||||
expect(harness.sent).toEqual([])
|
||||
})
|
||||
|
||||
it('Given accessory backspace with mirrored sent text When committed Then mirrors the shortened field so the diff emits DEL', async () => {
|
||||
// Given
|
||||
const harness = createAccessoryInputCommitHarness({
|
||||
heldText: '',
|
||||
sentText: 'ab',
|
||||
pendingHandle: 'terminal-a'
|
||||
})
|
||||
|
||||
// When
|
||||
const result = await harness.commit({ bytes: '\x7f', localEdit: 'backspace' })
|
||||
|
||||
// Then
|
||||
expect(harness.applyLiveInputMirror).toHaveBeenCalledWith('terminal-a', 'a')
|
||||
expect(result).toEqual({ kind: 'handled' })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,8 +2,7 @@ import { useCallback, type RefObject } from 'react'
|
||||
import type { TextInput } from 'react-native'
|
||||
import {
|
||||
getTerminalLiveAccessoryBytesDecision,
|
||||
getTerminalLiveAccessoryLocalEditText,
|
||||
getTerminalLiveDeferredTextDelayMs
|
||||
getTerminalLiveAccessoryLocalEditText
|
||||
} from './terminal-live-text-commit'
|
||||
import type { TerminalLiveAccessoryInput } from './terminal-live-accessory-input'
|
||||
import { sendTerminalLiveControlAfterPendingFlush } from './terminal-live-control-send-order'
|
||||
@@ -14,12 +13,6 @@ export type TerminalLiveAccessoryInputCommitResult =
|
||||
| { readonly kind: 'handled' }
|
||||
| { readonly kind: 'suppress-raw' }
|
||||
|
||||
type TerminalLiveInputCommitScheduler = (
|
||||
handle: string,
|
||||
text: string,
|
||||
delayMs: number | null
|
||||
) => void
|
||||
|
||||
export async function getTerminalLiveAccessoryInactiveInputCommitResult(
|
||||
waitForPendingLiveInputFlush: () => Promise<boolean>
|
||||
): Promise<TerminalLiveAccessoryInputCommitResult> {
|
||||
@@ -28,13 +21,14 @@ export async function getTerminalLiveAccessoryInactiveInputCommitResult(
|
||||
|
||||
type TerminalLiveAccessoryInputCommitOptions = {
|
||||
readonly activeHandle: string | null
|
||||
readonly applyLiveInputMirror: (handle: string, fieldText: string) => void
|
||||
readonly clearPendingLiveInputCommit: () => void
|
||||
readonly flushPendingLiveInputText: (expectedHandle: string | null) => Promise<boolean>
|
||||
readonly heldLiveInputTextRef: RefObject<string>
|
||||
readonly liveInputRef: RefObject<TextInput | null>
|
||||
readonly liveInputTerminalHandles: ReadonlySet<string>
|
||||
readonly pendingLiveInputHandleRef: RefObject<string | null>
|
||||
readonly pendingLiveInputTextRef: RefObject<string>
|
||||
readonly schedulePendingLiveInputCommit: TerminalLiveInputCommitScheduler
|
||||
readonly sentLiveInputTextRef: RefObject<string>
|
||||
readonly sendLiveTerminalInputRef: RefObject<TerminalLiveInputSender>
|
||||
readonly setLiveInputCapture: (text: string) => void
|
||||
readonly waitForPendingLiveInputFlush: () => Promise<boolean>
|
||||
@@ -42,13 +36,14 @@ type TerminalLiveAccessoryInputCommitOptions = {
|
||||
|
||||
export function useTerminalLiveAccessoryInputCommit({
|
||||
activeHandle,
|
||||
applyLiveInputMirror,
|
||||
clearPendingLiveInputCommit,
|
||||
flushPendingLiveInputText,
|
||||
heldLiveInputTextRef,
|
||||
liveInputRef,
|
||||
liveInputTerminalHandles,
|
||||
pendingLiveInputHandleRef,
|
||||
pendingLiveInputTextRef,
|
||||
schedulePendingLiveInputCommit,
|
||||
sentLiveInputTextRef,
|
||||
sendLiveTerminalInputRef,
|
||||
setLiveInputCapture,
|
||||
waitForPendingLiveInputFlush
|
||||
@@ -63,40 +58,33 @@ export function useTerminalLiveAccessoryInputCommit({
|
||||
if (!liveInputTerminalHandles.has(activeHandle)) {
|
||||
return getTerminalLiveAccessoryInactiveInputCommitResult(waitForPendingLiveInputFlush)
|
||||
}
|
||||
const pendingText =
|
||||
pendingLiveInputHandleRef.current === activeHandle ? pendingLiveInputTextRef.current : ''
|
||||
if (pendingLiveInputHandleRef.current && pendingLiveInputHandleRef.current !== activeHandle) {
|
||||
const ownsPendingState = pendingLiveInputHandleRef.current === activeHandle
|
||||
if (pendingLiveInputHandleRef.current && !ownsPendingState) {
|
||||
clearPendingLiveInputCommit()
|
||||
}
|
||||
const decision = getTerminalLiveAccessoryBytesDecision({ ...input, pendingText })
|
||||
const heldText = ownsPendingState ? heldLiveInputTextRef.current : ''
|
||||
const sentText = ownsPendingState ? sentLiveInputTextRef.current : ''
|
||||
const decision = getTerminalLiveAccessoryBytesDecision({ ...input, heldText, sentText })
|
||||
switch (decision.kind) {
|
||||
case 'send-now':
|
||||
// Why: raw accessory bytes must wait behind any in-flight IME text
|
||||
// flush so composed Hangul reaches the PTY before follow-up controls.
|
||||
// Why: raw accessory bytes must wait behind any in-flight mirror send
|
||||
// so composed Hangul reaches the PTY before follow-up controls.
|
||||
return (await waitForPendingLiveInputFlush())
|
||||
? { kind: 'allow-raw' }
|
||||
: { kind: 'suppress-raw' }
|
||||
case 'local-edit': {
|
||||
const editedText = getTerminalLiveAccessoryLocalEditText({
|
||||
localEdit: decision.localEdit,
|
||||
pendingText
|
||||
fieldText: sentText + heldText
|
||||
})
|
||||
if (editedText.length === 0) {
|
||||
clearPendingLiveInputCommit()
|
||||
return { kind: 'handled' }
|
||||
}
|
||||
// Why: accessory buttons do not emit native TextInput edits, so the
|
||||
// pending IME buffer must be edited and rescheduled here.
|
||||
// field is edited here and the mirror diff syncs the PTY echo.
|
||||
setLiveInputCapture(editedText)
|
||||
liveInputRef.current?.setNativeProps({ text: editedText })
|
||||
schedulePendingLiveInputCommit(
|
||||
activeHandle,
|
||||
editedText,
|
||||
getTerminalLiveDeferredTextDelayMs(editedText)
|
||||
)
|
||||
applyLiveInputMirror(activeHandle, editedText)
|
||||
return { kind: 'handled' }
|
||||
}
|
||||
case 'flush-then-send':
|
||||
case 'commit-held-then-send':
|
||||
await sendTerminalLiveControlAfterPendingFlush(
|
||||
() => flushPendingLiveInputText(activeHandle),
|
||||
() => sendLiveTerminalInputRef.current(activeHandle, decision.bytes)
|
||||
@@ -109,13 +97,14 @@ export function useTerminalLiveAccessoryInputCommit({
|
||||
},
|
||||
[
|
||||
activeHandle,
|
||||
applyLiveInputMirror,
|
||||
clearPendingLiveInputCommit,
|
||||
flushPendingLiveInputText,
|
||||
heldLiveInputTextRef,
|
||||
liveInputRef,
|
||||
liveInputTerminalHandles,
|
||||
pendingLiveInputHandleRef,
|
||||
pendingLiveInputTextRef,
|
||||
schedulePendingLiveInputCommit,
|
||||
sentLiveInputTextRef,
|
||||
sendLiveTerminalInputRef,
|
||||
setLiveInputCapture,
|
||||
waitForPendingLiveInputFlush
|
||||
|
||||
@@ -3,13 +3,14 @@ import { act, create, type ReactTestRenderer } from 'react-test-renderer'
|
||||
import type { TextInput } from 'react-native'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { TerminalLiveInputSender } from './terminal-live-input-sender'
|
||||
import { TERMINAL_LIVE_TEXT_COMMIT_DELAY_MS } from './terminal-live-text-commit'
|
||||
import { TERMINAL_LIVE_HELD_SYLLABLE_COMMIT_DELAY_MS } from './terminal-live-hangul-mirror'
|
||||
import { useTerminalLiveInputCommit } from './use-terminal-live-input-commit'
|
||||
|
||||
type TerminalLiveInputCommitHarness = {
|
||||
readonly captures: readonly string[]
|
||||
readonly handlers: ReturnType<typeof useTerminalLiveInputCommit<string>>
|
||||
readonly sent: readonly string[]
|
||||
readonly setActiveSessionTabType: (next: string | undefined) => void
|
||||
readonly unmount: () => void
|
||||
}
|
||||
|
||||
@@ -36,6 +37,9 @@ function createTerminalLiveInputCommitHarness({
|
||||
const activeHandleRef: RefObject<string | null> = { current: activeHandle }
|
||||
const activeSessionTabTypeRef: RefObject<string | null> = { current: 'terminal' }
|
||||
const captures: string[] = []
|
||||
const setLiveInputCapture = (text: string): void => {
|
||||
captures.push(text)
|
||||
}
|
||||
const liveInputRef: RefObject<TextInput | null> = { current: null }
|
||||
const liveInputTerminalHandles = new Set([activeHandle])
|
||||
const liveInputTerminalHandlesRef: RefObject<Set<string>> = {
|
||||
@@ -48,6 +52,9 @@ function createTerminalLiveInputCommitHarness({
|
||||
return sendResult
|
||||
}
|
||||
}
|
||||
// The hook keeps live-input state in refs, so a change handler alone never
|
||||
// re-renders; only a prop change (this variable) re-runs the pending-clear effect.
|
||||
let currentActiveSessionTabType: string | undefined = 'terminal'
|
||||
let handlers: ReturnType<typeof useTerminalLiveInputCommit<string>> | null = null
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
|
||||
@@ -55,13 +62,13 @@ function createTerminalLiveInputCommitHarness({
|
||||
handlers = useTerminalLiveInputCommit({
|
||||
activeHandle,
|
||||
activeHandleRef,
|
||||
activeSessionTabType: 'terminal',
|
||||
activeSessionTabType: currentActiveSessionTabType,
|
||||
activeSessionTabTypeRef,
|
||||
liveInputRef,
|
||||
liveInputTerminalHandles,
|
||||
liveInputTerminalHandlesRef,
|
||||
sendLiveTerminalInputRef,
|
||||
setLiveInputCapture: (text) => captures.push(text)
|
||||
setLiveInputCapture
|
||||
})
|
||||
return null
|
||||
}
|
||||
@@ -82,6 +89,15 @@ function createTerminalLiveInputCommitHarness({
|
||||
captures,
|
||||
handlers,
|
||||
sent,
|
||||
setActiveSessionTabType: (next: string | undefined): void => {
|
||||
currentActiveSessionTabType = next
|
||||
// Ref and prop derive from the same activeSessionTab in the real route, so
|
||||
// they go null together during tab-list lag — keep the harness coupled.
|
||||
activeSessionTabTypeRef.current = next ?? null
|
||||
act(() => {
|
||||
renderer?.update(createElement(Harness))
|
||||
})
|
||||
},
|
||||
unmount: () => {
|
||||
act(() => renderer?.unmount())
|
||||
}
|
||||
@@ -93,18 +109,48 @@ describe('terminal live input commit hook', () => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('Given Hangul pending text When the old idle window elapses Then does not send jamo to the terminal', async () => {
|
||||
it('Given Hangul composition When steps arrive Then streams the stable prefix and never leaks jamo', async () => {
|
||||
// Given
|
||||
vi.useFakeTimers()
|
||||
const { captures, handlers, sent } = createTerminalLiveInputCommitHarness()
|
||||
const { handlers, sent } = createTerminalLiveInputCommitHarness()
|
||||
|
||||
// When: ㅎ→하→한→한ㄱ→한그→한글 (no settle pause between steps)
|
||||
for (const fieldText of ['ㅎ', '하', '한', '한ㄱ', '한그', '한글']) {
|
||||
handlers.handleLiveInputChange(fieldText)
|
||||
await vi.advanceTimersByTimeAsync(50)
|
||||
}
|
||||
|
||||
// Then: only the stable prefix went out; the trailing syllable is held
|
||||
await vi.waitFor(() => expect(sent).toEqual(['한']))
|
||||
})
|
||||
|
||||
it('Given a held syllable When the settle timer elapses Then commits it to the terminal', async () => {
|
||||
// Given
|
||||
vi.useFakeTimers()
|
||||
const { handlers, sent } = createTerminalLiveInputCommitHarness()
|
||||
handlers.handleLiveInputChange('한')
|
||||
|
||||
// When
|
||||
handlers.handleLiveInputChange('ㅎ')
|
||||
await vi.advanceTimersByTimeAsync(1_000)
|
||||
await vi.advanceTimersByTimeAsync(TERMINAL_LIVE_HELD_SYLLABLE_COMMIT_DELAY_MS)
|
||||
|
||||
// Then
|
||||
expect(captures).toEqual(['ㅎ'])
|
||||
expect(sent).toEqual([])
|
||||
await vi.waitFor(() => expect(sent).toEqual(['한']))
|
||||
})
|
||||
|
||||
it('Given a timer-committed syllable When composition continues Then corrects with DEL and recommits', async () => {
|
||||
// Given
|
||||
vi.useFakeTimers()
|
||||
const { handlers, sent } = createTerminalLiveInputCommitHarness()
|
||||
handlers.handleLiveInputChange('하')
|
||||
await vi.advanceTimersByTimeAsync(TERMINAL_LIVE_HELD_SYLLABLE_COMMIT_DELAY_MS)
|
||||
await vi.waitFor(() => expect(sent).toEqual(['하']))
|
||||
|
||||
// When
|
||||
handlers.handleLiveInputChange('한')
|
||||
await vi.advanceTimersByTimeAsync(TERMINAL_LIVE_HELD_SYLLABLE_COMMIT_DELAY_MS)
|
||||
|
||||
// Then
|
||||
await vi.waitFor(() => expect(sent).toEqual(['하', '\x7f', '한']))
|
||||
})
|
||||
|
||||
it('Given Hangul pending text When submit is requested Then sends composed text before carriage return', async () => {
|
||||
@@ -119,6 +165,55 @@ describe('terminal live input commit hook', () => {
|
||||
await vi.waitFor(() => expect(sent).toEqual(['한', '\r']))
|
||||
})
|
||||
|
||||
it('Given no pending text When submit is requested Then sends only carriage return', async () => {
|
||||
// Given
|
||||
const { handlers, sent } = createTerminalLiveInputCommitHarness()
|
||||
|
||||
// When
|
||||
handlers.handleLiveInputSubmit()
|
||||
|
||||
// Then
|
||||
await vi.waitFor(() => expect(sent).toEqual(['\r']))
|
||||
})
|
||||
|
||||
it('Given a rejected held-text send When submit is requested Then suppresses the carriage return', async () => {
|
||||
// Given
|
||||
const { handlers, sent } = createTerminalLiveInputCommitHarness({ sendResult: false })
|
||||
handlers.handleLiveInputChange('한')
|
||||
|
||||
// When
|
||||
handlers.handleLiveInputSubmit()
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
|
||||
// Then: the held commit went out but was not accepted, so no \r follows
|
||||
await vi.waitFor(() => expect(sent).toEqual(['한']))
|
||||
})
|
||||
|
||||
it('Given ASCII typing When changes arrive Then mirrors immediately', async () => {
|
||||
// Given
|
||||
const { handlers, sent } = createTerminalLiveInputCommitHarness()
|
||||
|
||||
// When
|
||||
handlers.handleLiveInputChange('a')
|
||||
handlers.handleLiveInputChange('ab')
|
||||
|
||||
// Then
|
||||
await vi.waitFor(() => expect(sent).toEqual(['a', 'b']))
|
||||
})
|
||||
|
||||
it('Given a trailing space after Hangul When the change arrives Then the space commits the held syllable', async () => {
|
||||
// Given
|
||||
const { handlers, sent } = createTerminalLiveInputCommitHarness()
|
||||
handlers.handleLiveInputChange('한')
|
||||
|
||||
// When
|
||||
handlers.handleLiveInputChange('한 ')
|
||||
|
||||
// Then
|
||||
await vi.waitFor(() => expect(sent).toEqual(['한 ']))
|
||||
})
|
||||
|
||||
it('Given Hangul pending text When an external terminal send is requested Then flushes composed text first', async () => {
|
||||
// Given
|
||||
const { handlers, sent } = createTerminalLiveInputCommitHarness()
|
||||
@@ -145,34 +240,22 @@ describe('terminal live input commit hook', () => {
|
||||
expect(sent).toEqual(['한'])
|
||||
})
|
||||
|
||||
it('Given Chinese and Vietnamese IME text When the settle window elapses Then sends the committed text', async () => {
|
||||
it('Given non-Hangul IME text When changes arrive Then mirrors immediately without a settle window', async () => {
|
||||
// Given
|
||||
vi.useFakeTimers()
|
||||
const { captures, handlers, sent } = createTerminalLiveInputCommitHarness()
|
||||
const { handlers, sent } = createTerminalLiveInputCommitHarness()
|
||||
|
||||
// When
|
||||
handlers.handleLiveInputChange('你好')
|
||||
await vi.advanceTimersByTimeAsync(TERMINAL_LIVE_TEXT_COMMIT_DELAY_MS - 1)
|
||||
|
||||
// Then
|
||||
expect(captures).toEqual(['你好'])
|
||||
expect(sent).toEqual([])
|
||||
|
||||
// When
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
await vi.waitFor(() => expect(sent).toEqual(['你好']))
|
||||
handlers.handleLiveInputChange('tiếng Việt')
|
||||
await vi.advanceTimersByTimeAsync(TERMINAL_LIVE_TEXT_COMMIT_DELAY_MS)
|
||||
|
||||
// Then
|
||||
await vi.waitFor(() => expect(sent).toEqual(['你好', 'tiếng Việt']))
|
||||
})
|
||||
|
||||
it('Given deferred IME text When the hook unmounts Then cancels the pending commit timer', async () => {
|
||||
it('Given a held syllable When the hook unmounts Then cancels the settle timer', async () => {
|
||||
// Given
|
||||
vi.useFakeTimers()
|
||||
const { handlers, sent, unmount } = createTerminalLiveInputCommitHarness()
|
||||
handlers.handleLiveInputChange('é')
|
||||
handlers.handleLiveInputChange('한')
|
||||
|
||||
// When
|
||||
unmount()
|
||||
@@ -181,4 +264,54 @@ describe('terminal live input commit hook', () => {
|
||||
// Then
|
||||
expect(sent).toEqual([])
|
||||
})
|
||||
|
||||
it('Given Backspace with field text When the key arrives Then edits locally without terminal bytes', async () => {
|
||||
// Given
|
||||
const { handlers, sent } = createTerminalLiveInputCommitHarness()
|
||||
handlers.handleLiveInputChange('한')
|
||||
|
||||
// When
|
||||
handlers.handleLiveInputKeyPress({ nativeEvent: { key: 'Backspace' } })
|
||||
|
||||
// Then
|
||||
await vi.waitFor(() => expect(sent).toEqual([]))
|
||||
})
|
||||
|
||||
it('Given Tab with a held syllable When the key arrives Then commits the syllable before the tab bytes', async () => {
|
||||
// Given
|
||||
const { handlers, sent } = createTerminalLiveInputCommitHarness()
|
||||
handlers.handleLiveInputChange('한')
|
||||
|
||||
// When
|
||||
handlers.handleLiveInputKeyPress({ nativeEvent: { key: 'Tab' } })
|
||||
|
||||
// Then
|
||||
await vi.waitFor(() => expect(sent).toEqual(['한', '\t']))
|
||||
})
|
||||
|
||||
it('Given Hangul pending When the tab type lags to undefined Then keeps the composition state', async () => {
|
||||
// Given: '한' held while the active tab is still a terminal
|
||||
const { handlers, sent, setActiveSessionTabType } = createTerminalLiveInputCommitHarness()
|
||||
handlers.handleLiveInputChange('한')
|
||||
|
||||
// When: the mobile tab list momentarily yields no active tab object
|
||||
setActiveSessionTabType(undefined)
|
||||
handlers.handleLiveInputSubmit()
|
||||
|
||||
// Then: an unknown tab type is not "left the terminal", so pending still flushes
|
||||
await vi.waitFor(() => expect(sent).toEqual(['한', '\r']))
|
||||
})
|
||||
|
||||
it('Given Hangul pending When the tab genuinely changes to non-terminal Then clears the composition state', async () => {
|
||||
// Given: '한' held while the active tab is still a terminal
|
||||
const { handlers, sent, setActiveSessionTabType } = createTerminalLiveInputCommitHarness()
|
||||
handlers.handleLiveInputChange('한')
|
||||
|
||||
// When: the active tab actually becomes a non-terminal (chat) tab
|
||||
setActiveSessionTabType('chat')
|
||||
handlers.handleLiveInputSubmit()
|
||||
|
||||
// Then: pending was dropped, so submit sends only the carriage return
|
||||
await vi.waitFor(() => expect(sent).toEqual(['\r']))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
import { useCallback, useEffect, type RefObject } from 'react'
|
||||
import type { TextInput } from 'react-native'
|
||||
import {
|
||||
getTerminalLiveSpecialKeyDecision,
|
||||
getTerminalLiveSubmitSequence,
|
||||
getTerminalLiveTextChangeDecision
|
||||
} from './terminal-live-text-commit'
|
||||
import { getTerminalLiveSpecialKeyDecision } from './terminal-live-text-commit'
|
||||
import { sendTerminalLiveControlAfterPendingFlush } from './terminal-live-control-send-order'
|
||||
import type { TerminalLiveAccessoryInput } from './terminal-live-accessory-input'
|
||||
import type { TerminalLiveInputSender } from './terminal-live-input-sender'
|
||||
@@ -56,11 +52,12 @@ export function useTerminalLiveInputCommit<TTabType extends string>({
|
||||
setLiveInputCapture
|
||||
}: TerminalLiveInputCommitOptions<TTabType>): TerminalLiveInputCommitHandlers {
|
||||
const {
|
||||
applyLiveInputMirror,
|
||||
clearPendingLiveInputCommit,
|
||||
flushPendingLiveInputText,
|
||||
heldLiveInputTextRef,
|
||||
pendingLiveInputHandleRef,
|
||||
pendingLiveInputTextRef,
|
||||
schedulePendingLiveInputCommit,
|
||||
sentLiveInputTextRef,
|
||||
waitForPendingLiveInputFlush
|
||||
} = useTerminalLivePendingInputFlush({
|
||||
activeHandleRef,
|
||||
@@ -76,10 +73,13 @@ export function useTerminalLiveInputCommit<TTabType extends string>({
|
||||
if (!pendingHandle) {
|
||||
return
|
||||
}
|
||||
// Why: a lagging mobile tab list briefly yields no active tab object; a
|
||||
// null/undefined type is "unknown", not "left the terminal" — flush guards
|
||||
// still block sends if the tab truly changed.
|
||||
if (
|
||||
!activeHandle ||
|
||||
pendingHandle !== activeHandle ||
|
||||
activeSessionTabType !== 'terminal' ||
|
||||
(activeSessionTabType != null && activeSessionTabType !== 'terminal') ||
|
||||
!liveInputTerminalHandles.has(activeHandle)
|
||||
) {
|
||||
clearPendingLiveInputCommit()
|
||||
@@ -93,7 +93,9 @@ export function useTerminalLiveInputCommit<TTabType extends string>({
|
||||
clearPendingLiveInputCommit()
|
||||
return waitForPendingLiveInputFlush()
|
||||
}
|
||||
if (pendingHandle === handle && pendingLiveInputTextRef.current.length > 0) {
|
||||
// Why: external bytes (dictation/paste) land after the field's echo on the
|
||||
// PTY; the field session must fully end or later diffs would erase them.
|
||||
if (pendingHandle === handle) {
|
||||
return flushPendingLiveInputText(handle)
|
||||
}
|
||||
return waitForPendingLiveInputFlush()
|
||||
@@ -108,35 +110,15 @@ export function useTerminalLiveInputCommit<TTabType extends string>({
|
||||
return
|
||||
}
|
||||
const normalizedText = normalizeTerminalTextInput(text)
|
||||
const decision = getTerminalLiveTextChangeDecision(normalizedText)
|
||||
switch (decision.kind) {
|
||||
case 'ignore':
|
||||
clearPendingLiveInputCommit()
|
||||
return
|
||||
case 'send-now':
|
||||
clearPendingLiveInputCommit()
|
||||
void sendTerminalLiveControlAfterPendingFlush(waitForPendingLiveInputFlush, () =>
|
||||
sendLiveTerminalInputRef.current(activeHandle, decision.text)
|
||||
)
|
||||
return
|
||||
case 'defer':
|
||||
// Why: React Native does not expose composition events here, so keep
|
||||
// probable IME text in the native field until the commit timer settles.
|
||||
setLiveInputCapture(decision.text)
|
||||
schedulePendingLiveInputCommit(activeHandle, decision.text, decision.delayMs)
|
||||
return
|
||||
default:
|
||||
decision satisfies never
|
||||
}
|
||||
setLiveInputCapture(normalizedText)
|
||||
applyLiveInputMirror(activeHandle, normalizedText)
|
||||
},
|
||||
[
|
||||
activeHandle,
|
||||
applyLiveInputMirror,
|
||||
clearPendingLiveInputCommit,
|
||||
liveInputTerminalHandles,
|
||||
schedulePendingLiveInputCommit,
|
||||
sendLiveTerminalInputRef,
|
||||
setLiveInputCapture,
|
||||
waitForPendingLiveInputFlush
|
||||
setLiveInputCapture
|
||||
]
|
||||
)
|
||||
|
||||
@@ -145,26 +127,25 @@ export function useTerminalLiveInputCommit<TTabType extends string>({
|
||||
if (!activeHandle || !liveInputTerminalHandles.has(activeHandle)) {
|
||||
return
|
||||
}
|
||||
const pendingText =
|
||||
pendingLiveInputHandleRef.current === activeHandle ? pendingLiveInputTextRef.current : ''
|
||||
if (pendingLiveInputHandleRef.current && pendingLiveInputHandleRef.current !== activeHandle) {
|
||||
const ownsPendingState = pendingLiveInputHandleRef.current === activeHandle
|
||||
if (pendingLiveInputHandleRef.current && !ownsPendingState) {
|
||||
clearPendingLiveInputCommit()
|
||||
}
|
||||
const decision = getTerminalLiveSpecialKeyDecision({
|
||||
key: event.nativeEvent.key,
|
||||
pendingText
|
||||
heldText: ownsPendingState ? heldLiveInputTextRef.current : '',
|
||||
sentText: ownsPendingState ? sentLiveInputTextRef.current : ''
|
||||
})
|
||||
switch (decision.kind) {
|
||||
case 'ignore':
|
||||
case 'local-edit':
|
||||
return
|
||||
case 'send-now':
|
||||
clearPendingLiveInputCommit()
|
||||
void sendTerminalLiveControlAfterPendingFlush(waitForPendingLiveInputFlush, () =>
|
||||
sendLiveTerminalInputRef.current(activeHandle, decision.bytes)
|
||||
)
|
||||
return
|
||||
case 'flush-then-send':
|
||||
case 'commit-held-then-send':
|
||||
void sendTerminalLiveControlAfterPendingFlush(
|
||||
() => flushPendingLiveInputText(activeHandle),
|
||||
() => sendLiveTerminalInputRef.current(activeHandle, decision.bytes)
|
||||
@@ -186,13 +167,14 @@ export function useTerminalLiveInputCommit<TTabType extends string>({
|
||||
|
||||
const handleLiveInputAccessoryBytes = useTerminalLiveAccessoryInputCommit({
|
||||
activeHandle,
|
||||
applyLiveInputMirror,
|
||||
clearPendingLiveInputCommit,
|
||||
flushPendingLiveInputText,
|
||||
heldLiveInputTextRef,
|
||||
liveInputRef,
|
||||
liveInputTerminalHandles,
|
||||
pendingLiveInputHandleRef,
|
||||
pendingLiveInputTextRef,
|
||||
schedulePendingLiveInputCommit,
|
||||
sentLiveInputTextRef,
|
||||
sendLiveTerminalInputRef,
|
||||
setLiveInputCapture,
|
||||
waitForPendingLiveInputFlush
|
||||
@@ -202,28 +184,11 @@ export function useTerminalLiveInputCommit<TTabType extends string>({
|
||||
if (!activeHandle || !liveInputTerminalHandles.has(activeHandle)) {
|
||||
return
|
||||
}
|
||||
const pendingText =
|
||||
pendingLiveInputHandleRef.current === activeHandle ? pendingLiveInputTextRef.current : ''
|
||||
const sequence = getTerminalLiveSubmitSequence(pendingText)
|
||||
if (sequence.length === 2) {
|
||||
void sendTerminalLiveControlAfterPendingFlush(
|
||||
() => flushPendingLiveInputText(activeHandle),
|
||||
() => sendLiveTerminalInputRef.current(activeHandle, sequence[1])
|
||||
)
|
||||
return
|
||||
}
|
||||
clearPendingLiveInputCommit()
|
||||
void sendTerminalLiveControlAfterPendingFlush(waitForPendingLiveInputFlush, () =>
|
||||
sendLiveTerminalInputRef.current(activeHandle, sequence[0])
|
||||
void sendTerminalLiveControlAfterPendingFlush(
|
||||
() => flushPendingLiveInputText(activeHandle),
|
||||
() => sendLiveTerminalInputRef.current(activeHandle, '\r')
|
||||
)
|
||||
}, [
|
||||
activeHandle,
|
||||
clearPendingLiveInputCommit,
|
||||
flushPendingLiveInputText,
|
||||
liveInputTerminalHandles,
|
||||
sendLiveTerminalInputRef,
|
||||
waitForPendingLiveInputFlush
|
||||
])
|
||||
}, [activeHandle, flushPendingLiveInputText, liveInputTerminalHandles, sendLiveTerminalInputRef])
|
||||
|
||||
return {
|
||||
clearPendingLiveInputCommit,
|
||||
|
||||
@@ -2,7 +2,12 @@ import { useCallback, useEffect, useRef, type RefObject } from 'react'
|
||||
import type { TextInput } from 'react-native'
|
||||
import type { TerminalLiveInputSender } from './terminal-live-input-sender'
|
||||
import {
|
||||
queueTerminalLivePendingFlush,
|
||||
buildTerminalLiveMirrorPayload,
|
||||
computeTerminalLiveMirrorStep,
|
||||
TERMINAL_LIVE_HELD_SYLLABLE_COMMIT_DELAY_MS
|
||||
} from './terminal-live-hangul-mirror'
|
||||
import {
|
||||
queueTerminalLiveMirrorSend,
|
||||
waitForTerminalLivePendingFlush
|
||||
} from './terminal-live-pending-flush-state'
|
||||
|
||||
@@ -16,15 +21,12 @@ type TerminalLivePendingInputFlushOptions<TTabType extends string> = {
|
||||
}
|
||||
|
||||
type TerminalLivePendingInputFlush = {
|
||||
readonly applyLiveInputMirror: (handle: string, fieldText: string) => void
|
||||
readonly clearPendingLiveInputCommit: () => void
|
||||
readonly flushPendingLiveInputText: (expectedHandle: string | null) => Promise<boolean>
|
||||
readonly heldLiveInputTextRef: RefObject<string>
|
||||
readonly pendingLiveInputHandleRef: RefObject<string | null>
|
||||
readonly pendingLiveInputTextRef: RefObject<string>
|
||||
readonly schedulePendingLiveInputCommit: (
|
||||
handle: string,
|
||||
text: string,
|
||||
delayMs: number | null
|
||||
) => void
|
||||
readonly sentLiveInputTextRef: RefObject<string>
|
||||
readonly waitForPendingLiveInputFlush: () => Promise<boolean>
|
||||
}
|
||||
|
||||
@@ -36,104 +38,143 @@ export function useTerminalLivePendingInputFlush<TTabType extends string>({
|
||||
sendLiveTerminalInputRef,
|
||||
setLiveInputCapture
|
||||
}: TerminalLivePendingInputFlushOptions<TTabType>): TerminalLivePendingInputFlush {
|
||||
const liveInputCommitTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const heldCommitTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const pendingLiveInputFlushRef = useRef<Promise<boolean> | null>(null)
|
||||
const pendingLiveInputTextRef = useRef('')
|
||||
const heldLiveInputTextRef = useRef('')
|
||||
const sentLiveInputTextRef = useRef('')
|
||||
const pendingLiveInputHandleRef = useRef<string | null>(null)
|
||||
const runMirrorStepRef = useRef<
|
||||
(handle: string, fieldText: string, commitHeld: boolean) => Promise<boolean>
|
||||
>(async () => false)
|
||||
|
||||
const clearHeldCommitTimer = useCallback(() => {
|
||||
if (heldCommitTimerRef.current) {
|
||||
clearTimeout(heldCommitTimerRef.current)
|
||||
heldCommitTimerRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
const resetMirrorState = useCallback(() => {
|
||||
clearHeldCommitTimer()
|
||||
heldLiveInputTextRef.current = ''
|
||||
sentLiveInputTextRef.current = ''
|
||||
pendingLiveInputHandleRef.current = null
|
||||
}, [clearHeldCommitTimer])
|
||||
|
||||
const clearPendingLiveInputCommit = useCallback(() => {
|
||||
if (liveInputCommitTimerRef.current) {
|
||||
clearTimeout(liveInputCommitTimerRef.current)
|
||||
liveInputCommitTimerRef.current = null
|
||||
}
|
||||
pendingLiveInputTextRef.current = ''
|
||||
pendingLiveInputHandleRef.current = null
|
||||
resetMirrorState()
|
||||
setLiveInputCapture('')
|
||||
liveInputRef.current?.setNativeProps({ text: '' })
|
||||
}, [liveInputRef, setLiveInputCapture])
|
||||
}, [liveInputRef, resetMirrorState, setLiveInputCapture])
|
||||
|
||||
const waitForPendingLiveInputFlush = useCallback(async (): Promise<boolean> => {
|
||||
return waitForTerminalLivePendingFlush(pendingLiveInputFlushRef)
|
||||
}, [])
|
||||
|
||||
const flushPendingLiveInputText = useCallback(
|
||||
async (expectedHandle: string | null): Promise<boolean> => {
|
||||
const existingFlush = pendingLiveInputFlushRef.current
|
||||
if (liveInputCommitTimerRef.current) {
|
||||
clearTimeout(liveInputCommitTimerRef.current)
|
||||
liveInputCommitTimerRef.current = null
|
||||
}
|
||||
|
||||
const handle = pendingLiveInputHandleRef.current
|
||||
const text = pendingLiveInputTextRef.current
|
||||
pendingLiveInputHandleRef.current = null
|
||||
pendingLiveInputTextRef.current = ''
|
||||
setLiveInputCapture('')
|
||||
liveInputRef.current?.setNativeProps({ text: '' })
|
||||
|
||||
if (!handle || text.length === 0) {
|
||||
return existingFlush ?? false
|
||||
}
|
||||
const runMirrorStep = useCallback(
|
||||
async (handle: string, fieldText: string, commitHeld: boolean): Promise<boolean> => {
|
||||
if (
|
||||
(expectedHandle !== null && handle !== expectedHandle) ||
|
||||
handle !== activeHandleRef.current ||
|
||||
activeSessionTabTypeRef.current !== 'terminal' ||
|
||||
(activeSessionTabTypeRef.current != null &&
|
||||
activeSessionTabTypeRef.current !== 'terminal') ||
|
||||
!liveInputTerminalHandlesRef.current.has(handle)
|
||||
) {
|
||||
// Why: a stale handle must not keep local mirror state alive — the next
|
||||
// active terminal would inherit wrong erase counts. A null tab type is
|
||||
// "unknown" during tab-list lag, not "left the terminal", so it must not trip.
|
||||
resetMirrorState()
|
||||
return false
|
||||
}
|
||||
|
||||
return queueTerminalLivePendingFlush(pendingLiveInputFlushRef, () =>
|
||||
sendLiveTerminalInputRef.current(handle, text)
|
||||
const step = computeTerminalLiveMirrorStep(sentLiveInputTextRef.current, fieldText, {
|
||||
commitHeld
|
||||
})
|
||||
sentLiveInputTextRef.current = step.nextSentText
|
||||
heldLiveInputTextRef.current = step.heldText
|
||||
pendingLiveInputHandleRef.current =
|
||||
step.heldText.length > 0 || step.nextSentText.length > 0 ? handle : null
|
||||
|
||||
clearHeldCommitTimer()
|
||||
if (step.heldText.length > 0) {
|
||||
heldCommitTimerRef.current = setTimeout(() => {
|
||||
heldCommitTimerRef.current = null
|
||||
const heldField = sentLiveInputTextRef.current + heldLiveInputTextRef.current
|
||||
void runMirrorStepRef.current(handle, heldField, true)
|
||||
}, TERMINAL_LIVE_HELD_SYLLABLE_COMMIT_DELAY_MS)
|
||||
}
|
||||
|
||||
const payload = buildTerminalLiveMirrorPayload(step)
|
||||
if (payload.length === 0) {
|
||||
return waitForPendingLiveInputFlush()
|
||||
}
|
||||
return queueTerminalLiveMirrorSend(pendingLiveInputFlushRef, () =>
|
||||
sendLiveTerminalInputRef.current(handle, payload)
|
||||
)
|
||||
},
|
||||
[
|
||||
activeHandleRef,
|
||||
activeSessionTabTypeRef,
|
||||
liveInputRef,
|
||||
clearHeldCommitTimer,
|
||||
liveInputTerminalHandlesRef,
|
||||
resetMirrorState,
|
||||
sendLiveTerminalInputRef,
|
||||
setLiveInputCapture
|
||||
waitForPendingLiveInputFlush
|
||||
]
|
||||
)
|
||||
runMirrorStepRef.current = runMirrorStep
|
||||
|
||||
const schedulePendingLiveInputCommit = useCallback(
|
||||
(handle: string, text: string, delayMs: number | null) => {
|
||||
if (liveInputCommitTimerRef.current) {
|
||||
clearTimeout(liveInputCommitTimerRef.current)
|
||||
}
|
||||
pendingLiveInputHandleRef.current = handle
|
||||
pendingLiveInputTextRef.current = text
|
||||
if (delayMs === null) {
|
||||
liveInputCommitTimerRef.current = null
|
||||
return
|
||||
}
|
||||
liveInputCommitTimerRef.current = setTimeout(() => {
|
||||
liveInputCommitTimerRef.current = null
|
||||
void flushPendingLiveInputText(handle)
|
||||
}, delayMs)
|
||||
const applyLiveInputMirror = useCallback(
|
||||
(handle: string, fieldText: string): void => {
|
||||
void runMirrorStep(handle, fieldText, false)
|
||||
},
|
||||
[flushPendingLiveInputText]
|
||||
[runMirrorStep]
|
||||
)
|
||||
|
||||
const flushPendingLiveInputText = useCallback(
|
||||
async (expectedHandle: string | null): Promise<boolean> => {
|
||||
const handle = pendingLiveInputHandleRef.current
|
||||
if (!handle) {
|
||||
return waitForPendingLiveInputFlush()
|
||||
}
|
||||
if (expectedHandle !== null && handle !== expectedHandle) {
|
||||
clearPendingLiveInputCommit()
|
||||
return waitForPendingLiveInputFlush()
|
||||
}
|
||||
|
||||
const heldText = heldLiveInputTextRef.current
|
||||
const result =
|
||||
heldText.length > 0
|
||||
? await runMirrorStep(handle, sentLiveInputTextRef.current + heldText, true)
|
||||
: await waitForPendingLiveInputFlush()
|
||||
|
||||
// Why: an explicit flush ends the field's editing session; the echoed PTY
|
||||
// text stays, so local mirror state must restart from empty.
|
||||
clearPendingLiveInputCommit()
|
||||
return result
|
||||
},
|
||||
[clearPendingLiveInputCommit, runMirrorStep, waitForPendingLiveInputFlush]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (liveInputCommitTimerRef.current) {
|
||||
clearTimeout(liveInputCommitTimerRef.current)
|
||||
liveInputCommitTimerRef.current = null
|
||||
if (heldCommitTimerRef.current) {
|
||||
clearTimeout(heldCommitTimerRef.current)
|
||||
heldCommitTimerRef.current = null
|
||||
}
|
||||
heldLiveInputTextRef.current = ''
|
||||
sentLiveInputTextRef.current = ''
|
||||
pendingLiveInputHandleRef.current = null
|
||||
pendingLiveInputTextRef.current = ''
|
||||
pendingLiveInputFlushRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
return {
|
||||
applyLiveInputMirror,
|
||||
clearPendingLiveInputCommit,
|
||||
flushPendingLiveInputText,
|
||||
heldLiveInputTextRef,
|
||||
pendingLiveInputHandleRef,
|
||||
pendingLiveInputTextRef,
|
||||
schedulePendingLiveInputCommit,
|
||||
sentLiveInputTextRef,
|
||||
waitForPendingLiveInputFlush
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "orca",
|
||||
"version": "1.4.120",
|
||||
"version": "1.4.121-rc.5",
|
||||
"description": "Next-gen IDE for parallel agentic development",
|
||||
"homepage": "https://github.com/stablyai/orca",
|
||||
"author": "stablyai",
|
||||
|
||||
@@ -4,7 +4,7 @@ import { WORKTREE_ID_SEPARATOR } from '../../shared/worktree-id'
|
||||
|
||||
const {
|
||||
gitExecFileAsyncMock,
|
||||
getGitUsernameMock,
|
||||
resolveLocalGitUsernameMock,
|
||||
getSshGitUsernameMock,
|
||||
getSshGitProviderMock,
|
||||
generateBranchNameMock,
|
||||
@@ -14,7 +14,7 @@ const {
|
||||
getConfiguredBranchPrefixMock
|
||||
} = vi.hoisted(() => ({
|
||||
gitExecFileAsyncMock: vi.fn(),
|
||||
getGitUsernameMock: vi.fn(() => 'you'),
|
||||
resolveLocalGitUsernameMock: vi.fn(async () => 'you'),
|
||||
getSshGitUsernameMock: vi.fn(async () => 'you'),
|
||||
getSshGitProviderMock: vi.fn(() => undefined),
|
||||
generateBranchNameMock: vi.fn(),
|
||||
@@ -26,8 +26,10 @@ const {
|
||||
}))
|
||||
|
||||
vi.mock('../git/runner', () => ({ gitExecFileAsync: gitExecFileAsyncMock }))
|
||||
vi.mock('../git/repo', () => ({ getGitUsername: getGitUsernameMock }))
|
||||
vi.mock('../git/git-username', () => ({ getSshGitUsername: getSshGitUsernameMock }))
|
||||
vi.mock('../git/git-username', () => ({
|
||||
getSshGitUsername: getSshGitUsernameMock,
|
||||
resolveLocalGitUsername: resolveLocalGitUsernameMock
|
||||
}))
|
||||
vi.mock('../providers/ssh-git-dispatch', () => ({ getSshGitProvider: getSshGitProviderMock }))
|
||||
vi.mock('../text-generation/commit-message-text-generation', () => ({
|
||||
generateBranchNameFromContext: generateBranchNameMock,
|
||||
@@ -65,7 +67,7 @@ describe('maybeAutoRenameBranchOnFirstWork', () => {
|
||||
beforeEach(() => {
|
||||
resetFirstWorkBranchRenameState()
|
||||
vi.clearAllMocks()
|
||||
getGitUsernameMock.mockReturnValue('you')
|
||||
resolveLocalGitUsernameMock.mockResolvedValue('you')
|
||||
getSshGitUsernameMock.mockResolvedValue('you')
|
||||
getSshGitProviderMock.mockReturnValue(undefined)
|
||||
computeBranchNameMock.mockImplementation((leaf: string) => `you/${leaf}`)
|
||||
|
||||
@@ -15,8 +15,7 @@ import {
|
||||
import { getCommitMessageModelDiscoveryHostKey } from '../../shared/commit-message-host-key'
|
||||
import { computeBranchName, getConfiguredBranchPrefix } from '../ipc/worktree-logic'
|
||||
import { gitExecFileAsync } from '../git/runner'
|
||||
import { getGitUsername } from '../git/repo'
|
||||
import { getSshGitUsername } from '../git/git-username'
|
||||
import { getSshGitUsername, resolveLocalGitUsername } from '../git/git-username'
|
||||
import { getSshGitProvider } from '../providers/ssh-git-dispatch'
|
||||
import {
|
||||
branchHasUpstream,
|
||||
@@ -256,7 +255,7 @@ async function runAutoRename(
|
||||
|
||||
const username = provider
|
||||
? (await getSshGitUsername(provider, repo.path)) || null
|
||||
: getGitUsername(repo.path) || null
|
||||
: (await resolveLocalGitUsername(repo.path)) || null
|
||||
// The model is told not to add a prefix, but sometimes echoes the configured
|
||||
// one (e.g. `tmchow/...`); strip it so it doesn't double-prefix the branch or
|
||||
// leak into the display name.
|
||||
|
||||
@@ -96,6 +96,11 @@ describe('AntigravityHookService', () => {
|
||||
expect(script).toContain('payload=$(cat)')
|
||||
expect(script).toContain("payload='{}'")
|
||||
expect(script).not.toContain('if [ -z "$payload" ]; then\n exit 0\nfi')
|
||||
// Why: payload is piped to curl via stdin (`payload@-`) so it never lands
|
||||
// on the curl command line (EDR oversized-command-line false positive).
|
||||
expect(script).toContain('printf \'%s\' "$payload" | curl')
|
||||
expect(script).toContain('--data-urlencode "payload@-"')
|
||||
expect(script).not.toContain('--data-urlencode "payload=${payload}"')
|
||||
}
|
||||
expect(script).toContain('{"decision":""}')
|
||||
})
|
||||
|
||||
@@ -127,7 +127,10 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string {
|
||||
" payload='{}'",
|
||||
'fi',
|
||||
// Timeout caps best-effort hook posts if the local listener stalls.
|
||||
'curl -sS -X POST "http://127.0.0.1:${ORCA_AGENT_HOOK_PORT}/hook/antigravity" \\',
|
||||
// Why: pipe payload to curl's stdin (`payload@-`) instead of an inline
|
||||
// `payload=$VALUE` arg, so tens-of-KB tool output stays off the curl
|
||||
// command line (EDR command-line false positives). Wire body is identical.
|
||||
'printf \'%s\' "$payload" | curl -sS -X POST "http://127.0.0.1:${ORCA_AGENT_HOOK_PORT}/hook/antigravity" \\',
|
||||
' --connect-timeout 0.5 --max-time 1.5 \\',
|
||||
' -H "Content-Type: application/x-www-form-urlencoded" \\',
|
||||
' -H "X-Orca-Agent-Hook-Token: ${ORCA_AGENT_HOOK_TOKEN}" \\',
|
||||
@@ -138,7 +141,7 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string {
|
||||
' --data-urlencode "env=${ORCA_AGENT_HOOK_ENV}" \\',
|
||||
' --data-urlencode "version=${ORCA_AGENT_HOOK_VERSION}" \\',
|
||||
' --data-urlencode "hook_event_name=${ORCA_ANTIGRAVITY_EVENT}" \\',
|
||||
' --data-urlencode "payload=${payload}" >/dev/null 2>&1 || true',
|
||||
' --data-urlencode "payload@-" >/dev/null 2>&1 || true',
|
||||
'exit 0',
|
||||
''
|
||||
].join('\n')
|
||||
|
||||
@@ -19,10 +19,6 @@ vi.mock('electron', () => ({
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('../git/repo', () => ({
|
||||
getGitUsername: vi.fn().mockReturnValue('testuser')
|
||||
}))
|
||||
|
||||
vi.mock('./precheck-runner', () => ({
|
||||
runAutomationPrecheck: runAutomationPrecheckMock
|
||||
}))
|
||||
|
||||
@@ -19,10 +19,6 @@ vi.mock('electron', () => ({
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('../git/repo', () => ({
|
||||
getGitUsername: vi.fn().mockReturnValue('testuser')
|
||||
}))
|
||||
|
||||
async function createStore() {
|
||||
vi.resetModules()
|
||||
const { Store, initDataPath } = await import('../persistence')
|
||||
|
||||
@@ -105,11 +105,30 @@ describe('setupGuestContextMenu', () => {
|
||||
screenY: 375,
|
||||
pageUrl: 'https://test.dev/page',
|
||||
linkUrl: 'https://test.dev/link',
|
||||
selectionText: '',
|
||||
canGoBack: true,
|
||||
canGoForward: true
|
||||
})
|
||||
})
|
||||
|
||||
it('forwards the native selection text so the renderer can offer Copy', () => {
|
||||
const guest = makeGuest()
|
||||
const renderer = makeRenderer()
|
||||
|
||||
setupGuestContextMenu({
|
||||
browserTabId,
|
||||
guest,
|
||||
resolveRenderer: () => renderer
|
||||
})
|
||||
|
||||
triggerContextMenu(guest, { x: 10, y: 20, selectionText: 'copied selection' })
|
||||
|
||||
expect(rendererSendMock).toHaveBeenCalledWith(
|
||||
'browser:context-menu-requested',
|
||||
expect.objectContaining({ selectionText: 'copied selection' })
|
||||
)
|
||||
})
|
||||
|
||||
it('reads navigation state from navigationHistory', () => {
|
||||
const deprecatedCanGoBack = vi.fn(() => false)
|
||||
const deprecatedCanGoForward = vi.fn(() => false)
|
||||
|
||||
@@ -95,6 +95,11 @@ export function setupGuestContextMenu(args: {
|
||||
screenY: cursor.y,
|
||||
pageUrl,
|
||||
linkUrl,
|
||||
// Why: forward the native selection so the renderer can offer a Copy that
|
||||
// writes it to the clipboard directly, bypassing pages that suppress copy
|
||||
// via oncopy handlers (the reported bug — the selection is never re-read
|
||||
// through a page-visible copy event).
|
||||
selectionText: params.selectionText ?? '',
|
||||
...navigationState
|
||||
})
|
||||
}
|
||||
|
||||
@@ -106,6 +106,17 @@ describe('BrowserSessionRegistry', () => {
|
||||
expect(browserSessionRegistry.resolvePartition('nonexistent')).toBe(ORCA_BROWSER_PARTITION)
|
||||
})
|
||||
|
||||
it('strictly resolves known profile partitions without downgrading unknown profiles', () => {
|
||||
const profile = browserSessionRegistry.createProfile('isolated', 'Strict Resolve')
|
||||
expect(profile).not.toBeNull()
|
||||
|
||||
expect(browserSessionRegistry.resolveKnownPartition(null)).toBe(ORCA_BROWSER_PARTITION)
|
||||
expect(browserSessionRegistry.resolveKnownPartition(undefined)).toBe(ORCA_BROWSER_PARTITION)
|
||||
expect(browserSessionRegistry.resolveKnownPartition('default')).toBe(ORCA_BROWSER_PARTITION)
|
||||
expect(browserSessionRegistry.resolveKnownPartition(profile!.id)).toBe(profile!.partition)
|
||||
expect(browserSessionRegistry.resolveKnownPartition('missing-profile')).toBeNull()
|
||||
})
|
||||
|
||||
it('lists all profiles', () => {
|
||||
const before = browserSessionRegistry.listProfiles().length
|
||||
browserSessionRegistry.createProfile('isolated', 'List Test')
|
||||
|
||||
@@ -326,6 +326,13 @@ class BrowserSessionRegistry {
|
||||
return this.profiles.get(profileId)?.partition ?? ORCA_BROWSER_PARTITION
|
||||
}
|
||||
|
||||
resolveKnownPartition(profileId: string | null | undefined): string | null {
|
||||
if (!profileId) {
|
||||
return ORCA_BROWSER_PARTITION
|
||||
}
|
||||
return this.profiles.get(profileId)?.partition ?? null
|
||||
}
|
||||
|
||||
createProfile(scope: BrowserSessionProfileScope, label: string): BrowserSessionProfile | null {
|
||||
// Why: only the constructor may create the default profile. Allowing the
|
||||
// renderer to pass scope:'default' would create a second profile sharing
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest'
|
||||
import { buildPrintToPdfOptions, CdpPdfStreamStore } from './cdp-print-to-pdf'
|
||||
|
||||
const DEFAULT_MARGIN_INCHES = 1 / 2.54
|
||||
const TTL_MS = 5 * 60 * 1000
|
||||
|
||||
describe('buildPrintToPdfOptions', () => {
|
||||
it('returns empty options for empty params', () => {
|
||||
expect(buildPrintToPdfOptions({})).toEqual({})
|
||||
})
|
||||
|
||||
it('maps every well-formed field', () => {
|
||||
expect(
|
||||
buildPrintToPdfOptions({
|
||||
landscape: true,
|
||||
displayHeaderFooter: true,
|
||||
printBackground: true,
|
||||
preferCSSPageSize: true,
|
||||
generateTaggedPDF: true,
|
||||
generateDocumentOutline: false,
|
||||
scale: 1.5,
|
||||
paperWidth: 8.5,
|
||||
paperHeight: 11,
|
||||
marginTop: 0.25,
|
||||
marginBottom: 0.5,
|
||||
marginLeft: 0.75,
|
||||
marginRight: 1,
|
||||
pageRanges: '1-2',
|
||||
headerTemplate: '<span></span>',
|
||||
footerTemplate: '<span></span>'
|
||||
})
|
||||
).toEqual({
|
||||
landscape: true,
|
||||
displayHeaderFooter: true,
|
||||
printBackground: true,
|
||||
preferCSSPageSize: true,
|
||||
generateTaggedPDF: true,
|
||||
generateDocumentOutline: false,
|
||||
scale: 1.5,
|
||||
pageSize: { width: 8.5, height: 11 },
|
||||
margins: { marginType: 'custom', top: 0.25, bottom: 0.5, left: 0.75, right: 1 },
|
||||
pageRanges: '1-2',
|
||||
headerTemplate: '<span></span>',
|
||||
footerTemplate: '<span></span>'
|
||||
})
|
||||
})
|
||||
|
||||
it('defaults omitted margin sides to 1cm when any side is set', () => {
|
||||
expect(buildPrintToPdfOptions({ marginTop: 0.25 }).margins).toEqual({
|
||||
marginType: 'custom',
|
||||
top: 0.25,
|
||||
bottom: DEFAULT_MARGIN_INCHES,
|
||||
left: DEFAULT_MARGIN_INCHES,
|
||||
right: DEFAULT_MARGIN_INCHES
|
||||
})
|
||||
})
|
||||
|
||||
it('drops non-finite and out-of-range numeric options', () => {
|
||||
const options = buildPrintToPdfOptions({
|
||||
scale: 0,
|
||||
paperWidth: Number.POSITIVE_INFINITY,
|
||||
paperHeight: 11,
|
||||
marginTop: Number.NaN
|
||||
})
|
||||
expect(options.scale).toBeUndefined()
|
||||
expect(options.pageSize).toBeUndefined()
|
||||
expect(options.margins).toBeUndefined()
|
||||
})
|
||||
|
||||
it('requires both paper dimensions for pageSize', () => {
|
||||
expect(buildPrintToPdfOptions({ paperWidth: 8.5 }).pageSize).toBeUndefined()
|
||||
})
|
||||
|
||||
it('ignores wrong-typed options', () => {
|
||||
expect(buildPrintToPdfOptions({ landscape: 'yes', pageRanges: 7 })).toEqual({})
|
||||
})
|
||||
})
|
||||
|
||||
describe('CdpPdfStreamStore', () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('claims only its own handles', () => {
|
||||
const store = new CdpPdfStreamStore()
|
||||
const handle = store.create(Buffer.from('pdf'))
|
||||
|
||||
expect(store.ownsHandle({ handle })).toBe(true)
|
||||
expect(handle).toMatch(/^orca-pdf-[\da-f-]{36}-\d+$/)
|
||||
expect(store.ownsHandle({ handle: 'trace-stream' })).toBe(false)
|
||||
expect(store.ownsHandle({ handle: 'orca-pdf-forged-1' })).toBe(false)
|
||||
expect(store.ownsHandle({})).toBe(false)
|
||||
})
|
||||
|
||||
it('mints distinct handles per stream', () => {
|
||||
const store = new CdpPdfStreamStore()
|
||||
expect(store.create(Buffer.from('a'))).not.toBe(store.create(Buffer.from('b')))
|
||||
})
|
||||
|
||||
it('reads sequential chunks and reports eof', () => {
|
||||
const store = new CdpPdfStreamStore()
|
||||
const handle = store.create(Buffer.from('abcdef'))
|
||||
|
||||
expect(store.read({ handle, size: 2 })).toEqual({
|
||||
data: Buffer.from('ab').toString('base64'),
|
||||
eof: false
|
||||
})
|
||||
expect(store.read({ handle })).toEqual({
|
||||
data: Buffer.from('cdef').toString('base64'),
|
||||
eof: true
|
||||
})
|
||||
})
|
||||
|
||||
it('honors an explicit read offset', () => {
|
||||
const store = new CdpPdfStreamStore()
|
||||
const handle = store.create(Buffer.from('abcdef'))
|
||||
|
||||
expect(store.read({ handle, offset: 4 })).toEqual({
|
||||
data: Buffer.from('ef').toString('base64'),
|
||||
eof: true
|
||||
})
|
||||
})
|
||||
|
||||
it('returns an empty eof chunk when reading past the end', () => {
|
||||
const store = new CdpPdfStreamStore()
|
||||
const handle = store.create(Buffer.from('abc'))
|
||||
|
||||
expect(store.read({ handle, offset: 99 })).toEqual({ data: '', eof: true })
|
||||
})
|
||||
|
||||
it('returns null for unknown handles', () => {
|
||||
const store = new CdpPdfStreamStore()
|
||||
expect(store.read({ handle: 'nope' })).toBeNull()
|
||||
expect(store.read({})).toBeNull()
|
||||
})
|
||||
|
||||
it('drops a stream on close', () => {
|
||||
const store = new CdpPdfStreamStore()
|
||||
const handle = store.create(Buffer.from('abc'))
|
||||
|
||||
store.close({ handle })
|
||||
expect(store.read({ handle })).toBeNull()
|
||||
})
|
||||
|
||||
it('drops all streams on clear', () => {
|
||||
const store = new CdpPdfStreamStore()
|
||||
const handle = store.create(Buffer.from('abc'))
|
||||
|
||||
store.clear()
|
||||
expect(store.read({ handle })).toBeNull()
|
||||
})
|
||||
|
||||
it('evicts an abandoned stream after the TTL', () => {
|
||||
vi.useFakeTimers()
|
||||
const store = new CdpPdfStreamStore()
|
||||
const handle = store.create(Buffer.from('abc'))
|
||||
|
||||
vi.advanceTimersByTime(TTL_MS + 1)
|
||||
expect(store.read({ handle })).toBeNull()
|
||||
})
|
||||
|
||||
it('refreshes the TTL on each read', () => {
|
||||
vi.useFakeTimers()
|
||||
const store = new CdpPdfStreamStore()
|
||||
const handle = store.create(Buffer.from('abcdef'))
|
||||
|
||||
vi.advanceTimersByTime(TTL_MS - 1)
|
||||
expect(store.read({ handle, size: 1 })).not.toBeNull()
|
||||
vi.advanceTimersByTime(TTL_MS - 1)
|
||||
expect(store.read({ handle, size: 1 })).not.toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,177 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import type { PrintToPDFOptions } from 'electron'
|
||||
|
||||
const PDF_DEFAULT_MARGIN_INCHES = 1 / 2.54
|
||||
const PDF_STREAM_CHUNK_BYTES = 1024 * 1024
|
||||
const PDF_STREAM_HANDLE_PREFIX = 'orca-pdf-'
|
||||
const PDF_STREAM_TTL_MS = 5 * 60 * 1000
|
||||
|
||||
function finiteNumber(value: unknown): number | null {
|
||||
return typeof value === 'number' && Number.isFinite(value) ? value : null
|
||||
}
|
||||
|
||||
/**
|
||||
* Translate CDP `Page.printToPDF` params into Electron `printToPDF` options.
|
||||
* Only well-formed values are forwarded so a malformed param can never smuggle
|
||||
* NaN/Infinity into Electron; omitted margin sides default to CDP's 1cm.
|
||||
*/
|
||||
export function buildPrintToPdfOptions(params: Record<string, unknown>): PrintToPDFOptions {
|
||||
const options: PrintToPDFOptions = {}
|
||||
|
||||
if (typeof params.landscape === 'boolean') {
|
||||
options.landscape = params.landscape
|
||||
}
|
||||
if (typeof params.displayHeaderFooter === 'boolean') {
|
||||
options.displayHeaderFooter = params.displayHeaderFooter
|
||||
}
|
||||
if (typeof params.printBackground === 'boolean') {
|
||||
options.printBackground = params.printBackground
|
||||
}
|
||||
if (typeof params.preferCSSPageSize === 'boolean') {
|
||||
options.preferCSSPageSize = params.preferCSSPageSize
|
||||
}
|
||||
if (typeof params.generateTaggedPDF === 'boolean') {
|
||||
options.generateTaggedPDF = params.generateTaggedPDF
|
||||
}
|
||||
if (typeof params.generateDocumentOutline === 'boolean') {
|
||||
options.generateDocumentOutline = params.generateDocumentOutline
|
||||
}
|
||||
|
||||
const scale = finiteNumber(params.scale)
|
||||
if (scale !== null && scale > 0) {
|
||||
options.scale = scale
|
||||
}
|
||||
|
||||
const paperWidth = finiteNumber(params.paperWidth)
|
||||
const paperHeight = finiteNumber(params.paperHeight)
|
||||
if (paperWidth !== null && paperHeight !== null && paperWidth > 0 && paperHeight > 0) {
|
||||
options.pageSize = { width: paperWidth, height: paperHeight }
|
||||
}
|
||||
|
||||
const marginTop = finiteNumber(params.marginTop)
|
||||
const marginBottom = finiteNumber(params.marginBottom)
|
||||
const marginLeft = finiteNumber(params.marginLeft)
|
||||
const marginRight = finiteNumber(params.marginRight)
|
||||
if ([marginTop, marginBottom, marginLeft, marginRight].some((margin) => margin !== null)) {
|
||||
// CDP and Electron printToPDF both use inches; omitted CDP sides default to 1cm.
|
||||
options.margins = {
|
||||
marginType: 'custom',
|
||||
top: marginTop ?? PDF_DEFAULT_MARGIN_INCHES,
|
||||
bottom: marginBottom ?? PDF_DEFAULT_MARGIN_INCHES,
|
||||
left: marginLeft ?? PDF_DEFAULT_MARGIN_INCHES,
|
||||
right: marginRight ?? PDF_DEFAULT_MARGIN_INCHES
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof params.pageRanges === 'string') {
|
||||
options.pageRanges = params.pageRanges
|
||||
}
|
||||
if (typeof params.headerTemplate === 'string') {
|
||||
options.headerTemplate = params.headerTemplate
|
||||
}
|
||||
if (typeof params.footerTemplate === 'string') {
|
||||
options.footerTemplate = params.footerTemplate
|
||||
}
|
||||
|
||||
return options
|
||||
}
|
||||
|
||||
type PdfStream = {
|
||||
data: Buffer
|
||||
offset: number
|
||||
cleanupTimer: ReturnType<typeof setTimeout>
|
||||
}
|
||||
|
||||
export type PdfStreamChunk = {
|
||||
data: string
|
||||
eof: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Holds the PDF buffers produced for CDP `transferMode: "ReturnAsStream"` and
|
||||
* serves them back through `IO.read` / `IO.close`, the path Playwright's
|
||||
* `page.pdf()` uses. Handles carry a per-instance random prefix so a CDP client
|
||||
* cannot forge one or collide with a real Chromium IO stream, and each buffer is
|
||||
* evicted after a TTL so an abandoned stream can never leak memory.
|
||||
*/
|
||||
export class CdpPdfStreamStore {
|
||||
private readonly streams = new Map<string, PdfStream>()
|
||||
private readonly handlePrefix = `${PDF_STREAM_HANDLE_PREFIX}${randomUUID()}-`
|
||||
private nextId = 0
|
||||
|
||||
/** True when `params.handle` names one of this store's streams. */
|
||||
ownsHandle(params: Record<string, unknown>): boolean {
|
||||
return typeof params.handle === 'string' && params.handle.startsWith(this.handlePrefix)
|
||||
}
|
||||
|
||||
create(data: Buffer): string {
|
||||
const handle = `${this.handlePrefix}${++this.nextId}`
|
||||
this.streams.set(handle, {
|
||||
data,
|
||||
offset: 0,
|
||||
cleanupTimer: this.scheduleCleanup(handle)
|
||||
})
|
||||
return handle
|
||||
}
|
||||
|
||||
/** Read the next chunk, or `null` if the handle is unknown/expired. */
|
||||
read(params: Record<string, unknown>): PdfStreamChunk | null {
|
||||
const handle = typeof params.handle === 'string' ? params.handle : ''
|
||||
const stream = this.streams.get(handle)
|
||||
if (!stream) {
|
||||
return null
|
||||
}
|
||||
this.refreshCleanup(handle, stream)
|
||||
|
||||
const offset = finiteNumber(params.offset)
|
||||
if (offset !== null) {
|
||||
stream.offset = Math.max(0, Math.floor(offset))
|
||||
}
|
||||
const requestedSize = finiteNumber(params.size)
|
||||
const size =
|
||||
requestedSize !== null && requestedSize > 0
|
||||
? Math.floor(requestedSize)
|
||||
: PDF_STREAM_CHUNK_BYTES
|
||||
const start = Math.min(stream.offset, stream.data.length)
|
||||
const end = Math.min(start + size, stream.data.length)
|
||||
const chunk = stream.data.subarray(start, end)
|
||||
stream.offset = end
|
||||
|
||||
return { data: chunk.toString('base64'), eof: end >= stream.data.length }
|
||||
}
|
||||
|
||||
close(params: Record<string, unknown>): void {
|
||||
const handle = typeof params.handle === 'string' ? params.handle : ''
|
||||
this.delete(handle)
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
for (const stream of this.streams.values()) {
|
||||
clearTimeout(stream.cleanupTimer)
|
||||
}
|
||||
this.streams.clear()
|
||||
}
|
||||
|
||||
private scheduleCleanup(handle: string): ReturnType<typeof setTimeout> {
|
||||
const cleanupTimer = setTimeout(() => {
|
||||
this.delete(handle)
|
||||
}, PDF_STREAM_TTL_MS)
|
||||
const maybeNodeTimer = cleanupTimer as { unref?: () => void }
|
||||
maybeNodeTimer.unref?.()
|
||||
return cleanupTimer
|
||||
}
|
||||
|
||||
private refreshCleanup(handle: string, stream: PdfStream): void {
|
||||
clearTimeout(stream.cleanupTimer)
|
||||
stream.cleanupTimer = this.scheduleCleanup(handle)
|
||||
}
|
||||
|
||||
private delete(handle: string): void {
|
||||
const stream = this.streams.get(handle)
|
||||
if (!stream) {
|
||||
return
|
||||
}
|
||||
clearTimeout(stream.cleanupTimer)
|
||||
this.streams.delete(handle)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,394 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { CdpWsProxy } from './cdp-ws-proxy'
|
||||
import {
|
||||
connect,
|
||||
createMockWebContents,
|
||||
getSendCommandCalls,
|
||||
getSendCommandMethods,
|
||||
sendAndReceive,
|
||||
type MockWebContents
|
||||
} from './cdp-ws-proxy-test-harness'
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
webContents: { fromId: vi.fn() }
|
||||
}))
|
||||
|
||||
// Why: the proxy focuses the guest webContents natively before Input.insertText,
|
||||
// which blurs any element a prior DOM.focus targeted. These tests pin the replay
|
||||
// that re-applies that focus so text lands in the intended field.
|
||||
describe('CdpWsProxy DOM.focus replay', () => {
|
||||
let mock: MockWebContents
|
||||
let proxy: CdpWsProxy
|
||||
let endpoint: string
|
||||
|
||||
beforeEach(async () => {
|
||||
mock = createMockWebContents()
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
proxy = new CdpWsProxy(mock.webContents as any)
|
||||
endpoint = await proxy.start()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await proxy.stop()
|
||||
})
|
||||
|
||||
it('replays DOM.focus before Input.insertText in the root session', async () => {
|
||||
const client = await connect(endpoint)
|
||||
|
||||
const focusResponse = await sendAndReceive(client, {
|
||||
id: 14,
|
||||
method: 'DOM.focus',
|
||||
params: { backendNodeId: 99 }
|
||||
})
|
||||
const insertResponse = await sendAndReceive(client, {
|
||||
id: 15,
|
||||
method: 'Input.insertText',
|
||||
params: { text: 'hello' }
|
||||
})
|
||||
|
||||
expect(focusResponse.id).toBe(14)
|
||||
expect(insertResponse.id).toBe(15)
|
||||
expect(insertResponse.result).toEqual({})
|
||||
expect(mock.webContents.focus).toHaveBeenCalledTimes(1)
|
||||
expect(getSendCommandCalls(mock)).toEqual([
|
||||
['Page.enable', {}],
|
||||
['Page.addScriptToEvaluateOnNewDocument', expect.any(Object)],
|
||||
['DOM.focus', { backendNodeId: 99 }],
|
||||
['DOM.focus', { backendNodeId: 99 }],
|
||||
['Input.insertText', { text: 'hello' }]
|
||||
])
|
||||
client.close()
|
||||
})
|
||||
|
||||
it('replays DOM.focus before Input.insertText for OOPIF sessions', async () => {
|
||||
const client = await connect(endpoint)
|
||||
|
||||
await sendAndReceive(client, {
|
||||
id: 16,
|
||||
method: 'DOM.focus',
|
||||
params: { backendNodeId: 123 },
|
||||
sessionId: 'oopif-session-123'
|
||||
})
|
||||
const insertResponse = await sendAndReceive(client, {
|
||||
id: 17,
|
||||
method: 'Input.insertText',
|
||||
params: { text: 'frame text' },
|
||||
sessionId: 'oopif-session-123'
|
||||
})
|
||||
|
||||
expect(insertResponse.id).toBe(17)
|
||||
expect(insertResponse.result).toEqual({})
|
||||
expect(getSendCommandCalls(mock)).toEqual([
|
||||
['Page.enable', {}],
|
||||
['Page.addScriptToEvaluateOnNewDocument', expect.any(Object)],
|
||||
['DOM.focus', { backendNodeId: 123 }, 'oopif-session-123'],
|
||||
['DOM.focus', { backendNodeId: 123 }, 'oopif-session-123'],
|
||||
['Input.insertText', { text: 'frame text' }, 'oopif-session-123']
|
||||
])
|
||||
client.close()
|
||||
})
|
||||
|
||||
it('does not replay DOM.focus after adjacent eval traffic', async () => {
|
||||
const client = await connect(endpoint)
|
||||
|
||||
await sendAndReceive(client, {
|
||||
id: 18,
|
||||
method: 'DOM.focus',
|
||||
params: { backendNodeId: 44 }
|
||||
})
|
||||
await sendAndReceive(client, {
|
||||
id: 19,
|
||||
method: 'Runtime.callFunctionOn',
|
||||
params: { functionDeclaration: '() => document.activeElement?.id' }
|
||||
})
|
||||
const insertResponse = await sendAndReceive(client, {
|
||||
id: 20,
|
||||
method: 'Input.insertText',
|
||||
params: { text: 'after eval' }
|
||||
})
|
||||
|
||||
expect(insertResponse.id).toBe(20)
|
||||
expect(insertResponse.result).toEqual({})
|
||||
expect(mock.webContents.focus).toHaveBeenCalledTimes(1)
|
||||
expect(getSendCommandCalls(mock)).toEqual([
|
||||
['Page.enable', {}],
|
||||
['Page.addScriptToEvaluateOnNewDocument', expect.any(Object)],
|
||||
['DOM.focus', { backendNodeId: 44 }],
|
||||
['Runtime.callFunctionOn', { functionDeclaration: '() => document.activeElement?.id' }],
|
||||
['Input.insertText', { text: 'after eval' }]
|
||||
])
|
||||
client.close()
|
||||
})
|
||||
|
||||
it('does not replay a failed DOM.focus on the next Input.insertText', async () => {
|
||||
let domFocusAttempt = 0
|
||||
mock.webContents.debugger.sendCommand.mockImplementation(async (...args: unknown[]) => {
|
||||
const [method] = args as [string]
|
||||
if (method === 'DOM.focus') {
|
||||
domFocusAttempt += 1
|
||||
if (domFocusAttempt === 1) {
|
||||
throw new Error('Node not found')
|
||||
}
|
||||
}
|
||||
return {}
|
||||
})
|
||||
|
||||
const client = await connect(endpoint)
|
||||
|
||||
const focusResponse = await sendAndReceive(client, {
|
||||
id: 21,
|
||||
method: 'DOM.focus',
|
||||
params: { backendNodeId: 55 }
|
||||
})
|
||||
const insertResponse = await sendAndReceive(client, {
|
||||
id: 22,
|
||||
method: 'Input.insertText',
|
||||
params: { text: 'fallback' }
|
||||
})
|
||||
|
||||
expect(focusResponse).toEqual({
|
||||
id: 21,
|
||||
error: { code: -32000, message: 'Node not found' }
|
||||
})
|
||||
expect(insertResponse.id).toBe(22)
|
||||
expect(insertResponse.result).toEqual({})
|
||||
expect(mock.webContents.focus).toHaveBeenCalledTimes(1)
|
||||
expect(getSendCommandCalls(mock)).toEqual([
|
||||
['Page.enable', {}],
|
||||
['Page.addScriptToEvaluateOnNewDocument', expect.any(Object)],
|
||||
['DOM.focus', { backendNodeId: 55 }],
|
||||
['Input.insertText', { text: 'fallback' }]
|
||||
])
|
||||
client.close()
|
||||
})
|
||||
|
||||
it('returns the replay error when the stored DOM.focus fails before Input.insertText', async () => {
|
||||
let domFocusAttempt = 0
|
||||
mock.webContents.debugger.sendCommand.mockImplementation(async (...args: unknown[]) => {
|
||||
const [method] = args as [string]
|
||||
if (method === 'DOM.focus') {
|
||||
domFocusAttempt += 1
|
||||
if (domFocusAttempt === 2) {
|
||||
throw new Error('Focus target went stale')
|
||||
}
|
||||
}
|
||||
return {}
|
||||
})
|
||||
|
||||
const client = await connect(endpoint)
|
||||
|
||||
const focusResponse = await sendAndReceive(client, {
|
||||
id: 23,
|
||||
method: 'DOM.focus',
|
||||
params: { backendNodeId: 77 }
|
||||
})
|
||||
const insertResponse = await sendAndReceive(client, {
|
||||
id: 24,
|
||||
method: 'Input.insertText',
|
||||
params: { text: 'blocked' }
|
||||
})
|
||||
|
||||
expect(focusResponse.id).toBe(23)
|
||||
expect(focusResponse.result).toEqual({})
|
||||
expect(insertResponse).toEqual({
|
||||
id: 24,
|
||||
error: { code: -32000, message: 'Focus target went stale' }
|
||||
})
|
||||
expect(mock.webContents.focus).toHaveBeenCalledTimes(1)
|
||||
expect(getSendCommandCalls(mock)).toEqual([
|
||||
['Page.enable', {}],
|
||||
['Page.addScriptToEvaluateOnNewDocument', expect.any(Object)],
|
||||
['DOM.focus', { backendNodeId: 77 }],
|
||||
['DOM.focus', { backendNodeId: 77 }]
|
||||
])
|
||||
client.close()
|
||||
})
|
||||
|
||||
it('still replays DOM.focus when Input.insertText is dispatched while DOM.focus is still in flight', async () => {
|
||||
let resolveFocus: (v: Record<string, unknown>) => void
|
||||
const focusPromise = new Promise<Record<string, unknown>>((r) => {
|
||||
resolveFocus = r
|
||||
})
|
||||
mock.webContents.debugger.sendCommand.mockImplementation(async (...args: unknown[]) => {
|
||||
const [method] = args as [string]
|
||||
if (method === 'DOM.focus') {
|
||||
return focusPromise
|
||||
}
|
||||
return {}
|
||||
})
|
||||
|
||||
const client = await connect(endpoint)
|
||||
const responses: Record<string, unknown>[] = []
|
||||
client.on('message', (data) => {
|
||||
responses.push(JSON.parse(data.toString()))
|
||||
})
|
||||
|
||||
client.send(JSON.stringify({ id: 25, method: 'DOM.focus', params: { backendNodeId: 66 } }))
|
||||
await new Promise((r) => setTimeout(r, 10))
|
||||
// Why: dispatch the next message before the in-flight DOM.focus sendCommand
|
||||
// resolves, reproducing the pipelining race the fix closes.
|
||||
client.send(
|
||||
JSON.stringify({ id: 26, method: 'Input.insertText', params: { text: 'pipelined' } })
|
||||
)
|
||||
|
||||
await new Promise((r) => setTimeout(r, 20))
|
||||
resolveFocus!({})
|
||||
await new Promise((r) => setTimeout(r, 20))
|
||||
|
||||
expect(responses).toHaveLength(2)
|
||||
const focusResponse = responses.find((r) => r.id === 25)
|
||||
const insertResponse = responses.find((r) => r.id === 26)
|
||||
expect(focusResponse?.result).toEqual({})
|
||||
expect(insertResponse?.result).toEqual({})
|
||||
expect(getSendCommandMethods(mock)).toEqual([
|
||||
'Page.enable',
|
||||
'Page.addScriptToEvaluateOnNewDocument',
|
||||
'DOM.focus',
|
||||
'DOM.focus',
|
||||
'Input.insertText'
|
||||
])
|
||||
client.close()
|
||||
})
|
||||
|
||||
it('clears the pending DOM.focus replay when Page.bringToFront intervenes', async () => {
|
||||
const client = await connect(endpoint)
|
||||
|
||||
await sendAndReceive(client, {
|
||||
id: 27,
|
||||
method: 'DOM.focus',
|
||||
params: { backendNodeId: 88 }
|
||||
})
|
||||
await sendAndReceive(client, { id: 28, method: 'Page.bringToFront', params: {} })
|
||||
const insertResponse = await sendAndReceive(client, {
|
||||
id: 29,
|
||||
method: 'Input.insertText',
|
||||
params: { text: 'no replay' }
|
||||
})
|
||||
|
||||
expect(insertResponse.id).toBe(29)
|
||||
expect(insertResponse.result).toEqual({})
|
||||
// Why: both Page.bringToFront and Input.insertText natively call focus(),
|
||||
// independent of the (now-cleared) DOM.focus replay.
|
||||
expect(mock.webContents.focus).toHaveBeenCalledTimes(2)
|
||||
expect(getSendCommandMethods(mock)).toEqual([
|
||||
'Page.enable',
|
||||
'Page.addScriptToEvaluateOnNewDocument',
|
||||
'DOM.focus',
|
||||
'Input.insertText'
|
||||
])
|
||||
client.close()
|
||||
})
|
||||
|
||||
it('clears the pending DOM.focus replay when Page.captureScreenshot intervenes', async () => {
|
||||
const client = await connect(endpoint)
|
||||
|
||||
await sendAndReceive(client, {
|
||||
id: 30,
|
||||
method: 'DOM.focus',
|
||||
params: { backendNodeId: 91 }
|
||||
})
|
||||
await sendAndReceive(client, { id: 31, method: 'Page.captureScreenshot', params: {} })
|
||||
const insertResponse = await sendAndReceive(client, {
|
||||
id: 32,
|
||||
method: 'Input.insertText',
|
||||
params: { text: 'no replay after screenshot' }
|
||||
})
|
||||
|
||||
expect(insertResponse.id).toBe(32)
|
||||
expect(insertResponse.result).toEqual({})
|
||||
expect(getSendCommandMethods(mock)).toEqual([
|
||||
'Page.enable',
|
||||
'Page.addScriptToEvaluateOnNewDocument',
|
||||
'DOM.focus',
|
||||
'Page.captureScreenshot',
|
||||
'Input.insertText'
|
||||
])
|
||||
client.close()
|
||||
})
|
||||
|
||||
it('does not replay a pending DOM.focus across a client reconnect', async () => {
|
||||
const first = await connect(endpoint)
|
||||
await sendAndReceive(first, { id: 33, method: 'DOM.focus', params: { backendNodeId: 12 } })
|
||||
first.close()
|
||||
|
||||
// Why: a new client connection replaces the previous one; the stale focus
|
||||
// stored by the departed client must not leak into the new client's insert.
|
||||
const second = await connect(endpoint)
|
||||
const insertResponse = await sendAndReceive(second, {
|
||||
id: 34,
|
||||
method: 'Input.insertText',
|
||||
params: { text: 'fresh client' }
|
||||
})
|
||||
|
||||
expect(insertResponse.id).toBe(34)
|
||||
expect(insertResponse.result).toEqual({})
|
||||
expect(getSendCommandMethods(mock)).toEqual([
|
||||
'Page.enable',
|
||||
'Page.addScriptToEvaluateOnNewDocument',
|
||||
'DOM.focus',
|
||||
'Input.insertText'
|
||||
])
|
||||
second.close()
|
||||
})
|
||||
|
||||
it('does not replay or insert once the client disconnects mid-DOM.focus', async () => {
|
||||
let resolveFocus: (v: Record<string, unknown>) => void = () => {}
|
||||
mock.webContents.debugger.sendCommand.mockImplementation(async (...args: unknown[]) => {
|
||||
const [method] = args as [string]
|
||||
if (method === 'DOM.focus') {
|
||||
return new Promise<Record<string, unknown>>((resolve) => {
|
||||
resolveFocus = resolve
|
||||
})
|
||||
}
|
||||
return {}
|
||||
})
|
||||
|
||||
const client = await connect(endpoint)
|
||||
client.send(JSON.stringify({ id: 35, method: 'DOM.focus', params: { backendNodeId: 7 } }))
|
||||
await new Promise((r) => setTimeout(r, 10))
|
||||
// Pipeline the insert while DOM.focus is still in flight, then drop the client.
|
||||
client.send(JSON.stringify({ id: 36, method: 'Input.insertText', params: { text: 'gone' } }))
|
||||
await new Promise((r) => setTimeout(r, 10))
|
||||
client.close()
|
||||
await new Promise((r) => setTimeout(r, 10))
|
||||
|
||||
// Resolve the in-flight DOM.focus only after the client is gone.
|
||||
resolveFocus({})
|
||||
await new Promise((r) => setTimeout(r, 20))
|
||||
|
||||
// Why: a disconnected client's focus replay and insert must not reach the live page.
|
||||
const methods = getSendCommandMethods(mock)
|
||||
expect(methods.filter((m) => m === 'DOM.focus')).toHaveLength(1)
|
||||
expect(methods).not.toContain('Input.insertText')
|
||||
})
|
||||
|
||||
it('does not insert once the client disconnects during the DOM.focus replay', async () => {
|
||||
let domFocusCalls = 0
|
||||
let resolveReplay: (v: Record<string, unknown>) => void = () => {}
|
||||
mock.webContents.debugger.sendCommand.mockImplementation(async (...args: unknown[]) => {
|
||||
const [method] = args as [string]
|
||||
if (method === 'DOM.focus') {
|
||||
domFocusCalls += 1
|
||||
// Why: let the first DOM.focus resolve so a replay is queued, then hang the
|
||||
// replay so the client can disconnect while it is in flight.
|
||||
if (domFocusCalls === 2) {
|
||||
return new Promise<Record<string, unknown>>((resolve) => {
|
||||
resolveReplay = resolve
|
||||
})
|
||||
}
|
||||
}
|
||||
return {}
|
||||
})
|
||||
|
||||
const client = await connect(endpoint)
|
||||
await sendAndReceive(client, { id: 37, method: 'DOM.focus', params: { backendNodeId: 9 } })
|
||||
client.send(JSON.stringify({ id: 38, method: 'Input.insertText', params: { text: 'late' } }))
|
||||
await new Promise((r) => setTimeout(r, 10))
|
||||
client.close()
|
||||
await new Promise((r) => setTimeout(r, 10))
|
||||
resolveReplay({})
|
||||
await new Promise((r) => setTimeout(r, 20))
|
||||
|
||||
expect(getSendCommandMethods(mock)).not.toContain('Input.insertText')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,118 @@
|
||||
import { vi, type Mock } from 'vitest'
|
||||
import WebSocket from 'ws'
|
||||
|
||||
type DebuggerListener = (...args: unknown[]) => void
|
||||
|
||||
type MockDebugger = {
|
||||
isAttached: Mock<() => boolean>
|
||||
attach: Mock<() => void>
|
||||
detach: Mock<() => void>
|
||||
sendCommand: Mock<
|
||||
(
|
||||
method?: string,
|
||||
params?: Record<string, unknown>,
|
||||
sessionId?: string
|
||||
) => Promise<Record<string, unknown>>
|
||||
>
|
||||
on: Mock<(event: string, handler: DebuggerListener) => void>
|
||||
removeListener: Mock<(event: string, handler: DebuggerListener) => void>
|
||||
}
|
||||
|
||||
// Why: annotate the return explicitly so the exported inferred type stays nameable under
|
||||
// composite declaration emit — otherwise the vi.fn() mocks leak @vitest/spy's Procedure
|
||||
// and tsgo reports TS2883.
|
||||
export type MockWebContents = {
|
||||
webContents: {
|
||||
debugger: MockDebugger
|
||||
isDestroyed: () => boolean
|
||||
focus: Mock<() => void>
|
||||
printToPDF: Mock<() => Promise<Buffer>>
|
||||
reload: Mock<() => void>
|
||||
reloadIgnoringCache: Mock<() => void>
|
||||
getTitle: Mock<() => string>
|
||||
getURL: Mock<() => string>
|
||||
}
|
||||
listeners: Map<string, DebuggerListener[]>
|
||||
destroy: () => void
|
||||
emit: (event: string, ...args: unknown[]) => void
|
||||
}
|
||||
|
||||
export function createMockWebContents(): MockWebContents {
|
||||
const listeners = new Map<string, DebuggerListener[]>()
|
||||
let debuggerAttached = false
|
||||
let destroyed = false
|
||||
|
||||
const debuggerObj = {
|
||||
isAttached: vi.fn(() => debuggerAttached),
|
||||
attach: vi.fn(() => {
|
||||
debuggerAttached = true
|
||||
}),
|
||||
detach: vi.fn(() => {
|
||||
debuggerAttached = false
|
||||
}),
|
||||
sendCommand: vi.fn(
|
||||
async (_method?: string, _params?: Record<string, unknown>, _sessionId?: string) => ({})
|
||||
),
|
||||
on: vi.fn((event: string, handler: DebuggerListener) => {
|
||||
const arr = listeners.get(event) ?? []
|
||||
arr.push(handler)
|
||||
listeners.set(event, arr)
|
||||
}),
|
||||
removeListener: vi.fn((event: string, handler: DebuggerListener) => {
|
||||
const arr = listeners.get(event) ?? []
|
||||
listeners.set(
|
||||
event,
|
||||
arr.filter((h) => h !== handler)
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
webContents: {
|
||||
debugger: debuggerObj,
|
||||
isDestroyed: () => destroyed,
|
||||
focus: vi.fn(),
|
||||
printToPDF: vi.fn(async () => Buffer.from('%PDF-test')),
|
||||
reload: vi.fn(),
|
||||
reloadIgnoringCache: vi.fn(),
|
||||
getTitle: vi.fn(() => 'Example'),
|
||||
getURL: vi.fn(() => 'https://example.com')
|
||||
},
|
||||
listeners,
|
||||
destroy() {
|
||||
destroyed = true
|
||||
},
|
||||
emit(event: string, ...args: unknown[]) {
|
||||
for (const handler of listeners.get(event) ?? []) {
|
||||
handler(...args)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export type SendCommandCall = [string, Record<string, unknown>?, string?]
|
||||
|
||||
export function connect(endpoint: string): Promise<WebSocket> {
|
||||
return new Promise((resolve) => {
|
||||
const ws = new WebSocket(endpoint)
|
||||
ws.on('open', () => resolve(ws))
|
||||
})
|
||||
}
|
||||
|
||||
export function sendAndReceive(
|
||||
ws: WebSocket,
|
||||
msg: Record<string, unknown>
|
||||
): Promise<Record<string, unknown>> {
|
||||
return new Promise((resolve) => {
|
||||
ws.once('message', (data) => resolve(JSON.parse(data.toString())))
|
||||
ws.send(JSON.stringify(msg))
|
||||
})
|
||||
}
|
||||
|
||||
export function getSendCommandCalls(mock: MockWebContents): SendCommandCall[] {
|
||||
return mock.webContents.debugger.sendCommand.mock.calls as unknown as SendCommandCall[]
|
||||
}
|
||||
|
||||
export function getSendCommandMethods(mock: MockWebContents): string[] {
|
||||
return getSendCommandCalls(mock).map((call) => call[0])
|
||||
}
|
||||
@@ -1,67 +1,21 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import WebSocket from 'ws'
|
||||
import { CdpWsProxy } from './cdp-ws-proxy'
|
||||
import {
|
||||
connect,
|
||||
createMockWebContents,
|
||||
getSendCommandCalls,
|
||||
getSendCommandMethods,
|
||||
sendAndReceive,
|
||||
type MockWebContents
|
||||
} from './cdp-ws-proxy-test-harness'
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
webContents: { fromId: vi.fn() }
|
||||
}))
|
||||
|
||||
type DebuggerListener = (...args: unknown[]) => void
|
||||
|
||||
function createMockWebContents() {
|
||||
const listeners = new Map<string, DebuggerListener[]>()
|
||||
let debuggerAttached = false
|
||||
let destroyed = false
|
||||
|
||||
const debuggerObj = {
|
||||
isAttached: vi.fn(() => debuggerAttached),
|
||||
attach: vi.fn(() => {
|
||||
debuggerAttached = true
|
||||
}),
|
||||
detach: vi.fn(() => {
|
||||
debuggerAttached = false
|
||||
}),
|
||||
sendCommand: vi.fn(
|
||||
async (_method?: string, _params?: Record<string, unknown>, _sessionId?: string) => ({})
|
||||
),
|
||||
on: vi.fn((event: string, handler: DebuggerListener) => {
|
||||
const arr = listeners.get(event) ?? []
|
||||
arr.push(handler)
|
||||
listeners.set(event, arr)
|
||||
}),
|
||||
removeListener: vi.fn((event: string, handler: DebuggerListener) => {
|
||||
const arr = listeners.get(event) ?? []
|
||||
listeners.set(
|
||||
event,
|
||||
arr.filter((h) => h !== handler)
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
webContents: {
|
||||
debugger: debuggerObj,
|
||||
isDestroyed: () => destroyed,
|
||||
focus: vi.fn(),
|
||||
reload: vi.fn(),
|
||||
reloadIgnoringCache: vi.fn(),
|
||||
getTitle: vi.fn(() => 'Example'),
|
||||
getURL: vi.fn(() => 'https://example.com')
|
||||
},
|
||||
listeners,
|
||||
destroy() {
|
||||
destroyed = true
|
||||
},
|
||||
emit(event: string, ...args: unknown[]) {
|
||||
for (const handler of listeners.get(event) ?? []) {
|
||||
handler(...args)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('CdpWsProxy', () => {
|
||||
let mock: ReturnType<typeof createMockWebContents>
|
||||
let mock: MockWebContents
|
||||
let proxy: CdpWsProxy
|
||||
let endpoint: string
|
||||
|
||||
@@ -76,38 +30,14 @@ describe('CdpWsProxy', () => {
|
||||
await proxy.stop()
|
||||
})
|
||||
|
||||
function connect(): Promise<WebSocket> {
|
||||
return new Promise((resolve) => {
|
||||
const ws = new WebSocket(endpoint)
|
||||
ws.on('open', () => resolve(ws))
|
||||
})
|
||||
function expectPdfStreamHandle(response: Record<string, unknown>): string {
|
||||
const result = response.result as Record<string, unknown>
|
||||
expect(result.data).toBe('')
|
||||
expect(result.stream).toEqual(expect.stringMatching(/^orca-pdf-[\da-f-]{36}-\d+$/))
|
||||
return result.stream as string
|
||||
}
|
||||
|
||||
function sendAndReceive(
|
||||
ws: WebSocket,
|
||||
msg: Record<string, unknown>
|
||||
): Promise<Record<string, unknown>> {
|
||||
return new Promise((resolve) => {
|
||||
ws.once('message', (data) => resolve(JSON.parse(data.toString())))
|
||||
ws.send(JSON.stringify(msg))
|
||||
})
|
||||
}
|
||||
|
||||
type SendCommandCall = [string, Record<string, unknown>?, string?]
|
||||
|
||||
function getSendCommandCalls(): SendCommandCall[] {
|
||||
const calls = mock.webContents.debugger.sendCommand.mock.calls as unknown as [
|
||||
string,
|
||||
Record<string, unknown>?,
|
||||
string?
|
||||
][]
|
||||
return calls
|
||||
}
|
||||
|
||||
function getSendCommandMethods(): string[] {
|
||||
const calls = getSendCommandCalls()
|
||||
return calls.map((call) => call[0])
|
||||
}
|
||||
const defaultPdfMarginInches = 1 / 2.54
|
||||
|
||||
it('starts on a random port and returns ws:// URL', () => {
|
||||
expect(endpoint).toMatch(/^ws:\/\/127\.0\.0\.1:\d+$/)
|
||||
@@ -131,7 +61,7 @@ describe('CdpWsProxy', () => {
|
||||
it('correlates CDP request/response IDs', async () => {
|
||||
mock.webContents.debugger.sendCommand.mockResolvedValueOnce({ tree: 'nodes' })
|
||||
|
||||
const ws = connect()
|
||||
const ws = connect(endpoint)
|
||||
const client = await ws
|
||||
const response = await sendAndReceive(client, {
|
||||
id: 42,
|
||||
@@ -147,7 +77,7 @@ describe('CdpWsProxy', () => {
|
||||
it('returns error response when sendCommand fails', async () => {
|
||||
mock.webContents.debugger.sendCommand.mockRejectedValueOnce(new Error('Node not found'))
|
||||
|
||||
const client = await connect()
|
||||
const client = await connect(endpoint)
|
||||
const response = await sendAndReceive(client, {
|
||||
id: 7,
|
||||
method: 'DOM.describeNode',
|
||||
@@ -160,7 +90,7 @@ describe('CdpWsProxy', () => {
|
||||
})
|
||||
|
||||
it('returns an error instead of crashing when a command arrives after tab destruction', async () => {
|
||||
const client = await connect()
|
||||
const client = await connect(endpoint)
|
||||
mock.destroy()
|
||||
|
||||
const response = await sendAndReceive(client, {
|
||||
@@ -197,7 +127,7 @@ describe('CdpWsProxy', () => {
|
||||
})
|
||||
.mockResolvedValueOnce({ result: 'fast' })
|
||||
|
||||
const client = await connect()
|
||||
const client = await connect(endpoint)
|
||||
|
||||
const responses: Record<string, unknown>[] = []
|
||||
client.on('message', (data) => {
|
||||
@@ -232,11 +162,11 @@ describe('CdpWsProxy', () => {
|
||||
)
|
||||
.mockResolvedValueOnce({ result: 'new-client' })
|
||||
|
||||
const firstClient = await connect()
|
||||
const firstClient = await connect(endpoint)
|
||||
firstClient.send(JSON.stringify({ id: 1, method: 'DOM.enable', params: {} }))
|
||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||
|
||||
const secondClient = await connect()
|
||||
const secondClient = await connect(endpoint)
|
||||
const responses: Record<string, unknown>[] = []
|
||||
secondClient.on('message', (data) => {
|
||||
responses.push(JSON.parse(data.toString()))
|
||||
@@ -258,7 +188,7 @@ describe('CdpWsProxy', () => {
|
||||
it('forwards sessionId to sendCommand for OOPIF support', async () => {
|
||||
mock.webContents.debugger.sendCommand.mockResolvedValueOnce({})
|
||||
|
||||
const client = await connect()
|
||||
const client = await connect(endpoint)
|
||||
await sendAndReceive(client, {
|
||||
id: 1,
|
||||
method: 'DOM.enable',
|
||||
@@ -277,7 +207,7 @@ describe('CdpWsProxy', () => {
|
||||
// ── Event forwarding ──
|
||||
|
||||
it('forwards CDP events from debugger to client', async () => {
|
||||
const client = await connect()
|
||||
const client = await connect(endpoint)
|
||||
|
||||
const eventPromise = new Promise<Record<string, unknown>>((resolve) => {
|
||||
client.on('message', (data) => resolve(JSON.parse(data.toString())))
|
||||
@@ -292,7 +222,7 @@ describe('CdpWsProxy', () => {
|
||||
})
|
||||
|
||||
it('forwards sessionId in events when present', async () => {
|
||||
const client = await connect()
|
||||
const client = await connect(endpoint)
|
||||
|
||||
const eventPromise = new Promise<Record<string, unknown>>((resolve) => {
|
||||
client.on('message', (data) => resolve(JSON.parse(data.toString())))
|
||||
@@ -306,7 +236,7 @@ describe('CdpWsProxy', () => {
|
||||
})
|
||||
|
||||
it('does not focus the guest for Runtime.evaluate polling commands', async () => {
|
||||
const client = await connect()
|
||||
const client = await connect(endpoint)
|
||||
|
||||
await sendAndReceive(client, {
|
||||
id: 9,
|
||||
@@ -319,7 +249,7 @@ describe('CdpWsProxy', () => {
|
||||
})
|
||||
|
||||
it('still focuses the guest for Input.insertText', async () => {
|
||||
const client = await connect()
|
||||
const client = await connect(endpoint)
|
||||
|
||||
await sendAndReceive(client, {
|
||||
id: 10,
|
||||
@@ -328,11 +258,16 @@ describe('CdpWsProxy', () => {
|
||||
})
|
||||
|
||||
expect(mock.webContents.focus).toHaveBeenCalledTimes(1)
|
||||
expect(getSendCommandMethods(mock)).toEqual([
|
||||
'Page.enable',
|
||||
'Page.addScriptToEvaluateOnNewDocument',
|
||||
'Input.insertText'
|
||||
])
|
||||
client.close()
|
||||
})
|
||||
|
||||
it('primes lifecycle events for Page.navigate', async () => {
|
||||
const client = await connect()
|
||||
const client = await connect(endpoint)
|
||||
|
||||
const response = await sendAndReceive(client, {
|
||||
id: 11,
|
||||
@@ -342,7 +277,7 @@ describe('CdpWsProxy', () => {
|
||||
|
||||
expect(response.id).toBe(11)
|
||||
expect(response.result).toEqual({})
|
||||
expect(getSendCommandMethods()).toEqual([
|
||||
expect(getSendCommandMethods(mock)).toEqual([
|
||||
'Page.enable',
|
||||
'Page.addScriptToEvaluateOnNewDocument',
|
||||
'Network.enable',
|
||||
@@ -354,7 +289,7 @@ describe('CdpWsProxy', () => {
|
||||
})
|
||||
|
||||
it('primes lifecycle events for Page.reload and preserves response id', async () => {
|
||||
const client = await connect()
|
||||
const client = await connect(endpoint)
|
||||
|
||||
const response = await sendAndReceive(client, {
|
||||
id: 12,
|
||||
@@ -363,7 +298,7 @@ describe('CdpWsProxy', () => {
|
||||
|
||||
expect(response.id).toBe(12)
|
||||
expect(response.result).toEqual({})
|
||||
expect(getSendCommandMethods()).toEqual([
|
||||
expect(getSendCommandMethods(mock)).toEqual([
|
||||
'Page.enable',
|
||||
'Page.addScriptToEvaluateOnNewDocument',
|
||||
'Network.enable',
|
||||
@@ -371,12 +306,12 @@ describe('CdpWsProxy', () => {
|
||||
'Page.setLifecycleEventsEnabled'
|
||||
])
|
||||
expect(mock.webContents.reload).toHaveBeenCalledTimes(1)
|
||||
expect(getSendCommandMethods()).not.toContain('Page.reload')
|
||||
expect(getSendCommandMethods(mock)).not.toContain('Page.reload')
|
||||
client.close()
|
||||
})
|
||||
|
||||
it('preserves explicit Page.navigate session during lifecycle priming', async () => {
|
||||
const client = await connect()
|
||||
const client = await connect(endpoint)
|
||||
|
||||
await sendAndReceive(client, {
|
||||
id: 14,
|
||||
@@ -385,7 +320,7 @@ describe('CdpWsProxy', () => {
|
||||
sessionId: 'iframe-session-123'
|
||||
})
|
||||
|
||||
expect(getSendCommandCalls().slice(2)).toEqual([
|
||||
expect(getSendCommandCalls(mock).slice(2)).toEqual([
|
||||
['Network.enable', {}, 'iframe-session-123'],
|
||||
['Page.enable', {}, 'iframe-session-123'],
|
||||
['Page.setLifecycleEventsEnabled', { enabled: true }, 'iframe-session-123'],
|
||||
@@ -395,7 +330,7 @@ describe('CdpWsProxy', () => {
|
||||
})
|
||||
|
||||
it('forwards explicit Page.reload session after lifecycle priming', async () => {
|
||||
const client = await connect()
|
||||
const client = await connect(endpoint)
|
||||
|
||||
await sendAndReceive(client, {
|
||||
id: 15,
|
||||
@@ -404,7 +339,7 @@ describe('CdpWsProxy', () => {
|
||||
sessionId: 'iframe-session-123'
|
||||
})
|
||||
|
||||
expect(getSendCommandCalls().slice(2)).toEqual([
|
||||
expect(getSendCommandCalls(mock).slice(2)).toEqual([
|
||||
['Network.enable', {}, 'iframe-session-123'],
|
||||
['Page.enable', {}, 'iframe-session-123'],
|
||||
['Page.setLifecycleEventsEnabled', { enabled: true }, 'iframe-session-123'],
|
||||
@@ -416,7 +351,7 @@ describe('CdpWsProxy', () => {
|
||||
})
|
||||
|
||||
it('rejects root Page.reload params that direct webContents reload cannot honor', async () => {
|
||||
const client = await connect()
|
||||
const client = await connect(endpoint)
|
||||
|
||||
const response = await sendAndReceive(client, {
|
||||
id: 16,
|
||||
@@ -433,12 +368,12 @@ describe('CdpWsProxy', () => {
|
||||
})
|
||||
expect(mock.webContents.reload).not.toHaveBeenCalled()
|
||||
expect(mock.webContents.reloadIgnoringCache).not.toHaveBeenCalled()
|
||||
expect(getSendCommandMethods()).not.toContain('Network.enable')
|
||||
expect(getSendCommandMethods(mock)).not.toContain('Network.enable')
|
||||
client.close()
|
||||
})
|
||||
|
||||
it('still reloads when lifecycle priming stalls', async () => {
|
||||
const client = await connect()
|
||||
const client = await connect(endpoint)
|
||||
mock.webContents.debugger.sendCommand.mockImplementation((method?: string) => {
|
||||
if (method === 'Network.enable') {
|
||||
return new Promise(() => {})
|
||||
@@ -457,7 +392,7 @@ describe('CdpWsProxy', () => {
|
||||
})
|
||||
|
||||
it('does not reload after the requesting client disconnects during priming', async () => {
|
||||
const client = await connect()
|
||||
const client = await connect(endpoint)
|
||||
mock.webContents.debugger.sendCommand.mockImplementation((method?: string) => {
|
||||
if (method === 'Network.enable') {
|
||||
return new Promise(() => {})
|
||||
@@ -475,7 +410,7 @@ describe('CdpWsProxy', () => {
|
||||
})
|
||||
|
||||
it('forwards Runtime.evaluate without lifecycle priming', async () => {
|
||||
const client = await connect()
|
||||
const client = await connect(endpoint)
|
||||
|
||||
const response = await sendAndReceive(client, {
|
||||
id: 13,
|
||||
@@ -485,7 +420,7 @@ describe('CdpWsProxy', () => {
|
||||
|
||||
expect(response.id).toBe(13)
|
||||
expect(response.result).toEqual({})
|
||||
expect(getSendCommandMethods()).toEqual([
|
||||
expect(getSendCommandMethods(mock)).toEqual([
|
||||
'Page.enable',
|
||||
'Page.addScriptToEvaluateOnNewDocument',
|
||||
'Runtime.evaluate'
|
||||
@@ -493,12 +428,215 @@ describe('CdpWsProxy', () => {
|
||||
client.close()
|
||||
})
|
||||
|
||||
it('prints PDF data through native webContents printToPDF', async () => {
|
||||
const client = await connect(endpoint)
|
||||
|
||||
const response = await sendAndReceive(client, {
|
||||
id: 19,
|
||||
method: 'Page.printToPDF',
|
||||
params: {
|
||||
landscape: true,
|
||||
printBackground: true,
|
||||
paperWidth: 8.5,
|
||||
paperHeight: 11,
|
||||
marginTop: 0.25,
|
||||
marginBottom: 0.5,
|
||||
marginLeft: 0.75,
|
||||
marginRight: 1,
|
||||
pageRanges: '1-2',
|
||||
preferCSSPageSize: true
|
||||
}
|
||||
})
|
||||
|
||||
expect(response).toEqual({
|
||||
id: 19,
|
||||
result: { data: Buffer.from('%PDF-test').toString('base64') }
|
||||
})
|
||||
expect(mock.webContents.printToPDF).toHaveBeenCalledWith({
|
||||
landscape: true,
|
||||
printBackground: true,
|
||||
pageSize: { width: 8.5, height: 11 },
|
||||
margins: {
|
||||
marginType: 'custom',
|
||||
top: 0.25,
|
||||
bottom: 0.5,
|
||||
left: 0.75,
|
||||
right: 1
|
||||
},
|
||||
pageRanges: '1-2',
|
||||
preferCSSPageSize: true
|
||||
})
|
||||
expect(getSendCommandMethods(mock)).not.toContain('Page.printToPDF')
|
||||
client.close()
|
||||
})
|
||||
|
||||
it('keeps default PDF margins for omitted sides', async () => {
|
||||
const client = await connect(endpoint)
|
||||
|
||||
await sendAndReceive(client, {
|
||||
id: 20,
|
||||
method: 'Page.printToPDF',
|
||||
params: {
|
||||
marginTop: 0.25
|
||||
}
|
||||
})
|
||||
|
||||
expect(mock.webContents.printToPDF).toHaveBeenCalledWith({
|
||||
margins: {
|
||||
marginType: 'custom',
|
||||
top: 0.25,
|
||||
bottom: defaultPdfMarginInches,
|
||||
left: defaultPdfMarginInches,
|
||||
right: defaultPdfMarginInches
|
||||
}
|
||||
})
|
||||
client.close()
|
||||
})
|
||||
|
||||
it('supports streamed Page.printToPDF results for Playwright page.pdf', async () => {
|
||||
mock.webContents.printToPDF.mockResolvedValueOnce(Buffer.from('abcdef'))
|
||||
const client = await connect(endpoint)
|
||||
|
||||
const printResponse = await sendAndReceive(client, {
|
||||
id: 21,
|
||||
method: 'Page.printToPDF',
|
||||
params: { transferMode: 'ReturnAsStream' }
|
||||
})
|
||||
const handle = expectPdfStreamHandle(printResponse)
|
||||
|
||||
const firstRead = await sendAndReceive(client, {
|
||||
id: 22,
|
||||
method: 'IO.read',
|
||||
params: { handle, size: 2 }
|
||||
})
|
||||
const secondRead = await sendAndReceive(client, {
|
||||
id: 23,
|
||||
method: 'IO.read',
|
||||
params: { handle }
|
||||
})
|
||||
const closeResponse = await sendAndReceive(client, {
|
||||
id: 24,
|
||||
method: 'IO.close',
|
||||
params: { handle }
|
||||
})
|
||||
const readAfterClose = await sendAndReceive(client, {
|
||||
id: 25,
|
||||
method: 'IO.read',
|
||||
params: { handle }
|
||||
})
|
||||
|
||||
expect(printResponse.id).toBe(21)
|
||||
expect(firstRead).toEqual({
|
||||
id: 22,
|
||||
result: { base64Encoded: true, data: Buffer.from('ab').toString('base64'), eof: false }
|
||||
})
|
||||
expect(secondRead).toEqual({
|
||||
id: 23,
|
||||
result: { base64Encoded: true, data: Buffer.from('cdef').toString('base64'), eof: true }
|
||||
})
|
||||
expect(closeResponse).toEqual({ id: 24, result: {} })
|
||||
expect(readAfterClose).toEqual({
|
||||
id: 25,
|
||||
error: { code: -32000, message: 'Invalid stream handle' }
|
||||
})
|
||||
client.close()
|
||||
})
|
||||
|
||||
it('clears streamed PDF data when the client disconnects', async () => {
|
||||
mock.webContents.printToPDF.mockResolvedValueOnce(Buffer.from('abcdef'))
|
||||
const client = await connect(endpoint)
|
||||
|
||||
const printResponse = await sendAndReceive(client, {
|
||||
id: 26,
|
||||
method: 'Page.printToPDF',
|
||||
params: { transferMode: 'ReturnAsStream' }
|
||||
})
|
||||
const handle = expectPdfStreamHandle(printResponse)
|
||||
|
||||
expect(printResponse.id).toBe(26)
|
||||
client.close()
|
||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||
|
||||
const nextClient = await connect(endpoint)
|
||||
const staleRead = await sendAndReceive(nextClient, {
|
||||
id: 27,
|
||||
method: 'IO.read',
|
||||
params: { handle }
|
||||
})
|
||||
|
||||
expect(staleRead).toEqual({
|
||||
id: 27,
|
||||
error: { code: -32000, message: 'Invalid stream handle' }
|
||||
})
|
||||
nextClient.close()
|
||||
})
|
||||
|
||||
it('does not register a PDF stream when the client disconnects mid-print', async () => {
|
||||
let resolvePrint: (buf: Buffer<ArrayBuffer>) => void = () => {}
|
||||
mock.webContents.printToPDF.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<Buffer<ArrayBuffer>>((resolve) => {
|
||||
resolvePrint = resolve
|
||||
})
|
||||
)
|
||||
const store = (proxy as unknown as { pdfStreams: { create: (b: Buffer) => string } }).pdfStreams
|
||||
const createSpy = vi.spyOn(store, 'create')
|
||||
|
||||
const client = await connect(endpoint)
|
||||
client.send(
|
||||
JSON.stringify({
|
||||
id: 30,
|
||||
method: 'Page.printToPDF',
|
||||
params: { transferMode: 'ReturnAsStream' }
|
||||
})
|
||||
)
|
||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||
client.close()
|
||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||
|
||||
// Print resolves only after the client is gone: no stream must be created.
|
||||
resolvePrint(Buffer.from('%PDF-late'))
|
||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||
|
||||
expect(createSpy).not.toHaveBeenCalled()
|
||||
createSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('forwards non-PDF IO streams to the debugger', async () => {
|
||||
mock.webContents.debugger.sendCommand
|
||||
.mockResolvedValueOnce({ data: 'trace-data', eof: false })
|
||||
.mockResolvedValueOnce({})
|
||||
const client = await connect(endpoint)
|
||||
|
||||
const readResponse = await sendAndReceive(client, {
|
||||
id: 28,
|
||||
method: 'IO.read',
|
||||
params: { handle: 'trace-stream', size: 64 }
|
||||
})
|
||||
const closeResponse = await sendAndReceive(client, {
|
||||
id: 29,
|
||||
method: 'IO.close',
|
||||
params: { handle: 'trace-stream' }
|
||||
})
|
||||
|
||||
expect(readResponse).toEqual({ id: 28, result: { data: 'trace-data', eof: false } })
|
||||
expect(closeResponse).toEqual({ id: 29, result: {} })
|
||||
expect(mock.webContents.debugger.sendCommand).toHaveBeenCalledWith('IO.read', {
|
||||
handle: 'trace-stream',
|
||||
size: 64
|
||||
})
|
||||
expect(mock.webContents.debugger.sendCommand).toHaveBeenCalledWith('IO.close', {
|
||||
handle: 'trace-stream'
|
||||
})
|
||||
client.close()
|
||||
})
|
||||
|
||||
// ── Page.frameNavigated interception ──
|
||||
|
||||
// ── Cleanup ──
|
||||
|
||||
it('detaches debugger and closes server on stop', async () => {
|
||||
const client = await connect()
|
||||
const client = await connect(endpoint)
|
||||
await proxy.stop()
|
||||
|
||||
expect(mock.webContents.debugger.detach).toHaveBeenCalled()
|
||||
@@ -513,7 +651,7 @@ describe('CdpWsProxy', () => {
|
||||
})
|
||||
|
||||
it('detaches client websocket listeners after client close', async () => {
|
||||
const client = await connect()
|
||||
const client = await connect(endpoint)
|
||||
const serverClient = (proxy as unknown as { client: WebSocket | null }).client
|
||||
expect(serverClient).toBeTruthy()
|
||||
const offSpy = vi.spyOn(serverClient!, 'off')
|
||||
@@ -543,7 +681,7 @@ describe('CdpWsProxy', () => {
|
||||
})
|
||||
)
|
||||
|
||||
const client = await connect()
|
||||
const client = await connect(endpoint)
|
||||
client.send(JSON.stringify({ id: 1, method: 'Page.enable', params: {} }))
|
||||
|
||||
await new Promise((r) => setTimeout(r, 10))
|
||||
|
||||
@@ -3,12 +3,19 @@ import { WebSocketServer, WebSocket } from 'ws'
|
||||
import { createServer, type Server, type IncomingMessage, type ServerResponse } from 'node:http'
|
||||
import type { WebContents } from 'electron'
|
||||
import { captureScreenshot } from './cdp-screenshot'
|
||||
import { buildPrintToPdfOptions, CdpPdfStreamStore } from './cdp-print-to-pdf'
|
||||
import { ANTI_DETECTION_SCRIPT } from './anti-detection'
|
||||
import { acquireElectronDebugger, type ElectronDebuggerLease } from './electron-debugger-lease'
|
||||
|
||||
const LIFECYCLE_PRIMING_TIMEOUT_MS = 1_000
|
||||
|
||||
export class CdpWsProxy {
|
||||
// Why: holds each session's last DOM.focus params to replay right before the next
|
||||
// Input.insertText, countering the native webContents.focus() that would blur the target.
|
||||
private pendingDomFocusBySession = new Map<
|
||||
string | undefined,
|
||||
Promise<Record<string, unknown> | undefined>
|
||||
>()
|
||||
private httpServer: Server | null = null
|
||||
private wss: WebSocketServer | null = null
|
||||
private client: WebSocket | null = null
|
||||
@@ -20,6 +27,7 @@ export class CdpWsProxy {
|
||||
private attached = false
|
||||
// Why: agent-browser filters events by sessionId from Target.attachToTarget.
|
||||
private clientSessionId: string | undefined = undefined
|
||||
private readonly pdfStreams = new CdpPdfStreamStore()
|
||||
|
||||
constructor(private readonly webContents: WebContents) {}
|
||||
|
||||
@@ -51,6 +59,7 @@ export class CdpWsProxy {
|
||||
const onClose = (): void => {
|
||||
detach()
|
||||
if (this.client === ws) {
|
||||
this.pdfStreams.clear()
|
||||
this.client = null
|
||||
}
|
||||
}
|
||||
@@ -101,6 +110,9 @@ export class CdpWsProxy {
|
||||
this.detachClientListeners?.()
|
||||
this.detachClientListeners = null
|
||||
this.client = null
|
||||
// Why: a pending focus belongs to the departing client; never replay it for the next one.
|
||||
this.pendingDomFocusBySession.clear()
|
||||
this.pdfStreams.clear()
|
||||
client?.close()
|
||||
}
|
||||
|
||||
@@ -278,6 +290,12 @@ export class CdpWsProxy {
|
||||
)
|
||||
return
|
||||
}
|
||||
const effectiveSessionId = this.resolveDebuggerSessionId(msg.sessionId)
|
||||
// Why: a stored focus is only valid for the immediately following Input.insertText;
|
||||
// any other command may have moved DOM focus, so invalidate the replay in one place.
|
||||
if (msg.method !== 'DOM.focus' && msg.method !== 'Input.insertText') {
|
||||
this.pendingDomFocusBySession.delete(effectiveSessionId)
|
||||
}
|
||||
if (msg.method === 'Page.bringToFront') {
|
||||
if (!this.webContents.isDestroyed()) {
|
||||
this.webContents.focus()
|
||||
@@ -285,11 +303,39 @@ export class CdpWsProxy {
|
||||
this.sendResult(clientId, {}, client)
|
||||
return
|
||||
}
|
||||
if (msg.method === 'DOM.focus') {
|
||||
this.forwardDomFocus(client, clientId, msg.params ?? {}, effectiveSessionId)
|
||||
return
|
||||
}
|
||||
// Why: Page.captureScreenshot via debugger.sendCommand hangs on Electron webview guests.
|
||||
if (msg.method === 'Page.captureScreenshot') {
|
||||
this.handleScreenshot(client, clientId, msg.params)
|
||||
return
|
||||
}
|
||||
// Why: CDP Page.printToPDF is not available for Electron webview guests.
|
||||
// Electron's native printToPDF path is the reliable equivalent.
|
||||
if (msg.method === 'Page.printToPDF') {
|
||||
void this.handlePrintToPdf(client, clientId, msg.params ?? {})
|
||||
return
|
||||
}
|
||||
if (msg.method === 'IO.read') {
|
||||
const params = msg.params ?? {}
|
||||
if (this.pdfStreams.ownsHandle(params)) {
|
||||
this.handleStreamRead(client, clientId, params)
|
||||
return
|
||||
}
|
||||
this.forwardCommand(client, clientId, msg.method, params, msg.sessionId)
|
||||
return
|
||||
}
|
||||
if (msg.method === 'IO.close') {
|
||||
const params = msg.params ?? {}
|
||||
if (this.pdfStreams.ownsHandle(params)) {
|
||||
this.handleStreamClose(client, clientId, params)
|
||||
return
|
||||
}
|
||||
this.forwardCommand(client, clientId, msg.method, params, msg.sessionId)
|
||||
return
|
||||
}
|
||||
// Why: Input.insertText can still require native focus in Electron webviews.
|
||||
// Do not auto-focus generic Runtime.evaluate/callFunctionOn traffic: wait
|
||||
// polling and read-only JS probes use those methods heavily, and focusing on
|
||||
@@ -297,6 +343,8 @@ export class CdpWsProxy {
|
||||
// is running.
|
||||
if (msg.method === 'Input.insertText' && !this.webContents.isDestroyed()) {
|
||||
this.webContents.focus()
|
||||
void this.forwardInsertText(client, clientId, msg.params ?? {}, effectiveSessionId)
|
||||
return
|
||||
}
|
||||
// Why: agent-browser waits for network idle to detect navigation completion.
|
||||
// Electron webview CDP subscriptions silently lapse after cross-process swaps.
|
||||
@@ -439,6 +487,124 @@ export class CdpWsProxy {
|
||||
}
|
||||
}
|
||||
|
||||
// Why: this must stay synchronous up to the `.set()` call so the pending-focus
|
||||
// entry exists before the event loop can dispatch a pipelined Input.insertText
|
||||
// message, closing the race where the replay would otherwise be silently skipped.
|
||||
private forwardDomFocus(
|
||||
client: WebSocket,
|
||||
clientId: number,
|
||||
params: Record<string, unknown>,
|
||||
effectiveSessionId?: string
|
||||
): void {
|
||||
const focused = this.sendDomFocus(client, clientId, params, effectiveSessionId)
|
||||
this.pendingDomFocusBySession.set(effectiveSessionId, focused)
|
||||
}
|
||||
|
||||
private async sendDomFocus(
|
||||
client: WebSocket,
|
||||
clientId: number,
|
||||
params: Record<string, unknown>,
|
||||
effectiveSessionId?: string
|
||||
): Promise<Record<string, unknown> | undefined> {
|
||||
if (this.webContents.isDestroyed()) {
|
||||
this.sendError(clientId, 'Browser tab is no longer available', client)
|
||||
return undefined
|
||||
}
|
||||
try {
|
||||
const result = await this.sendDebuggerCommand('DOM.focus', params, effectiveSessionId)
|
||||
this.sendResult(clientId, result, client)
|
||||
return { ...params }
|
||||
} catch (err) {
|
||||
this.sendError(clientId, err instanceof Error ? err.message : String(err), client)
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
private async forwardInsertText(
|
||||
client: WebSocket,
|
||||
clientId: number,
|
||||
params: Record<string, unknown>,
|
||||
effectiveSessionId?: string
|
||||
): Promise<void> {
|
||||
const pendingFocus = this.pendingDomFocusBySession.get(effectiveSessionId)
|
||||
this.pendingDomFocusBySession.delete(effectiveSessionId)
|
||||
const pendingFocusParams = pendingFocus ? await pendingFocus : undefined
|
||||
// Why: the client can disconnect while DOM.focus is in flight; don't replay its
|
||||
// focus or forward its insert into the live page once it is no longer active.
|
||||
if (!this.isActiveClient(client)) {
|
||||
return
|
||||
}
|
||||
if (pendingFocusParams) {
|
||||
if (this.webContents.isDestroyed()) {
|
||||
this.sendError(clientId, 'Browser tab is no longer available', client)
|
||||
return
|
||||
}
|
||||
try {
|
||||
await this.sendDebuggerCommand('DOM.focus', pendingFocusParams, effectiveSessionId)
|
||||
} catch (err) {
|
||||
this.sendError(clientId, err instanceof Error ? err.message : String(err), client)
|
||||
return
|
||||
}
|
||||
// Why: the replay DOM.focus also awaited a round-trip; bail if the client vanished
|
||||
// during it so its insert never lands in the live page.
|
||||
if (!this.isActiveClient(client)) {
|
||||
return
|
||||
}
|
||||
}
|
||||
this.forwardCommand(client, clientId, 'Input.insertText', params, effectiveSessionId)
|
||||
}
|
||||
|
||||
private async handlePrintToPdf(
|
||||
client: WebSocket,
|
||||
clientId: number,
|
||||
params: Record<string, unknown>
|
||||
): Promise<void> {
|
||||
if (this.webContents.isDestroyed()) {
|
||||
this.sendError(clientId, 'Browser tab is no longer available', client)
|
||||
return
|
||||
}
|
||||
try {
|
||||
const pdf = await this.webContents.printToPDF(buildPrintToPdfOptions(params))
|
||||
// Why: printToPDF can resolve after the client disconnected (or was
|
||||
// replaced). Bail before registering a stream so its buffer isn't
|
||||
// orphaned in pdfStreams past the disconnect's clear() until the TTL.
|
||||
if (!this.isActiveClient(client)) {
|
||||
return
|
||||
}
|
||||
const buffer = Buffer.isBuffer(pdf) ? pdf : Buffer.from(pdf)
|
||||
if (params.transferMode === 'ReturnAsStream') {
|
||||
const handle = this.pdfStreams.create(buffer)
|
||||
this.sendResult(clientId, { data: '', stream: handle }, client)
|
||||
return
|
||||
}
|
||||
this.sendResult(clientId, { data: buffer.toString('base64') }, client)
|
||||
} catch (err) {
|
||||
this.sendError(clientId, err instanceof Error ? err.message : String(err), client)
|
||||
}
|
||||
}
|
||||
|
||||
private handleStreamRead(
|
||||
client: WebSocket,
|
||||
clientId: number,
|
||||
params: Record<string, unknown>
|
||||
): void {
|
||||
const chunk = this.pdfStreams.read(params)
|
||||
if (!chunk) {
|
||||
this.sendError(clientId, 'Invalid stream handle', client)
|
||||
return
|
||||
}
|
||||
this.sendResult(clientId, { base64Encoded: true, data: chunk.data, eof: chunk.eof }, client)
|
||||
}
|
||||
|
||||
private handleStreamClose(
|
||||
client: WebSocket,
|
||||
clientId: number,
|
||||
params: Record<string, unknown>
|
||||
): void {
|
||||
this.pdfStreams.close(params)
|
||||
this.sendResult(clientId, {}, client)
|
||||
}
|
||||
|
||||
private handleScreenshot(
|
||||
client: WebSocket,
|
||||
clientId: number,
|
||||
|
||||
@@ -269,6 +269,12 @@ describe('ClaudeHookService.installRemote', () => {
|
||||
const script = fs.files.get('/home/dev/.orca/agent-hooks/claude-hook.sh')
|
||||
expect(script).toContain('#!/bin/sh')
|
||||
expect(script).toContain('DEVIN_PROJECT_DIR')
|
||||
// Why: payload is piped to curl via stdin (`payload@-`) so it never lands
|
||||
// on the curl command line (EDR oversized-command-line false positive),
|
||||
// matching the Windows curl.exe hook post.
|
||||
expect(script).toContain('printf \'%s\' "$payload" | curl')
|
||||
expect(script).toContain('--data-urlencode "payload@-"')
|
||||
expect(script).not.toContain('--data-urlencode "payload=${payload}"')
|
||||
expect(fs.modes.get('/home/dev/.orca/agent-hooks/claude-hook.sh')).toBe(0o755)
|
||||
})
|
||||
|
||||
|
||||
@@ -113,7 +113,10 @@ function getManagedScript(
|
||||
// shell is not safe once a path contains quotes or newlines. Post the raw
|
||||
// hook payload plus metadata as form fields and let the receiver parse it.
|
||||
// Timeout caps best-effort hook posts if the local listener stalls.
|
||||
'curl -sS -X POST "http://127.0.0.1:${ORCA_AGENT_HOOK_PORT}/hook/claude" \\',
|
||||
// Why: pipe payload to curl's stdin (`payload@-`) instead of an inline
|
||||
// `payload=$VALUE` arg, so tens-of-KB tool output stays off the curl
|
||||
// command line (EDR command-line false positives). Wire body is identical.
|
||||
'printf \'%s\' "$payload" | curl -sS -X POST "http://127.0.0.1:${ORCA_AGENT_HOOK_PORT}/hook/claude" \\',
|
||||
' --connect-timeout 0.5 --max-time 1.5 \\',
|
||||
' -H "Content-Type: application/x-www-form-urlencoded" \\',
|
||||
' -H "X-Orca-Agent-Hook-Token: ${ORCA_AGENT_HOOK_TOKEN}" \\',
|
||||
@@ -123,7 +126,7 @@ function getManagedScript(
|
||||
' --data-urlencode "worktreeId=${ORCA_WORKTREE_ID}" \\',
|
||||
' --data-urlencode "env=${ORCA_AGENT_HOOK_ENV}" \\',
|
||||
' --data-urlencode "version=${ORCA_AGENT_HOOK_VERSION}" \\',
|
||||
' --data-urlencode "payload=${payload}" >/dev/null 2>&1 || true',
|
||||
' --data-urlencode "payload@-" >/dev/null 2>&1 || true',
|
||||
'exit 0',
|
||||
''
|
||||
].join('\n')
|
||||
|
||||
@@ -32,6 +32,6 @@ ELECTRON_RUN_AS_NODE=1 exec "$APPIMAGE" -e ${quoteShell(APPIMAGE_CLI_SCRIPT)} --
|
||||
`
|
||||
}
|
||||
|
||||
function quoteShell(value: string): string {
|
||||
export function quoteShell(value: string): string {
|
||||
return `'${value.replaceAll("'", `'"'"'`)}'`
|
||||
}
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
app: { isPackaged: true }
|
||||
}))
|
||||
|
||||
import { installLinuxBareOrcaDispatcher } from './linux-bare-orca-dispatcher'
|
||||
|
||||
const created: string[] = []
|
||||
|
||||
async function makeFixture(): Promise<{ homePath: string; resourcesPath: string }> {
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-bare-dispatcher-'))
|
||||
created.push(root)
|
||||
const resourcesPath = join(root, 'resources')
|
||||
// The bundled orca-ide launcher must exist for the dispatcher to be written.
|
||||
await mkdir(join(resourcesPath, 'bin'), { recursive: true })
|
||||
await writeFile(join(resourcesPath, 'bin', 'orca-ide'), '#!/usr/bin/env bash\n', 'utf8')
|
||||
return { homePath: join(root, 'home'), resourcesPath }
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(created.splice(0).map((dir) => rm(dir, { recursive: true, force: true })))
|
||||
})
|
||||
|
||||
describe('installLinuxBareOrcaDispatcher', () => {
|
||||
it('writes an executable bare-orca dispatcher that execs the bundled orca-ide launcher', async () => {
|
||||
const { homePath, resourcesPath } = await makeFixture()
|
||||
|
||||
const result = await installLinuxBareOrcaDispatcher({
|
||||
resourcesPath,
|
||||
homePath,
|
||||
appImagePath: null
|
||||
})
|
||||
|
||||
const expectedTarget = join(resourcesPath, 'bin', 'orca-ide')
|
||||
expect(result.state).toBe('installed')
|
||||
expect(result.target).toBe(expectedTarget)
|
||||
expect(result.dispatcherPath).toBe(join(homePath, '.local', 'bin', 'orca'))
|
||||
|
||||
const content = await readFile(result.dispatcherPath, 'utf8')
|
||||
expect(content).toContain('#!/usr/bin/env bash')
|
||||
// Single-quoted so a resources path with shell metacharacters can't break out.
|
||||
expect(content).toContain(`exec '${expectedTarget}' "$@"`)
|
||||
|
||||
const mode = (await stat(result.dispatcherPath)).mode & 0o777
|
||||
expect(mode & 0o111).not.toBe(0)
|
||||
})
|
||||
|
||||
it('is idempotent — a second install rewrites its own dispatcher without throwing', async () => {
|
||||
const { homePath, resourcesPath } = await makeFixture()
|
||||
|
||||
const first = await installLinuxBareOrcaDispatcher({
|
||||
resourcesPath,
|
||||
homePath,
|
||||
appImagePath: null
|
||||
})
|
||||
const second = await installLinuxBareOrcaDispatcher({
|
||||
resourcesPath,
|
||||
homePath,
|
||||
appImagePath: null
|
||||
})
|
||||
|
||||
expect(second).toEqual(first)
|
||||
expect(second.state).toBe('installed')
|
||||
})
|
||||
|
||||
it('quotes a resources path containing spaces so the exec line cannot be split', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-bare-dispatcher-space-'))
|
||||
created.push(root)
|
||||
const resourcesPath = join(root, 'App Support', 'resources')
|
||||
await mkdir(join(resourcesPath, 'bin'), { recursive: true })
|
||||
await writeFile(join(resourcesPath, 'bin', 'orca-ide'), '#!/usr/bin/env bash\n', 'utf8')
|
||||
|
||||
const result = await installLinuxBareOrcaDispatcher({
|
||||
resourcesPath,
|
||||
homePath: join(root, 'home'),
|
||||
appImagePath: null
|
||||
})
|
||||
|
||||
const content = await readFile(result.dispatcherPath, 'utf8')
|
||||
expect(content).toContain(`exec '${join(resourcesPath, 'bin', 'orca-ide')}' "$@"`)
|
||||
})
|
||||
|
||||
it('execs the stable AppImage (not the ephemeral mount) when running from an AppImage', async () => {
|
||||
const { homePath, resourcesPath } = await makeFixture()
|
||||
const appImagePath = join(homePath, 'Applications', 'Orca.AppImage')
|
||||
|
||||
const result = await installLinuxBareOrcaDispatcher({ resourcesPath, homePath, appImagePath })
|
||||
|
||||
expect(result.state).toBe('installed')
|
||||
expect(result.target).toBe(appImagePath)
|
||||
const content = await readFile(result.dispatcherPath, 'utf8')
|
||||
// The AppImage wrapper references the stable outer path, never resourcesPath.
|
||||
expect(content).toContain(appImagePath)
|
||||
expect(content).not.toContain(resourcesPath)
|
||||
})
|
||||
|
||||
it('skips (does not clobber) a user-owned orca already at ~/.local/bin', async () => {
|
||||
const { homePath, resourcesPath } = await makeFixture()
|
||||
const dispatcherPath = join(homePath, '.local', 'bin', 'orca')
|
||||
await mkdir(join(homePath, '.local', 'bin'), { recursive: true })
|
||||
await writeFile(dispatcherPath, '#!/bin/sh\necho my own orca\n', 'utf8')
|
||||
|
||||
const result = await installLinuxBareOrcaDispatcher({
|
||||
resourcesPath,
|
||||
homePath,
|
||||
appImagePath: null
|
||||
})
|
||||
|
||||
expect(result.state).toBe('skipped-foreign')
|
||||
expect(await readFile(dispatcherPath, 'utf8')).toBe('#!/bin/sh\necho my own orca\n')
|
||||
})
|
||||
|
||||
it('skips when the bundled orca-ide launcher is missing from the build', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-bare-dispatcher-nolauncher-'))
|
||||
created.push(root)
|
||||
|
||||
const result = await installLinuxBareOrcaDispatcher({
|
||||
resourcesPath: join(root, 'resources'),
|
||||
homePath: join(root, 'home'),
|
||||
appImagePath: null
|
||||
})
|
||||
|
||||
expect(result.state).toBe('skipped-launcher-missing')
|
||||
expect(result.target).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,102 @@
|
||||
import { existsSync } from 'node:fs'
|
||||
import { chmod, mkdir, readFile, writeFile } from 'node:fs/promises'
|
||||
import { homedir } from 'node:os'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { buildAppImageCliWrapper, quoteShell } from './appimage-cli-wrapper'
|
||||
import { getBundledLauncherPath } from './cli-installer'
|
||||
|
||||
// Why: marks a dispatcher this function wrote so repeat serve starts overwrite
|
||||
// our own file idempotently but never clobber a user's own ~/.local/bin/orca.
|
||||
const DISPATCHER_MARKER = '# orca-serve-bare-orca-dispatcher'
|
||||
|
||||
export type LinuxBareOrcaDispatcherOptions = {
|
||||
/** Packaged app resources root; the bundled `orca-ide` launcher lives under it. */
|
||||
resourcesPath: string
|
||||
/** Test seam — defaults to the real home directory. */
|
||||
homePath?: string
|
||||
/** Test seam — defaults to $APPIMAGE (set only when running from an AppImage). */
|
||||
appImagePath?: string | null
|
||||
}
|
||||
|
||||
export type LinuxBareOrcaDispatcherState =
|
||||
| 'installed'
|
||||
| 'skipped-foreign'
|
||||
| 'skipped-launcher-missing'
|
||||
|
||||
export type LinuxBareOrcaDispatcherResult = {
|
||||
state: LinuxBareOrcaDispatcherState
|
||||
dispatcherPath: string
|
||||
/** What the dispatcher execs: the stable AppImage, or the bundled orca-ide. */
|
||||
target: string | null
|
||||
}
|
||||
|
||||
// Why: on Linux the CLI installs as `orca-ide`, not bare `orca`, to avoid
|
||||
// shadowing GNOME Orca's /usr/bin/orca. But the Claude Team launcher typed into
|
||||
// the initial managed terminal invokes the literal `orca claude-teams`, so a
|
||||
// headless serve box needs a bare-`orca` dispatcher on the managed-terminal PATH
|
||||
// (~/.local/bin, which patchPackagedProcessPath puts ahead of /usr/bin). It is a
|
||||
// plain file, not a managed symlink, so CliInstaller.removeLegacyLinuxCommandIfManaged
|
||||
// never reclaims it.
|
||||
export async function installLinuxBareOrcaDispatcher(
|
||||
options: LinuxBareOrcaDispatcherOptions
|
||||
): Promise<LinuxBareOrcaDispatcherResult> {
|
||||
const dispatcherPath = join(options.homePath ?? homedir(), '.local', 'bin', 'orca')
|
||||
const appImagePath = options.appImagePath ?? process.env.APPIMAGE ?? null
|
||||
|
||||
const resolved = resolveDispatcherScript(options.resourcesPath, appImagePath)
|
||||
if (!resolved) {
|
||||
return { state: 'skipped-launcher-missing', dispatcherPath, target: null }
|
||||
}
|
||||
|
||||
// Why: only (re)write a dispatcher we previously created; leave a user's own
|
||||
// `orca` untouched rather than silently clobbering it on every serve start.
|
||||
if (existsSync(dispatcherPath) && !(await isOwnedDispatcher(dispatcherPath))) {
|
||||
return { state: 'skipped-foreign', dispatcherPath, target: resolved.target }
|
||||
}
|
||||
|
||||
await mkdir(dirname(dispatcherPath), { recursive: true })
|
||||
await writeFile(dispatcherPath, resolved.script, 'utf8')
|
||||
await chmod(dispatcherPath, 0o755)
|
||||
return { state: 'installed', dispatcherPath, target: resolved.target }
|
||||
}
|
||||
|
||||
function resolveDispatcherScript(
|
||||
resourcesPath: string,
|
||||
appImagePath: string | null
|
||||
): { script: string; target: string } | null {
|
||||
if (appImagePath) {
|
||||
// Why: an AppImage mounts resources under an ephemeral FUSE path per launch,
|
||||
// so the dispatcher must exec the stable outer AppImage — reuse the same
|
||||
// wrapper CliInstaller installs for the AppImage command.
|
||||
return { script: withMarker(buildAppImageCliWrapper(appImagePath)), target: appImagePath }
|
||||
}
|
||||
|
||||
const launcher = getBundledLauncherPath('linux', resourcesPath)
|
||||
// Why: getBundledLauncherPath only joins the path; guard existence so we never
|
||||
// write a dispatcher pointing at a missing launcher (which would fail at exec
|
||||
// time with a confusing error instead of the command-not-found we fix).
|
||||
if (!launcher || !existsSync(launcher)) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
script: `#!/usr/bin/env bash\n${DISPATCHER_MARKER}\nexec ${quoteShell(launcher)} "$@"\n`,
|
||||
target: launcher
|
||||
}
|
||||
}
|
||||
|
||||
function withMarker(script: string): string {
|
||||
const firstNewline = script.indexOf('\n')
|
||||
if (firstNewline === -1) {
|
||||
return `${script}\n${DISPATCHER_MARKER}\n`
|
||||
}
|
||||
// Keep the shebang on line 1; insert the marker immediately after it.
|
||||
return `${script.slice(0, firstNewline + 1)}${DISPATCHER_MARKER}\n${script.slice(firstNewline + 1)}`
|
||||
}
|
||||
|
||||
async function isOwnedDispatcher(dispatcherPath: string): Promise<boolean> {
|
||||
try {
|
||||
return (await readFile(dispatcherPath, 'utf8')).includes(DISPATCHER_MARKER)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -712,7 +712,10 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string {
|
||||
// shell is not safe once a path contains quotes or newlines. Post the raw
|
||||
// hook payload plus metadata as form fields and let the receiver parse it.
|
||||
// Timeout caps best-effort hook posts if the local listener stalls.
|
||||
'curl -sS -X POST "http://127.0.0.1:${ORCA_AGENT_HOOK_PORT}/hook/codex" \\',
|
||||
// Why: pipe payload to curl's stdin (`payload@-`) instead of an inline
|
||||
// `payload=$VALUE` arg, so tens-of-KB tool output stays off the curl
|
||||
// command line (EDR command-line false positives). Wire body is identical.
|
||||
'printf \'%s\' "$payload" | curl -sS -X POST "http://127.0.0.1:${ORCA_AGENT_HOOK_PORT}/hook/codex" \\',
|
||||
' --connect-timeout 0.5 --max-time 1.5 \\',
|
||||
' -H "Content-Type: application/x-www-form-urlencoded" \\',
|
||||
' -H "X-Orca-Agent-Hook-Token: ${ORCA_AGENT_HOOK_TOKEN}" \\',
|
||||
@@ -722,7 +725,7 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string {
|
||||
' --data-urlencode "worktreeId=${ORCA_WORKTREE_ID}" \\',
|
||||
' --data-urlencode "env=${ORCA_AGENT_HOOK_ENV}" \\',
|
||||
' --data-urlencode "version=${ORCA_AGENT_HOOK_VERSION}" \\',
|
||||
' --data-urlencode "payload=${payload}" >/dev/null 2>&1 || true',
|
||||
' --data-urlencode "payload@-" >/dev/null 2>&1 || true',
|
||||
'exit 0',
|
||||
''
|
||||
].join('\n')
|
||||
|
||||
@@ -121,7 +121,10 @@ export function buildCommandCodeManagedScript(
|
||||
' exit 0',
|
||||
'fi',
|
||||
// Timeout caps best-effort hook posts if the local listener stalls.
|
||||
'curl -sS -X POST "http://127.0.0.1:${ORCA_AGENT_HOOK_PORT}/hook/command-code" \\',
|
||||
// Why: pipe payload to curl's stdin (`payload@-`) instead of an inline
|
||||
// `payload=$VALUE` arg, so tens-of-KB tool output stays off the curl
|
||||
// command line (EDR command-line false positives). Wire body is identical.
|
||||
'printf \'%s\' "$payload" | curl -sS -X POST "http://127.0.0.1:${ORCA_AGENT_HOOK_PORT}/hook/command-code" \\',
|
||||
' --connect-timeout 0.5 --max-time 1.5 \\',
|
||||
' -H "Content-Type: application/x-www-form-urlencoded" \\',
|
||||
' -H "X-Orca-Agent-Hook-Token: ${ORCA_AGENT_HOOK_TOKEN}" \\',
|
||||
@@ -131,7 +134,7 @@ export function buildCommandCodeManagedScript(
|
||||
' --data-urlencode "worktreeId=${ORCA_WORKTREE_ID}" \\',
|
||||
' --data-urlencode "env=${ORCA_AGENT_HOOK_ENV}" \\',
|
||||
' --data-urlencode "version=${ORCA_AGENT_HOOK_VERSION}" \\',
|
||||
' --data-urlencode "payload=${payload}" >/dev/null 2>&1 || true',
|
||||
' --data-urlencode "payload@-" >/dev/null 2>&1 || true',
|
||||
'exit 0',
|
||||
''
|
||||
].join('\n')
|
||||
|
||||
@@ -166,7 +166,10 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string {
|
||||
'if [ -z "$payload" ]; then',
|
||||
' exit 0',
|
||||
'fi',
|
||||
'curl -sS -X POST "http://127.0.0.1:${ORCA_AGENT_HOOK_PORT}/hook/copilot" \\',
|
||||
// Why: pipe payload to curl's stdin (`payload@-`) instead of an inline
|
||||
// `payload=$VALUE` arg, so tens-of-KB tool output stays off the curl
|
||||
// command line (EDR command-line false positives). Wire body is identical.
|
||||
'printf \'%s\' "$payload" | curl -sS -X POST "http://127.0.0.1:${ORCA_AGENT_HOOK_PORT}/hook/copilot" \\',
|
||||
' --connect-timeout 0.5 --max-time 1.5 \\',
|
||||
' -H "Content-Type: application/x-www-form-urlencoded" \\',
|
||||
' -H "X-Orca-Agent-Hook-Token: ${ORCA_AGENT_HOOK_TOKEN}" \\',
|
||||
@@ -177,7 +180,7 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string {
|
||||
' --data-urlencode "hookEventName=${ORCA_COPILOT_HOOK_EVENT}" \\',
|
||||
' --data-urlencode "env=${ORCA_AGENT_HOOK_ENV}" \\',
|
||||
' --data-urlencode "version=${ORCA_AGENT_HOOK_VERSION}" \\',
|
||||
' --data-urlencode "payload=${payload}" >/dev/null 2>&1 || true',
|
||||
' --data-urlencode "payload@-" >/dev/null 2>&1 || true',
|
||||
'exit 0',
|
||||
''
|
||||
].join('\n')
|
||||
|
||||
@@ -77,7 +77,12 @@ describe('CursorHookService', () => {
|
||||
if (process.platform === 'win32') {
|
||||
expect(script).toContain('%SystemRoot%\\System32\\curl.exe')
|
||||
} else {
|
||||
// Why: payload is piped to curl via stdin (`payload@-`) so it never lands
|
||||
// on the curl command line (EDR oversized-command-line false positive).
|
||||
expect(script).toContain('payload=$(cat)')
|
||||
expect(script).toContain('printf \'%s\' "$payload" | curl')
|
||||
expect(script).toContain('--data-urlencode "payload@-"')
|
||||
expect(script).not.toContain('--data-urlencode "payload=${payload}"')
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -104,7 +104,10 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string {
|
||||
// shell is not safe once a path contains quotes or newlines. Post the raw
|
||||
// hook payload plus metadata as form fields and let the receiver parse it.
|
||||
// Timeout caps best-effort hook posts if the local listener stalls.
|
||||
'curl -sS -X POST "http://127.0.0.1:${ORCA_AGENT_HOOK_PORT}/hook/cursor" \\',
|
||||
// Why: pipe payload to curl's stdin (`payload@-`) instead of an inline
|
||||
// `payload=$VALUE` arg, so tens-of-KB tool output stays off the curl
|
||||
// command line (EDR command-line false positives). Wire body is identical.
|
||||
'printf \'%s\' "$payload" | curl -sS -X POST "http://127.0.0.1:${ORCA_AGENT_HOOK_PORT}/hook/cursor" \\',
|
||||
' --connect-timeout 0.5 --max-time 1.5 \\',
|
||||
' -H "Content-Type: application/x-www-form-urlencoded" \\',
|
||||
' -H "X-Orca-Agent-Hook-Token: ${ORCA_AGENT_HOOK_TOKEN}" \\',
|
||||
@@ -114,7 +117,7 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string {
|
||||
' --data-urlencode "worktreeId=${ORCA_WORKTREE_ID}" \\',
|
||||
' --data-urlencode "env=${ORCA_AGENT_HOOK_ENV}" \\',
|
||||
' --data-urlencode "version=${ORCA_AGENT_HOOK_VERSION}" \\',
|
||||
' --data-urlencode "payload=${payload}" >/dev/null 2>&1 || true',
|
||||
' --data-urlencode "payload@-" >/dev/null 2>&1 || true',
|
||||
'exit 0',
|
||||
''
|
||||
].join('\n')
|
||||
|
||||
@@ -64,6 +64,11 @@ describe('DevinHookService', () => {
|
||||
}
|
||||
const script = readFileSync(getDevinManagedScriptPath(), 'utf8')
|
||||
expect(script).toContain('/hook/devin')
|
||||
// Why: payload is piped to curl via stdin (`payload@-`) so it never lands
|
||||
// on the curl command line (EDR oversized-command-line false positive).
|
||||
expect(script).toContain('printf \'%s\' "$payload" | curl')
|
||||
expect(script).toContain('--data-urlencode "payload@-"')
|
||||
expect(script).not.toContain('--data-urlencode "payload=${payload}"')
|
||||
})
|
||||
|
||||
it('preserves unrelated keys in Devin config when installing hooks', () => {
|
||||
|
||||
@@ -80,7 +80,10 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string {
|
||||
// shell is not safe once a path contains quotes or newlines. Post the raw
|
||||
// hook payload plus metadata as form fields and let the receiver parse it.
|
||||
// Timeout caps best-effort hook posts if the local listener stalls.
|
||||
'curl -sS -X POST "http://127.0.0.1:${ORCA_AGENT_HOOK_PORT}/hook/devin" \\',
|
||||
// Why: pipe payload to curl's stdin (`payload@-`) instead of an inline
|
||||
// `payload=$VALUE` arg, so tens-of-KB tool output stays off the curl
|
||||
// command line (EDR command-line false positives). Wire body is identical.
|
||||
'printf \'%s\' "$payload" | curl -sS -X POST "http://127.0.0.1:${ORCA_AGENT_HOOK_PORT}/hook/devin" \\',
|
||||
' --connect-timeout 0.5 --max-time 1.5 \\',
|
||||
' -H "Content-Type: application/x-www-form-urlencoded" \\',
|
||||
' -H "X-Orca-Agent-Hook-Token: ${ORCA_AGENT_HOOK_TOKEN}" \\',
|
||||
@@ -90,7 +93,7 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string {
|
||||
' --data-urlencode "worktreeId=${ORCA_WORKTREE_ID}" \\',
|
||||
' --data-urlencode "env=${ORCA_AGENT_HOOK_ENV}" \\',
|
||||
' --data-urlencode "version=${ORCA_AGENT_HOOK_VERSION}" \\',
|
||||
' --data-urlencode "payload=${payload}" >/dev/null 2>&1 || true',
|
||||
' --data-urlencode "payload@-" >/dev/null 2>&1 || true',
|
||||
'exit 0',
|
||||
''
|
||||
].join('\n')
|
||||
|
||||
@@ -92,7 +92,10 @@ function getManagedScript(): string {
|
||||
' exit 0',
|
||||
'fi',
|
||||
// Timeout caps best-effort hook posts if the local listener stalls.
|
||||
'curl -sS -X POST "http://127.0.0.1:${ORCA_AGENT_HOOK_PORT}/hook/droid" \\',
|
||||
// Why: pipe payload to curl's stdin (`payload@-`) instead of an inline
|
||||
// `payload=$VALUE` arg, so tens-of-KB tool output stays off the curl
|
||||
// command line (EDR command-line false positives). Wire body is identical.
|
||||
'printf \'%s\' "$payload" | curl -sS -X POST "http://127.0.0.1:${ORCA_AGENT_HOOK_PORT}/hook/droid" \\',
|
||||
' --connect-timeout 0.5 --max-time 1.5 \\',
|
||||
' -H "Content-Type: application/x-www-form-urlencoded" \\',
|
||||
' -H "X-Orca-Agent-Hook-Token: ${ORCA_AGENT_HOOK_TOKEN}" \\',
|
||||
@@ -102,7 +105,7 @@ function getManagedScript(): string {
|
||||
' --data-urlencode "worktreeId=${ORCA_WORKTREE_ID}" \\',
|
||||
' --data-urlencode "env=${ORCA_AGENT_HOOK_ENV}" \\',
|
||||
' --data-urlencode "version=${ORCA_AGENT_HOOK_VERSION}" \\',
|
||||
' --data-urlencode "payload=${payload}" >/dev/null 2>&1 || true',
|
||||
' --data-urlencode "payload@-" >/dev/null 2>&1 || true',
|
||||
'exit 0',
|
||||
''
|
||||
].join('\n')
|
||||
|
||||
@@ -97,7 +97,10 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string {
|
||||
// shell is not safe once a path contains quotes or newlines. Post the raw
|
||||
// hook payload plus metadata as form fields and let the receiver parse it.
|
||||
// Timeout caps best-effort hook posts if the local listener stalls.
|
||||
'curl -sS -X POST "http://127.0.0.1:${ORCA_AGENT_HOOK_PORT}/hook/gemini" \\',
|
||||
// Why: pipe payload to curl's stdin (`payload@-`) instead of an inline
|
||||
// `payload=$VALUE` arg, so tens-of-KB tool output stays off the curl
|
||||
// command line (EDR command-line false positives). Wire body is identical.
|
||||
'printf \'%s\' "$payload" | curl -sS -X POST "http://127.0.0.1:${ORCA_AGENT_HOOK_PORT}/hook/gemini" \\',
|
||||
' --connect-timeout 0.5 --max-time 1.5 \\',
|
||||
' -H "Content-Type: application/x-www-form-urlencoded" \\',
|
||||
' -H "X-Orca-Agent-Hook-Token: ${ORCA_AGENT_HOOK_TOKEN}" \\',
|
||||
@@ -107,7 +110,7 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string {
|
||||
' --data-urlencode "worktreeId=${ORCA_WORKTREE_ID}" \\',
|
||||
' --data-urlencode "env=${ORCA_AGENT_HOOK_ENV}" \\',
|
||||
' --data-urlencode "version=${ORCA_AGENT_HOOK_VERSION}" \\',
|
||||
' --data-urlencode "payload=${payload}" >/dev/null 2>&1 || true',
|
||||
' --data-urlencode "payload@-" >/dev/null 2>&1 || true',
|
||||
'exit 0',
|
||||
''
|
||||
].join('\n')
|
||||
|
||||
@@ -1,7 +1,25 @@
|
||||
import type { SshGitProvider } from '../providers/ssh-git-provider'
|
||||
import { extractExecError, ghExecFileAsync, gitExecFileAsync } from './runner'
|
||||
import { parseHostedRemote } from './hosted-remote-url'
|
||||
import { resolveDefaultBaseRefViaExec } from './repo'
|
||||
|
||||
const EXPLICIT_USERNAME_CONFIG_KEYS = ['github.user', 'user.username'] as const
|
||||
|
||||
const GH_LOGIN_PROBE_TIMEOUT_MS = 2500
|
||||
// Why: a timeout-killed gh can leave a grandchild holding the stdio pipes, so
|
||||
// the exec promise may settle long after the kill. The wall keeps the resolver
|
||||
// on schedule either way (issue #7225: a hung gh froze startup for 127s). It
|
||||
// must exceed ghExecFileAsync's full worst-case envelope — 3 attempts ×
|
||||
// GH_LOGIN_PROBE_TIMEOUT_MS plus its transient-retry backoff sleeps (250ms +
|
||||
// 1000ms ≈ 8.75s total) — or a slow-but-recovering gh would be misread as
|
||||
// stuck and start the retry cooldown. Retry-After sleeps can exceed any wall;
|
||||
// bounding those is exactly what the wall is for.
|
||||
const GH_LOGIN_PROBE_WALL_MS = 10_000
|
||||
// Why: a timed-out probe says nothing about the account, so don't pin '' for
|
||||
// the whole session — retry after a cooldown instead of hammering a stuck gh.
|
||||
const GH_LOGIN_TIMEOUT_RETRY_MS = 5 * 60 * 1000
|
||||
const LOCAL_GIT_READ_TIMEOUT_MS = 5000
|
||||
|
||||
export function normalizeGitUsername(value: string): string {
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed) {
|
||||
@@ -12,6 +30,13 @@ export function normalizeGitUsername(value: string): string {
|
||||
return localPart.replace(/^\d+\+/, '')
|
||||
}
|
||||
|
||||
/**
|
||||
* A resolved username plus whether every probe on the way to it completed.
|
||||
* Non-authoritative '' (a probe timed out) must not overwrite a previously
|
||||
* persisted username; authoritative '' should clear one.
|
||||
*/
|
||||
export type ResolvedGitUsername = { username: string; authoritative: boolean }
|
||||
|
||||
export async function getSshGitUsername(
|
||||
provider: SshGitProvider,
|
||||
repoPath: string
|
||||
@@ -31,3 +56,241 @@ export async function getSshGitUsername(
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
type GhLoginProbeResult = { stdout: string; stderr: string; timedOut: boolean }
|
||||
type GhLoginOutcome = { login: string; timedOut: boolean }
|
||||
|
||||
// gh reports one account for the whole machine, so the login is cached
|
||||
// per-process rather than per-repo (mirrors the old sync cache). Timed-out
|
||||
// probes use the soft retry timestamp instead of the permanent cache.
|
||||
let cachedGhLogin: string | null = null
|
||||
let ghLoginTimedOutAt: number | null = null
|
||||
let ghLoginProbeInFlight: Promise<GhLoginOutcome> | null = null
|
||||
|
||||
function isExecTimeoutError(err: unknown): boolean {
|
||||
if (!err || typeof err !== 'object') {
|
||||
return false
|
||||
}
|
||||
const { code, killed, signal } = err as { code?: unknown; killed?: unknown; signal?: unknown }
|
||||
// Why: on Windows a timeout kill surfaces as killed/SIGTERM with a null
|
||||
// code, not ETIMEDOUT — the old ETIMEDOUT-only check let a stuck first
|
||||
// probe fall through to a second equally stuck probe (issue #7225).
|
||||
return code === 'ETIMEDOUT' || killed === true || signal === 'SIGTERM'
|
||||
}
|
||||
|
||||
async function runGhLoginProbe(args: string[]): Promise<GhLoginProbeResult> {
|
||||
let wallTimer: ReturnType<typeof setTimeout> | undefined
|
||||
const wall = new Promise<GhLoginProbeResult>((resolve) => {
|
||||
wallTimer = setTimeout(
|
||||
() => resolve({ stdout: '', stderr: '', timedOut: true }),
|
||||
GH_LOGIN_PROBE_WALL_MS
|
||||
)
|
||||
wallTimer.unref?.()
|
||||
})
|
||||
const exec = ghExecFileAsync(args, { timeout: GH_LOGIN_PROBE_TIMEOUT_MS }).then(
|
||||
({ stdout, stderr }) => ({ stdout, stderr, timedOut: false }),
|
||||
(err: unknown) => {
|
||||
// Why: `gh auth status` reports the login on stderr with a non-zero
|
||||
// exit when partially authenticated, so failures still carry output.
|
||||
const { stdout, stderr } = extractExecError(err)
|
||||
return { stdout, stderr, timedOut: isExecTimeoutError(err) }
|
||||
}
|
||||
)
|
||||
try {
|
||||
return await Promise.race([exec, wall])
|
||||
} finally {
|
||||
if (wallTimer) {
|
||||
clearTimeout(wallTimer)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Why: `gh auth status` prints one block per account — the login line first,
|
||||
// then `Active account: true/false`. Parse block-wise so a multi-account
|
||||
// output resolves the ACTIVE account instead of whatever login line happens
|
||||
// to follow the first `Active account: true` marker.
|
||||
function parseGhAuthStatusLogin(output: string): string {
|
||||
let currentLogin = ''
|
||||
let firstLogin = ''
|
||||
for (const line of output.split('\n')) {
|
||||
const login = line.match(/Logged in to github\.com account\s+([A-Za-z0-9-]+)/)?.[1]
|
||||
if (login) {
|
||||
currentLogin = login
|
||||
if (!firstLogin) {
|
||||
firstLogin = login
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (/Active account:\s+true/.test(line) && currentLogin) {
|
||||
return currentLogin
|
||||
}
|
||||
}
|
||||
return firstLogin
|
||||
}
|
||||
|
||||
async function probeGhLoginOnce(): Promise<GhLoginOutcome> {
|
||||
const api = await runGhLoginProbe(['api', 'user', '-q', '.login'])
|
||||
const apiLogin = normalizeGitUsername(api.stdout.trim())
|
||||
if (apiLogin) {
|
||||
return { login: apiLogin, timedOut: false }
|
||||
}
|
||||
if (api.timedOut) {
|
||||
// Why: if `gh api user` timed out, `gh auth status` is likely to hit the
|
||||
// same stuck keychain/network path. Keep resolution bounded to one probe.
|
||||
return { login: '', timedOut: true }
|
||||
}
|
||||
const status = await runGhLoginProbe(['auth', 'status'])
|
||||
if (status.timedOut) {
|
||||
return { login: '', timedOut: true }
|
||||
}
|
||||
const output = `${status.stdout}\n${status.stderr}`
|
||||
return { login: normalizeGitUsername(parseGhAuthStatusLogin(output)), timedOut: false }
|
||||
}
|
||||
|
||||
async function getGhLoginOutcome(): Promise<GhLoginOutcome> {
|
||||
if (cachedGhLogin !== null) {
|
||||
return { login: cachedGhLogin, timedOut: false }
|
||||
}
|
||||
if (ghLoginTimedOutAt !== null && Date.now() - ghLoginTimedOutAt < GH_LOGIN_TIMEOUT_RETRY_MS) {
|
||||
return { login: '', timedOut: true }
|
||||
}
|
||||
if (ghLoginProbeInFlight) {
|
||||
return ghLoginProbeInFlight
|
||||
}
|
||||
const probe = probeGhLoginOnce()
|
||||
.then((outcome) => {
|
||||
if (outcome.timedOut) {
|
||||
ghLoginTimedOutAt = Date.now()
|
||||
} else {
|
||||
cachedGhLogin = outcome.login
|
||||
ghLoginTimedOutAt = null
|
||||
}
|
||||
return outcome
|
||||
})
|
||||
.finally(() => {
|
||||
ghLoginProbeInFlight = null
|
||||
})
|
||||
ghLoginProbeInFlight = probe
|
||||
return probe
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the `gh` CLI's GitHub login without ever blocking the caller for
|
||||
* longer than the probe wall. Never rejects; unknown resolves to ''.
|
||||
*/
|
||||
export async function getGhLoginAsync(): Promise<string> {
|
||||
return (await getGhLoginOutcome()).login
|
||||
}
|
||||
|
||||
async function readGitStdout(repoPath: string, args: string[]): Promise<string> {
|
||||
try {
|
||||
const { stdout } = await gitExecFileAsync(args, {
|
||||
cwd: repoPath,
|
||||
timeout: LOCAL_GIT_READ_TIMEOUT_MS
|
||||
})
|
||||
return stdout.trim()
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
function getRemoteNameFromRef(shortRef: string, remotes: readonly string[]): string {
|
||||
const sortedRemotes = [...remotes].sort((a, b) => b.length - a.length)
|
||||
return sortedRemotes.find((remote) => shortRef.startsWith(`${remote}/`)) ?? ''
|
||||
}
|
||||
|
||||
function getDefaultBranchName(shortRef: string, remoteName: string): string {
|
||||
if (!shortRef.includes('/')) {
|
||||
return shortRef
|
||||
}
|
||||
return remoteName ? shortRef.slice(remoteName.length + 1) : shortRef.split('/').slice(1).join('/')
|
||||
}
|
||||
|
||||
async function getConfiguredBranchRemote(repoPath: string, branch: string | null): Promise<string> {
|
||||
if (!branch) {
|
||||
return ''
|
||||
}
|
||||
const remote = await readGitStdout(repoPath, ['config', '--get', `branch.${branch}.remote`])
|
||||
return remote === '.' ? '' : remote
|
||||
}
|
||||
|
||||
/**
|
||||
* Faithful async port of the old candidate-ordered GitHub gate: only the
|
||||
* repo's *effective* remote (current-branch remote, default-branch remote,
|
||||
* default-base remote, origin, or a lone remote) may authorize the gh login.
|
||||
* Why: a GitLab-primary repo with a secondary GitHub mirror must NOT pick up
|
||||
* the GitHub account name as its branch prefix.
|
||||
*/
|
||||
async function localRepoHasEffectiveGitHubRemote(repoPath: string): Promise<boolean> {
|
||||
const remotes = (await readGitStdout(repoPath, ['remote'])).split('\n').filter(Boolean)
|
||||
const defaultBaseRef = await resolveDefaultBaseRefViaExec((argv) =>
|
||||
gitExecFileAsync(argv, { cwd: repoPath, timeout: LOCAL_GIT_READ_TIMEOUT_MS })
|
||||
)
|
||||
const defaultBaseRemote = defaultBaseRef ? getRemoteNameFromRef(defaultBaseRef, remotes) : ''
|
||||
const defaultBranch = defaultBaseRef
|
||||
? getDefaultBranchName(defaultBaseRef, defaultBaseRemote)
|
||||
: null
|
||||
|
||||
const currentBranch = await readGitStdout(repoPath, ['branch', '--show-current'])
|
||||
const candidateRemotes = [
|
||||
await getConfiguredBranchRemote(repoPath, currentBranch || null),
|
||||
await getConfiguredBranchRemote(repoPath, defaultBranch),
|
||||
defaultBaseRemote,
|
||||
'origin',
|
||||
remotes.length === 1 ? remotes[0] : ''
|
||||
]
|
||||
|
||||
const seen = new Set<string>()
|
||||
for (const remote of candidateRemotes) {
|
||||
if (!remote || seen.has(remote)) {
|
||||
continue
|
||||
}
|
||||
seen.add(remote)
|
||||
const remoteUrl = await readGitStdout(repoPath, ['remote', 'get-url', remote])
|
||||
if (remoteUrl && parseHostedRemote(remoteUrl)?.provider === 'github') {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Async replacement for the old sync `getGitUsername`: explicit config keys
|
||||
* first, then the `gh` login — but only for repos whose effective remote is
|
||||
* GitHub, since a GitHub account name would be the wrong branch prefix for
|
||||
* GitLab/Bitbucket/self-hosted repos. Never rejects; unknown resolves to
|
||||
* { username: '', authoritative: false }.
|
||||
*/
|
||||
export async function resolveLocalGitUsernameDetailed(
|
||||
repoPath: string
|
||||
): Promise<ResolvedGitUsername> {
|
||||
for (const key of EXPLICIT_USERNAME_CONFIG_KEYS) {
|
||||
try {
|
||||
const { stdout } = await gitExecFileAsync(['config', '--get', key], {
|
||||
cwd: repoPath,
|
||||
timeout: LOCAL_GIT_READ_TIMEOUT_MS
|
||||
})
|
||||
const username = normalizeGitUsername(stdout)
|
||||
if (username) {
|
||||
return { username, authoritative: true }
|
||||
}
|
||||
} catch {
|
||||
// Missing config keys are expected; try the next explicit username key.
|
||||
}
|
||||
}
|
||||
if (await localRepoHasEffectiveGitHubRemote(repoPath)) {
|
||||
const outcome = await getGhLoginOutcome()
|
||||
return { username: outcome.login, authoritative: !outcome.timedOut }
|
||||
}
|
||||
return { username: '', authoritative: true }
|
||||
}
|
||||
|
||||
export async function resolveLocalGitUsername(repoPath: string): Promise<string> {
|
||||
return (await resolveLocalGitUsernameDetailed(repoPath)).username
|
||||
}
|
||||
|
||||
export function resetGhLoginCacheForTests(): void {
|
||||
cachedGhLogin = null
|
||||
ghLoginTimedOutAt = null
|
||||
ghLoginProbeInFlight = null
|
||||
}
|
||||
|
||||
@@ -1,160 +1,271 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type * as ChildProcess from 'node:child_process'
|
||||
import type * as RepoModule from './repo'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type * as RunnerModule from './runner'
|
||||
|
||||
const execSyncMock = vi.hoisted(() => vi.fn())
|
||||
const execFileSyncMock = vi.hoisted(() => vi.fn())
|
||||
const gitExecFileAsyncMock = vi.hoisted(() => vi.fn())
|
||||
const ghExecFileAsyncMock = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('child_process', async () => {
|
||||
const actual = await vi.importActual<typeof ChildProcess>('child_process')
|
||||
vi.mock('./runner', async () => {
|
||||
const actual = await vi.importActual<typeof RunnerModule>('./runner')
|
||||
return {
|
||||
...actual,
|
||||
execSync: execSyncMock,
|
||||
execFileSync: execFileSyncMock
|
||||
gitExecFileAsync: gitExecFileAsyncMock,
|
||||
ghExecFileAsync: ghExecFileAsyncMock
|
||||
}
|
||||
})
|
||||
|
||||
describe('getGitUsername', () => {
|
||||
import {
|
||||
resolveLocalGitUsername,
|
||||
resolveLocalGitUsernameDetailed,
|
||||
resetGhLoginCacheForTests
|
||||
} from './git-username'
|
||||
|
||||
function makeExecError(
|
||||
message: string,
|
||||
extra: { code?: string; killed?: boolean; signal?: string; stderr?: string } = {}
|
||||
): Error {
|
||||
return Object.assign(new Error(message), { stdout: '', stderr: '', ...extra })
|
||||
}
|
||||
|
||||
describe('resolveLocalGitUsername', () => {
|
||||
let gitConfig: Record<string, string>
|
||||
let originRemoteUrl: string | undefined
|
||||
let remoteUrls: Record<string, string>
|
||||
let getGitUsername: typeof RepoModule.getGitUsername
|
||||
let currentBranch: string
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.resetModules()
|
||||
execSyncMock.mockReset()
|
||||
execFileSyncMock.mockReset()
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks()
|
||||
resetGhLoginCacheForTests()
|
||||
gitConfig = {}
|
||||
originRemoteUrl = undefined
|
||||
remoteUrls = {}
|
||||
currentBranch = ''
|
||||
|
||||
execFileSyncMock.mockImplementation((_binary: string, args: string[]) => {
|
||||
gitExecFileAsyncMock.mockImplementation(async (args: string[]) => {
|
||||
if (args[0] === 'config' && args[1] === '--get') {
|
||||
const value = gitConfig[args[2]]
|
||||
if (value !== undefined) {
|
||||
return `${value}\n`
|
||||
return { stdout: `${value}\n`, stderr: '' }
|
||||
}
|
||||
throw new Error(`missing config ${args[2]}`)
|
||||
throw makeExecError(`missing config ${args[2]}`)
|
||||
}
|
||||
if (args[0] === 'remote' && args.length === 1) {
|
||||
const remotes = new Set(Object.keys(remoteUrls))
|
||||
if (originRemoteUrl) {
|
||||
remotes.add('origin')
|
||||
}
|
||||
return `${[...remotes].join('\n')}\n`
|
||||
return { stdout: `${[...remotes].join('\n')}\n`, stderr: '' }
|
||||
}
|
||||
if (args[0] === 'remote' && args[1] === 'get-url') {
|
||||
const remoteUrl = args[2] === 'origin' ? originRemoteUrl : remoteUrls[args[2]]
|
||||
if (remoteUrl) {
|
||||
return `${remoteUrl}\n`
|
||||
return { stdout: `${remoteUrl}\n`, stderr: '' }
|
||||
}
|
||||
throw new Error(`missing ${args[2]} remote`)
|
||||
throw makeExecError(`missing ${args[2]} remote`)
|
||||
}
|
||||
throw new Error(`unexpected git args: ${args.join(' ')}`)
|
||||
if (args[0] === 'branch' && args[1] === '--show-current') {
|
||||
return { stdout: `${currentBranch}\n`, stderr: '' }
|
||||
}
|
||||
if (args[0] === 'symbolic-ref') {
|
||||
// origin/HEAD unset — resolveDefaultBaseRefViaExec falls through to probes.
|
||||
throw makeExecError('no origin/HEAD')
|
||||
}
|
||||
if (args[0] === 'rev-parse') {
|
||||
throw makeExecError(`missing ref ${args.at(-1)}`)
|
||||
}
|
||||
throw makeExecError(`unexpected git args: ${args.join(' ')}`)
|
||||
})
|
||||
|
||||
;({ getGitUsername } = await import('./repo'))
|
||||
})
|
||||
|
||||
it('prefers explicit GitHub user config before checking GitHub CLI login', () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('prefers explicit GitHub user config before checking GitHub CLI login', async () => {
|
||||
originRemoteUrl = 'https://github.com/stablyai/orca.git'
|
||||
gitConfig['github.user'] = 'config-demo'
|
||||
execSyncMock.mockImplementationOnce(() => 'gh-demo\n')
|
||||
ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: 'gh-demo\n', stderr: '' })
|
||||
|
||||
expect(getGitUsername('/repo')).toBe('config-demo')
|
||||
expect(execSyncMock).not.toHaveBeenCalled()
|
||||
await expect(resolveLocalGitUsername('/repo')).resolves.toBe('config-demo')
|
||||
expect(ghExecFileAsyncMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('uses explicit username config before checking GitHub CLI login', () => {
|
||||
it('uses explicit username config before checking GitHub CLI login', async () => {
|
||||
originRemoteUrl = 'https://github.com/stablyai/orca.git'
|
||||
gitConfig['user.username'] = 'repo-demo'
|
||||
execSyncMock.mockImplementationOnce(() => 'gh-demo\n')
|
||||
ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: 'gh-demo\n', stderr: '' })
|
||||
|
||||
expect(getGitUsername('/repo')).toBe('repo-demo')
|
||||
expect(execSyncMock).not.toHaveBeenCalled()
|
||||
await expect(resolveLocalGitUsername('/repo')).resolves.toBe('repo-demo')
|
||||
expect(ghExecFileAsyncMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('uses GitHub CLI login for GitHub remotes instead of repo-local author identity', () => {
|
||||
it('uses GitHub CLI login for GitHub remotes instead of repo-local author identity', async () => {
|
||||
originRemoteUrl = 'https://github.com/stablyai/orca.git'
|
||||
gitConfig['user.email'] = 'demo@example.com'
|
||||
gitConfig['user.name'] = 'Demo User'
|
||||
execSyncMock.mockImplementationOnce(() => 'gh-demo\n')
|
||||
ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: 'gh-demo\n', stderr: '' })
|
||||
|
||||
expect(getGitUsername('/repo')).toBe('gh-demo')
|
||||
expect(execSyncMock).toHaveBeenCalledTimes(1)
|
||||
await expect(resolveLocalGitUsername('/repo')).resolves.toBe('gh-demo')
|
||||
expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('uses GitHub CLI login for a single GitHub remote not named origin', () => {
|
||||
it('uses GitHub CLI login for a single GitHub remote not named origin', async () => {
|
||||
remoteUrls.upstream = 'https://github.com/stablyai/orca.git'
|
||||
execSyncMock.mockImplementationOnce(() => 'gh-demo\n')
|
||||
ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: 'gh-demo\n', stderr: '' })
|
||||
|
||||
expect(getGitUsername('/repo')).toBe('gh-demo')
|
||||
expect(execFileSyncMock.mock.calls).toEqual(
|
||||
expect.arrayContaining([
|
||||
['git', ['remote', 'get-url', 'upstream'], expect.objectContaining({ cwd: '/repo' })]
|
||||
])
|
||||
)
|
||||
expect(execSyncMock).toHaveBeenCalledTimes(1)
|
||||
await expect(resolveLocalGitUsername('/repo')).resolves.toBe('gh-demo')
|
||||
expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('uses GitHub CLI login for GitHub SSH-over-443 remotes', () => {
|
||||
it('uses GitHub CLI login for GitHub SSH-over-443 remotes', async () => {
|
||||
remoteUrls.upstream = 'ssh://git@ssh.github.com:443/stablyai/orca.git'
|
||||
execSyncMock.mockImplementationOnce(() => 'gh-demo\n')
|
||||
ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: 'gh-demo\n', stderr: '' })
|
||||
|
||||
expect(getGitUsername('/repo')).toBe('gh-demo')
|
||||
expect(execSyncMock).toHaveBeenCalledTimes(1)
|
||||
await expect(resolveLocalGitUsername('/repo')).resolves.toBe('gh-demo')
|
||||
expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('does not derive GitHub username prefixes from non-GitHub author identity', () => {
|
||||
it('does not derive GitHub username prefixes from non-GitHub remotes', async () => {
|
||||
originRemoteUrl = 'https://gitlab.com/stablyai/orca.git'
|
||||
gitConfig['user.email'] = 'demo@example.com'
|
||||
gitConfig['user.name'] = 'Demo User'
|
||||
execSyncMock.mockImplementationOnce(() => 'gh-demo\n')
|
||||
ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: 'gh-demo\n', stderr: '' })
|
||||
|
||||
expect(getGitUsername('/repo')).toBe('')
|
||||
expect(execSyncMock).not.toHaveBeenCalled()
|
||||
await expect(resolveLocalGitUsername('/repo')).resolves.toBe('')
|
||||
expect(ghExecFileAsyncMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('bounds and caches failed GitHub CLI lookup', () => {
|
||||
it('ignores a secondary GitHub mirror when the effective remote is GitLab', async () => {
|
||||
// Why: a GitLab-primary repo with a GitHub mirror must not pick up the
|
||||
// GitHub account name as its branch prefix — only the effective remote
|
||||
// (branch remote / default base remote / origin / lone remote) counts.
|
||||
originRemoteUrl = 'https://gitlab.com/stablyai/orca.git'
|
||||
remoteUrls['github-mirror'] = 'https://github.com/stablyai/orca.git'
|
||||
ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: 'gh-demo\n', stderr: '' })
|
||||
|
||||
await expect(resolveLocalGitUsername('/repo')).resolves.toBe('')
|
||||
expect(ghExecFileAsyncMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('bounds and caches failed GitHub CLI lookup', async () => {
|
||||
originRemoteUrl = 'https://github.com/stablyai/orca.git'
|
||||
execSyncMock.mockImplementation(() => {
|
||||
throw new Error('gh unavailable')
|
||||
})
|
||||
ghExecFileAsyncMock.mockRejectedValue(makeExecError('gh unavailable'))
|
||||
|
||||
expect(getGitUsername('/repo')).toBe('')
|
||||
expect(getGitUsername('/repo')).toBe('')
|
||||
await expect(resolveLocalGitUsername('/repo')).resolves.toBe('')
|
||||
await expect(resolveLocalGitUsername('/repo')).resolves.toBe('')
|
||||
|
||||
expect(execSyncMock).toHaveBeenCalledTimes(2)
|
||||
for (const [, options] of execSyncMock.mock.calls) {
|
||||
// api + auth-status fallback on the first resolution; cached afterwards.
|
||||
expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(2)
|
||||
for (const [, options] of ghExecFileAsyncMock.mock.calls) {
|
||||
expect(options).toMatchObject({ timeout: 2500 })
|
||||
}
|
||||
})
|
||||
|
||||
it('skips auth status fallback when GitHub CLI API lookup times out', () => {
|
||||
it('skips auth status fallback when GitHub CLI API lookup times out', async () => {
|
||||
originRemoteUrl = 'https://github.com/stablyai/orca.git'
|
||||
execSyncMock.mockImplementationOnce(() => {
|
||||
throw Object.assign(new Error('spawnSync /bin/sh ETIMEDOUT'), { code: 'ETIMEDOUT' })
|
||||
})
|
||||
ghExecFileAsyncMock.mockRejectedValueOnce(
|
||||
makeExecError('spawnSync gh ETIMEDOUT', { code: 'ETIMEDOUT' })
|
||||
)
|
||||
|
||||
expect(getGitUsername('/repo')).toBe('')
|
||||
expect(getGitUsername('/repo')).toBe('')
|
||||
await expect(resolveLocalGitUsername('/repo')).resolves.toBe('')
|
||||
await expect(resolveLocalGitUsername('/repo')).resolves.toBe('')
|
||||
|
||||
expect(execSyncMock).toHaveBeenCalledTimes(1)
|
||||
expect(execSyncMock.mock.calls[0][1]).toMatchObject({ timeout: 2500 })
|
||||
expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('uses auth status fallback after fast GitHub CLI API failure', () => {
|
||||
it('treats a Windows timeout kill (SIGTERM, no ETIMEDOUT code) as a timeout', async () => {
|
||||
// Why: on Windows the exec timeout kill surfaces killed/SIGTERM without an
|
||||
// ETIMEDOUT code; the old sync probe missed this and ran a second equally
|
||||
// stuck probe (issue #7225).
|
||||
originRemoteUrl = 'https://github.com/stablyai/orca.git'
|
||||
execSyncMock
|
||||
.mockImplementationOnce(() => {
|
||||
throw new Error('gh api unavailable')
|
||||
})
|
||||
.mockImplementationOnce(
|
||||
() =>
|
||||
'github.com\n ✓ Logged in to github.com account demo-user\n - Active account: true\n'
|
||||
)
|
||||
ghExecFileAsyncMock.mockRejectedValueOnce(
|
||||
makeExecError('gh was killed', { killed: true, signal: 'SIGTERM' })
|
||||
)
|
||||
|
||||
expect(getGitUsername('/repo')).toBe('demo-user')
|
||||
expect(execSyncMock).toHaveBeenCalledTimes(2)
|
||||
await expect(resolveLocalGitUsername('/repo')).resolves.toBe('')
|
||||
expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('marks a timed-out gh probe non-authoritative and retries after the cooldown', async () => {
|
||||
vi.useFakeTimers()
|
||||
originRemoteUrl = 'https://github.com/stablyai/orca.git'
|
||||
ghExecFileAsyncMock
|
||||
.mockRejectedValueOnce(makeExecError('gh timeout', { code: 'ETIMEDOUT' }))
|
||||
.mockResolvedValueOnce({ stdout: 'gh-demo\n', stderr: '' })
|
||||
|
||||
await expect(resolveLocalGitUsernameDetailed('/repo')).resolves.toEqual({
|
||||
username: '',
|
||||
authoritative: false
|
||||
})
|
||||
// Within the cooldown the timeout result is reused without a new spawn.
|
||||
await expect(resolveLocalGitUsernameDetailed('/repo')).resolves.toEqual({
|
||||
username: '',
|
||||
authoritative: false
|
||||
})
|
||||
expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1)
|
||||
|
||||
vi.advanceTimersByTime(5 * 60 * 1000 + 1)
|
||||
await expect(resolveLocalGitUsernameDetailed('/repo')).resolves.toEqual({
|
||||
username: 'gh-demo',
|
||||
authoritative: true
|
||||
})
|
||||
expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('reports authoritative empty for non-GitHub repos', async () => {
|
||||
originRemoteUrl = 'https://gitlab.com/stablyai/orca.git'
|
||||
|
||||
await expect(resolveLocalGitUsernameDetailed('/repo')).resolves.toEqual({
|
||||
username: '',
|
||||
authoritative: true
|
||||
})
|
||||
})
|
||||
|
||||
it('uses auth status fallback after fast GitHub CLI API failure', async () => {
|
||||
originRemoteUrl = 'https://github.com/stablyai/orca.git'
|
||||
ghExecFileAsyncMock
|
||||
.mockRejectedValueOnce(makeExecError('gh api unavailable'))
|
||||
.mockResolvedValueOnce({
|
||||
stdout: '',
|
||||
stderr:
|
||||
'github.com\n ✓ Logged in to github.com account demo-user\n - Active account: true\n'
|
||||
})
|
||||
|
||||
await expect(resolveLocalGitUsername('/repo')).resolves.toBe('demo-user')
|
||||
expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('settles within the wall even when the gh child never exits', async () => {
|
||||
vi.useFakeTimers()
|
||||
originRemoteUrl = 'https://github.com/stablyai/orca.git'
|
||||
// A promise that never settles — models a killed gh whose grandchild
|
||||
// keeps the stdio pipes open past the exec timeout.
|
||||
ghExecFileAsyncMock.mockImplementation(() => new Promise(() => {}))
|
||||
|
||||
const resolution = resolveLocalGitUsernameDetailed('/repo')
|
||||
await vi.advanceTimersByTimeAsync(10_100)
|
||||
await expect(resolution).resolves.toEqual({ username: '', authoritative: false })
|
||||
expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('picks the active account from a multi-account auth status output', async () => {
|
||||
// Why: each account block prints its login line BEFORE its
|
||||
// "Active account" marker; a cross-block regex would capture the next
|
||||
// block's login instead of the active one.
|
||||
originRemoteUrl = 'https://github.com/stablyai/orca.git'
|
||||
ghExecFileAsyncMock
|
||||
.mockRejectedValueOnce(makeExecError('gh api unavailable'))
|
||||
.mockResolvedValueOnce({
|
||||
stdout: '',
|
||||
stderr: [
|
||||
'github.com',
|
||||
' ✓ Logged in to github.com account active-user (keyring)',
|
||||
' - Active account: true',
|
||||
' - Git operations protocol: https',
|
||||
' ✓ Logged in to github.com account inactive-user (keyring)',
|
||||
' - Active account: false'
|
||||
].join('\n')
|
||||
})
|
||||
|
||||
await expect(resolveLocalGitUsername('/repo')).resolves.toBe('active-user')
|
||||
})
|
||||
})
|
||||
|
||||
+1
-186
@@ -1,5 +1,4 @@
|
||||
/* oxlint-disable max-lines */
|
||||
import { execSync } from 'node:child_process'
|
||||
import { existsSync, readFileSync, realpathSync, statSync } from 'node:fs'
|
||||
import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path'
|
||||
import { gitExecFileSync, gitExecFileAsync } from './runner'
|
||||
@@ -8,14 +7,7 @@ import { parseGitRevListAheadBehindCounts } from '../../shared/git-rev-list-outp
|
||||
import { normalizeRuntimePathSeparators } from '../../shared/cross-platform-path'
|
||||
import { parseWslUncPath } from '../../shared/wsl-paths'
|
||||
import { toWindowsWslPath } from '../wsl'
|
||||
import {
|
||||
buildHostedRemoteCommitUrl,
|
||||
buildHostedRemoteFileUrl,
|
||||
parseHostedRemote
|
||||
} from './hosted-remote-url'
|
||||
import { normalizeGitUsername } from './git-username'
|
||||
|
||||
const GH_LOGIN_TIMEOUT_MS = 2500
|
||||
import { buildHostedRemoteCommitUrl, buildHostedRemoteFileUrl } from './hosted-remote-url'
|
||||
|
||||
type LocalGitExecOptions = {
|
||||
wslDistro?: string
|
||||
@@ -464,183 +456,6 @@ function getRemoteUrlByName(path: string, remote: string): string {
|
||||
}).trim()
|
||||
}
|
||||
|
||||
function listRemoteNamesSync(path: string): string[] {
|
||||
try {
|
||||
return gitExecFileSync(['remote'], { cwd: path })
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
function getConfiguredBranchRemote(path: string, branch: string | null): string {
|
||||
if (!branch) {
|
||||
return ''
|
||||
}
|
||||
const remote = getGitConfigValue(path, `branch.${branch}.remote`)
|
||||
return remote === '.' ? '' : remote
|
||||
}
|
||||
|
||||
function getCurrentBranchName(path: string): string {
|
||||
try {
|
||||
return gitExecFileSync(['branch', '--show-current'], { cwd: path }).trim()
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
function getRemoteNameFromRef(shortRef: string, remotes: readonly string[]): string {
|
||||
const sortedRemotes = [...remotes].sort((a, b) => b.length - a.length)
|
||||
return sortedRemotes.find((remote) => shortRef.startsWith(`${remote}/`)) ?? ''
|
||||
}
|
||||
|
||||
function getDefaultBranchName(shortRef: string, remoteName: string): string {
|
||||
if (!shortRef.includes('/')) {
|
||||
return shortRef
|
||||
}
|
||||
return remoteName ? shortRef.slice(remoteName.length + 1) : shortRef.split('/').slice(1).join('/')
|
||||
}
|
||||
|
||||
function getGitConfigValue(path: string, key: string): string {
|
||||
try {
|
||||
return gitExecFileSync(['config', '--get', key], {
|
||||
cwd: path
|
||||
}).trim()
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
let cachedGhLogin: string | undefined
|
||||
|
||||
function isGhProbeTimeout(error: unknown): boolean {
|
||||
if (!error || typeof error !== 'object') {
|
||||
return false
|
||||
}
|
||||
|
||||
const err = error as { code?: unknown; message?: unknown }
|
||||
return (
|
||||
err.code === 'ETIMEDOUT' ||
|
||||
(typeof err.message === 'string' && /\bETIMEDOUT\b|timed out/i.test(err.message))
|
||||
)
|
||||
}
|
||||
|
||||
function getGhLogin(): string {
|
||||
if (cachedGhLogin !== undefined) {
|
||||
return cachedGhLogin
|
||||
}
|
||||
|
||||
try {
|
||||
const apiLogin = execSync('gh api user -q .login', {
|
||||
encoding: 'utf-8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
timeout: GH_LOGIN_TIMEOUT_MS
|
||||
}).trim()
|
||||
if (apiLogin) {
|
||||
cachedGhLogin = normalizeGitUsername(apiLogin)
|
||||
return cachedGhLogin
|
||||
}
|
||||
} catch (err) {
|
||||
if (isGhProbeTimeout(err)) {
|
||||
// Why: if `gh api user` timed out, `gh auth status` is likely to hit the
|
||||
// same stuck keychain/network path. Keep repo creation bounded to one probe.
|
||||
cachedGhLogin = ''
|
||||
return ''
|
||||
}
|
||||
// Fall through to auth status parsing
|
||||
}
|
||||
|
||||
try {
|
||||
// Why: gh auth status writes to stderr; redirect via shell so we can capture it.
|
||||
// Use platform-appropriate shell — /bin/bash does not exist on Windows.
|
||||
const output = execSync('gh auth status 2>&1', {
|
||||
encoding: 'utf-8',
|
||||
shell: process.platform === 'win32' ? process.env.ComSpec || 'cmd.exe' : '/bin/bash',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
timeout: GH_LOGIN_TIMEOUT_MS
|
||||
})
|
||||
|
||||
const activeAccountMatch = output.match(
|
||||
/Active account:\s+true[\s\S]*?account\s+([A-Za-z0-9-]+)/
|
||||
)
|
||||
if (activeAccountMatch?.[1]) {
|
||||
cachedGhLogin = normalizeGitUsername(activeAccountMatch[1])
|
||||
return cachedGhLogin
|
||||
}
|
||||
|
||||
const accountMatch = output.match(/Logged in to github\.com account\s+([A-Za-z0-9-]+)/)
|
||||
const login = normalizeGitUsername(accountMatch?.[1] ?? '')
|
||||
if (login) {
|
||||
cachedGhLogin = login
|
||||
}
|
||||
return login
|
||||
} catch {
|
||||
// Why: broken tokens/keychains can block the Electron main process.
|
||||
// Keep the fallback best-effort for this app session.
|
||||
cachedGhLogin = ''
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
function getGhLoginForGitHubRemote(path: string): string {
|
||||
const remoteUrl = getGitHubRemoteUrlForGhLogin(path)
|
||||
if (!remoteUrl) {
|
||||
return ''
|
||||
}
|
||||
return getGhLogin()
|
||||
}
|
||||
|
||||
function getGitHubRemoteUrlForGhLogin(path: string): string {
|
||||
const remotes = listRemoteNamesSync(path)
|
||||
const defaultBaseRef = getDefaultBaseRef(path)
|
||||
const defaultBaseRemote = defaultBaseRef ? getRemoteNameFromRef(defaultBaseRef, remotes) : ''
|
||||
const defaultBranch = defaultBaseRef
|
||||
? getDefaultBranchName(defaultBaseRef, defaultBaseRemote)
|
||||
: null
|
||||
|
||||
const candidateRemotes = [
|
||||
getConfiguredBranchRemote(path, getCurrentBranchName(path)),
|
||||
getConfiguredBranchRemote(path, defaultBranch),
|
||||
defaultBaseRemote,
|
||||
'origin',
|
||||
remotes.length === 1 ? remotes[0] : ''
|
||||
]
|
||||
|
||||
const seen = new Set<string>()
|
||||
for (const remote of candidateRemotes) {
|
||||
if (!remote || seen.has(remote)) {
|
||||
continue
|
||||
}
|
||||
seen.add(remote)
|
||||
try {
|
||||
const remoteUrl = getRemoteUrlByName(path, remote)
|
||||
if (parseHostedRemote(remoteUrl)?.provider === 'github') {
|
||||
return remoteUrl
|
||||
}
|
||||
} catch {
|
||||
// Missing candidate remotes are expected; try the next repo-level fallback.
|
||||
}
|
||||
}
|
||||
// Why: `gh` reports a GitHub account. For GitLab/Bitbucket/self-hosted
|
||||
// repos, using that identity would create the wrong provider prefix.
|
||||
return ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the GitHub/explicit username-style branch prefix for the repo.
|
||||
*/
|
||||
export function getGitUsername(path: string): string {
|
||||
// Why: this backs the "Git Username" branch-prefix setting. Commit author
|
||||
// email/name are not hosted-account usernames, so keep them out of this path.
|
||||
return normalizeGitUsername(
|
||||
getGitConfigValue(path, 'github.user') ||
|
||||
getGitConfigValue(path, 'user.username') ||
|
||||
getGhLoginForGitHubRemote(path)
|
||||
)
|
||||
}
|
||||
|
||||
function hasGitRef(path: string, ref: string): boolean {
|
||||
try {
|
||||
gitExecFileSync(['rev-parse', '--verify', ref], {
|
||||
|
||||
@@ -2,7 +2,47 @@
|
||||
// (transient detection must propagate, not silently retry on 250ms cadence)
|
||||
// and stderr extraction from execFile rejections (err.message is unreliable).
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { extractExecError, isTransientGhError, parseRetryAfterMs } from './runner'
|
||||
import {
|
||||
extractExecError,
|
||||
isTransientGhError,
|
||||
parseRetryAfterMs,
|
||||
redirectPortedHostnameToEnv
|
||||
} from './runner'
|
||||
|
||||
describe('redirectPortedHostnameToEnv', () => {
|
||||
it('moves a ported --hostname into GITLAB_HOST and strips the flag', () => {
|
||||
const { args, options } = redirectPortedHostnameToEnv(
|
||||
['api', '--hostname', 'gitlab.example.com:8443', 'projects/foo%2Fbar/issues'],
|
||||
{ cwd: '/repo' }
|
||||
)
|
||||
expect(args).toEqual(['api', 'projects/foo%2Fbar/issues'])
|
||||
expect(options.env?.GITLAB_HOST).toBe('gitlab.example.com:8443')
|
||||
expect(options.cwd).toBe('/repo')
|
||||
})
|
||||
|
||||
it('leaves a port-less --hostname untouched', () => {
|
||||
const input = ['api', '--hostname', 'gitlab.com', 'user']
|
||||
const { args, options } = redirectPortedHostnameToEnv(input, {})
|
||||
expect(args).toEqual(input)
|
||||
expect(options.env).toBeUndefined()
|
||||
})
|
||||
|
||||
it('is a no-op when no --hostname is present', () => {
|
||||
const input = ['auth', 'status']
|
||||
const { args, options } = redirectPortedHostnameToEnv(input, { env: { A: '1' } })
|
||||
expect(args).toEqual(input)
|
||||
expect(options.env).toEqual({ A: '1' })
|
||||
})
|
||||
|
||||
it('preserves existing env entries alongside GITLAB_HOST', () => {
|
||||
const { options } = redirectPortedHostnameToEnv(
|
||||
['auth', 'status', '--hostname', 'gl.example.org:3001'],
|
||||
{ env: { PATH: '/usr/bin' } }
|
||||
)
|
||||
expect(options.env?.PATH).toBe('/usr/bin')
|
||||
expect(options.env?.GITLAB_HOST).toBe('gl.example.org:3001')
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
|
||||
+40
-1
@@ -976,6 +976,13 @@ export async function gitStreamStdout(
|
||||
})
|
||||
}
|
||||
|
||||
// Why: sync git calls run on the Electron main thread. Local git is normally
|
||||
// fast, but a repo on a dead network drive / cloud-placeholder path can hang
|
||||
// git on filesystem timeouts for minutes with no timeout set — the leading
|
||||
// explanation for issue #7225's 127s "Not Responding" freeze. Callers needing
|
||||
// longer operations should use the async runners instead.
|
||||
const GIT_EXEC_SYNC_TIMEOUT_MS = 15_000
|
||||
|
||||
/**
|
||||
* Sync git command execution. Drop-in replacement for
|
||||
* `execFileSync('git', args, { cwd, encoding, ... })`.
|
||||
@@ -988,13 +995,15 @@ export function gitExecFileSync(
|
||||
cwd: string
|
||||
encoding?: BufferEncoding
|
||||
stdio?: SpawnOptions['stdio']
|
||||
timeout?: number
|
||||
}
|
||||
): string {
|
||||
const resolved = resolveCommand('git', args, options.cwd)
|
||||
return execFileSync(resolved.binary, resolved.args, {
|
||||
cwd: resolved.cwd,
|
||||
encoding: options.encoding ?? 'utf-8',
|
||||
stdio: options.stdio ?? ['pipe', 'pipe', 'pipe']
|
||||
stdio: options.stdio ?? ['pipe', 'pipe', 'pipe'],
|
||||
timeout: options.timeout ?? GIT_EXEC_SYNC_TIMEOUT_MS
|
||||
}) as string
|
||||
}
|
||||
|
||||
@@ -1409,10 +1418,40 @@ type GlabExecOptions = Omit<GitExecOptions, 'cwd'> & {
|
||||
*
|
||||
* Retry policy mirrors ghExecFileAsync.
|
||||
*/
|
||||
/**
|
||||
* glab's `--hostname` flag rejects a host that carries a port
|
||||
* ("error parsing --hostname: invalid hostname"). A self-hosted GitLab on a
|
||||
* non-default port (e.g. `gitlab.example.com:8443`) must instead be selected
|
||||
* via the `GITLAB_HOST` env var, which accepts `host:port`. Translate any
|
||||
* `--hostname host:port` pair into `GITLAB_HOST` so every call site (`api`,
|
||||
* `auth status`, …) works against ported self-hosted instances. Port-less
|
||||
* `--hostname` values are left untouched.
|
||||
*
|
||||
* @internal exported for tests.
|
||||
*/
|
||||
export function redirectPortedHostnameToEnv(
|
||||
args: string[],
|
||||
options: GlabExecOptions
|
||||
): { args: string[]; options: GlabExecOptions } {
|
||||
const i = args.indexOf('--hostname')
|
||||
if (i === -1 || i + 1 >= args.length) {
|
||||
return { args, options }
|
||||
}
|
||||
const host = args[i + 1]
|
||||
if (!/^[^/\s]+:\d+$/.test(host)) {
|
||||
return { args, options }
|
||||
}
|
||||
return {
|
||||
args: [...args.slice(0, i), ...args.slice(i + 2)],
|
||||
options: { ...options, env: { ...(options.env ?? process.env), GITLAB_HOST: host } }
|
||||
}
|
||||
}
|
||||
|
||||
export async function glabExecFileAsync(
|
||||
args: string[],
|
||||
options: GlabExecOptions = {}
|
||||
): Promise<{ stdout: string; stderr: string }> {
|
||||
;({ args, options } = redirectPortedHostnameToEnv(args, options))
|
||||
let resolved = resolveCommand('glab', args, options.cwd, options.wslDistro)
|
||||
let lastError: unknown
|
||||
let attemptedDefaultWslFallback = false
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
owner reset safety, dirty worktree, diverged branch, custom remote) that each
|
||||
need dedicated test coverage. Splitting into separate files would scatter related tests
|
||||
without a meaningful boundary. */
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { gitExecFileAsyncMock, gitExecFileSyncMock, translateWslOutputPathsMock } = vi.hoisted(
|
||||
() => ({
|
||||
@@ -22,11 +22,80 @@ import {
|
||||
addSparseWorktree,
|
||||
addWorktree,
|
||||
listWorktreeGraph,
|
||||
listWorktrees,
|
||||
moveWorktree,
|
||||
parseWorktreeList,
|
||||
removeWorktree
|
||||
} from './worktree'
|
||||
|
||||
describe('listWorktrees in-flight sharing', () => {
|
||||
beforeEach(() => {
|
||||
gitExecFileAsyncMock.mockReset()
|
||||
})
|
||||
|
||||
// Later describes in this file assume a pristine mock (no global reset).
|
||||
afterEach(() => {
|
||||
gitExecFileAsyncMock.mockReset()
|
||||
})
|
||||
|
||||
it('shares one scan across concurrent calls for the same repo', async () => {
|
||||
let resolveScan!: () => void
|
||||
const scanOutput = 'worktree /repo\nHEAD abc123\nbranch refs/heads/main\n'
|
||||
gitExecFileAsyncMock.mockImplementation(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
resolveScan = () => resolve({ stdout: scanOutput })
|
||||
})
|
||||
)
|
||||
|
||||
const first = listWorktrees('/repo')
|
||||
const second = listWorktrees('/repo')
|
||||
resolveScan()
|
||||
const [a, b] = await Promise.all([first, second])
|
||||
|
||||
expect(a).toEqual(b)
|
||||
expect(a[0]?.path).toBe('/repo')
|
||||
expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('runs a fresh scan once the shared one has settled', async () => {
|
||||
const scanOutput = 'worktree /repo\nHEAD abc123\nbranch refs/heads/main\n'
|
||||
gitExecFileAsyncMock.mockResolvedValue({ stdout: scanOutput })
|
||||
|
||||
await listWorktrees('/repo')
|
||||
await listWorktrees('/repo')
|
||||
|
||||
expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('does not share scans across different repos', async () => {
|
||||
gitExecFileAsyncMock.mockImplementation((_args: string[], options: { cwd: string }) =>
|
||||
Promise.resolve({
|
||||
stdout: `worktree ${options.cwd}\nHEAD abc123\nbranch refs/heads/main\n`
|
||||
})
|
||||
)
|
||||
|
||||
await Promise.all([listWorktrees('/repo-one'), listWorktrees('/repo-two')])
|
||||
|
||||
expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('does not share scans for callers with an AbortSignal', async () => {
|
||||
const scanOutput = 'worktree /repo\nHEAD abc123\nbranch refs/heads/main\n'
|
||||
gitExecFileAsyncMock.mockResolvedValue({ stdout: scanOutput })
|
||||
const controller = new AbortController()
|
||||
|
||||
// Why: an aborted shared scan would reject for every caller, so signal
|
||||
// callers must keep a private scan.
|
||||
await Promise.all([
|
||||
listWorktrees('/repo'),
|
||||
listWorktrees('/repo', { signal: controller.signal })
|
||||
])
|
||||
|
||||
expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseWorktreeList', () => {
|
||||
it('parses regular and bare worktree blocks from porcelain output', () => {
|
||||
const output = `
|
||||
@@ -237,6 +306,12 @@ bare
|
||||
})
|
||||
|
||||
describe('listWorktreeGraph', () => {
|
||||
beforeEach(() => {
|
||||
gitExecFileAsyncMock.mockReset()
|
||||
gitExecFileSyncMock.mockReset()
|
||||
translateWslOutputPathsMock.mockClear()
|
||||
})
|
||||
|
||||
it('returns the worktree graph without sparse-checkout annotation probes', async () => {
|
||||
gitExecFileAsyncMock.mockResolvedValueOnce({
|
||||
stdout: `worktree /repo
|
||||
@@ -272,6 +347,98 @@ branch refs/heads/feature/test
|
||||
})
|
||||
})
|
||||
|
||||
it('falls back when older Git rejects worktree list -z with usage exit code', async () => {
|
||||
gitExecFileAsyncMock
|
||||
.mockRejectedValueOnce(
|
||||
Object.assign(new Error('git usage error'), {
|
||||
code: 129,
|
||||
stderr: 'usage: git worktree list [<options>]\n'
|
||||
})
|
||||
)
|
||||
.mockResolvedValueOnce({
|
||||
stdout: `worktree /repo
|
||||
HEAD abc123
|
||||
branch refs/heads/main
|
||||
|
||||
worktree /repo-main-2
|
||||
HEAD def456
|
||||
branch refs/heads/main-2
|
||||
`
|
||||
})
|
||||
|
||||
await expect(listWorktreeGraph('/repo')).resolves.toEqual([
|
||||
{
|
||||
path: '/repo',
|
||||
head: 'abc123',
|
||||
branch: 'refs/heads/main',
|
||||
isBare: false,
|
||||
isMainWorktree: true
|
||||
},
|
||||
{
|
||||
path: '/repo-main-2',
|
||||
head: 'def456',
|
||||
branch: 'refs/heads/main-2',
|
||||
isBare: false,
|
||||
isMainWorktree: false
|
||||
}
|
||||
])
|
||||
|
||||
expect(gitExecFileAsyncMock.mock.calls).toEqual([
|
||||
[['worktree', 'list', '--porcelain', '-z'], { cwd: '/repo' }],
|
||||
[['worktree', 'list', '--porcelain'], { cwd: '/repo' }]
|
||||
])
|
||||
})
|
||||
|
||||
it('falls back on a localized (non-English) older-Git usage error via exit code 129', async () => {
|
||||
// Git under a non-English locale translates the usage text, so stderr
|
||||
// matching alone misses it; the 129 exit code must still trigger fallback.
|
||||
gitExecFileAsyncMock
|
||||
.mockRejectedValueOnce(
|
||||
Object.assign(new Error('git worktree list --porcelain -z'), {
|
||||
code: 129,
|
||||
stderr: 'Fehler: Unbekannter Schalter »z«\nAufruf: git worktree list [<Optionen>]\n'
|
||||
})
|
||||
)
|
||||
.mockResolvedValueOnce({
|
||||
stdout: `worktree /repo
|
||||
HEAD abc123
|
||||
branch refs/heads/main
|
||||
`
|
||||
})
|
||||
|
||||
await expect(listWorktreeGraph('/repo')).resolves.toEqual([
|
||||
{
|
||||
path: '/repo',
|
||||
head: 'abc123',
|
||||
branch: 'refs/heads/main',
|
||||
isBare: false,
|
||||
isMainWorktree: true
|
||||
}
|
||||
])
|
||||
|
||||
expect(gitExecFileAsyncMock.mock.calls).toEqual([
|
||||
[['worktree', 'list', '--porcelain', '-z'], { cwd: '/repo' }],
|
||||
[['worktree', 'list', '--porcelain'], { cwd: '/repo' }]
|
||||
])
|
||||
})
|
||||
|
||||
it('does not retry without -z for non-usage Git failures', async () => {
|
||||
// A fatal error (exit 128) is not an unsupported-flag signal, so the -z
|
||||
// command must not be silently re-run without it.
|
||||
gitExecFileAsyncMock.mockRejectedValueOnce(
|
||||
Object.assign(new Error('git worktree list --porcelain -z'), {
|
||||
code: 128,
|
||||
stderr: 'fatal: unable to read tree\n'
|
||||
})
|
||||
)
|
||||
|
||||
await expect(listWorktreeGraph('/repo')).resolves.toEqual([])
|
||||
|
||||
expect(gitExecFileAsyncMock.mock.calls).toEqual([
|
||||
[['worktree', 'list', '--porcelain', '-z'], { cwd: '/repo' }]
|
||||
])
|
||||
})
|
||||
|
||||
it('returns an empty graph for paths Git reports as non-repositories', async () => {
|
||||
gitExecFileAsyncMock.mockRejectedValueOnce(new Error('fatal: not a git repository'))
|
||||
|
||||
|
||||
@@ -104,9 +104,13 @@ function isNotGitRepositoryError(error: unknown): boolean {
|
||||
}
|
||||
|
||||
function isUnsupportedWorktreeListZError(error: unknown): boolean {
|
||||
return /(?:unknown|invalid) (?:switch|option).*`?-z'?|(?:unknown|invalid) (?:switch|option).*`?z'?/i.test(
|
||||
getErrorText(error)
|
||||
)
|
||||
// `-z` is this command's only flag older Git (<2.36) lacks, so its usage
|
||||
// exit 129 signals the rejection in any locale; stderr match is a fallback.
|
||||
if (getErrorCode(error) === '129') {
|
||||
return true
|
||||
}
|
||||
|
||||
return /(?:unknown|invalid|unrecognized) (?:switch|option).*`?-?z'?/i.test(getErrorText(error))
|
||||
}
|
||||
|
||||
function isUnsupportedRevParsePathFormatError(error: unknown): boolean {
|
||||
@@ -605,10 +609,51 @@ export async function listWorktreeGraph(
|
||||
}
|
||||
}
|
||||
|
||||
// Why: a cold start lists every repo's worktrees from several independent
|
||||
// paths (renderer worktrees fetch, lineage resolution, boot pty-registry
|
||||
// hydration), and each `git worktree list` is a full process spawn —
|
||||
// expensive on Windows (issue #7225). Sharing the in-flight promise collapses
|
||||
// concurrent duplicate scans; nothing is served after a scan settles.
|
||||
const inFlightWorktreeScans = new Map<string, Promise<GitWorktreeInfo[]>>()
|
||||
|
||||
// Why: a caller listing AFTER a mutation completed must observe it, so it may
|
||||
// not join a scan that started before the mutation. Bumping the generation on
|
||||
// mutation completion retires older in-flight scans from sharing (they still
|
||||
// settle for their original callers).
|
||||
const worktreeScanGenerations = new Map<string, number>()
|
||||
|
||||
function bumpWorktreeScanGeneration(repoPath: string): void {
|
||||
worktreeScanGenerations.set(repoPath, (worktreeScanGenerations.get(repoPath) ?? 0) + 1)
|
||||
}
|
||||
|
||||
/**
|
||||
* List all worktrees for a git repo at the given path.
|
||||
* List all worktrees for a git repo at the given path. Concurrent calls for
|
||||
* the same repo share one scan (unless the caller passes an AbortSignal,
|
||||
* which must only cancel its own scan).
|
||||
*/
|
||||
export async function listWorktrees(
|
||||
export function listWorktrees(
|
||||
repoPath: string,
|
||||
options: GitWorktreeExecOptions = {}
|
||||
): Promise<GitWorktreeInfo[]> {
|
||||
if (options.signal) {
|
||||
return listWorktreesUnshared(repoPath, options)
|
||||
}
|
||||
const generation = worktreeScanGenerations.get(repoPath) ?? 0
|
||||
const key = `${repoPath}\0${options.wslDistro ?? ''}\0${generation}`
|
||||
const inFlight = inFlightWorktreeScans.get(key)
|
||||
if (inFlight) {
|
||||
return inFlight
|
||||
}
|
||||
const scan = listWorktreesUnshared(repoPath, options).finally(() => {
|
||||
if (inFlightWorktreeScans.get(key) === scan) {
|
||||
inFlightWorktreeScans.delete(key)
|
||||
}
|
||||
})
|
||||
inFlightWorktreeScans.set(key, scan)
|
||||
return scan
|
||||
}
|
||||
|
||||
async function listWorktreesUnshared(
|
||||
repoPath: string,
|
||||
options: GitWorktreeExecOptions = {}
|
||||
): Promise<GitWorktreeInfo[]> {
|
||||
@@ -763,6 +808,30 @@ export async function addWorktree(
|
||||
refreshLocalBaseRef = false,
|
||||
noCheckout = false,
|
||||
options: AddWorktreeOptions = {}
|
||||
): Promise<AddWorktreeResult> {
|
||||
try {
|
||||
return await performAddWorktree(
|
||||
repoPath,
|
||||
worktreePath,
|
||||
branch,
|
||||
baseBranch,
|
||||
refreshLocalBaseRef,
|
||||
noCheckout,
|
||||
options
|
||||
)
|
||||
} finally {
|
||||
bumpWorktreeScanGeneration(repoPath)
|
||||
}
|
||||
}
|
||||
|
||||
async function performAddWorktree(
|
||||
repoPath: string,
|
||||
worktreePath: string,
|
||||
branch: string,
|
||||
baseBranch?: string,
|
||||
refreshLocalBaseRef = false,
|
||||
noCheckout = false,
|
||||
options: AddWorktreeOptions = {}
|
||||
): Promise<AddWorktreeResult> {
|
||||
let localBaseRefRefresh: LocalBaseRefRefreshResult | undefined
|
||||
let localBaseRefUpdateSuggestion: LocalBaseRefUpdateSuggestion | undefined
|
||||
@@ -953,7 +1022,11 @@ export async function moveWorktree(
|
||||
oldPath: string,
|
||||
newPath: string
|
||||
): Promise<void> {
|
||||
await gitExecFileAsync(['worktree', 'move', oldPath, newPath], { cwd: repoPath })
|
||||
try {
|
||||
await gitExecFileAsync(['worktree', 'move', oldPath, newPath], { cwd: repoPath })
|
||||
} finally {
|
||||
bumpWorktreeScanGeneration(repoPath)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -968,6 +1041,19 @@ export async function removeWorktree(
|
||||
// and must be removed outright. User-initiated deletes leave it false so unmerged
|
||||
// commits are preserved.
|
||||
options: RemoveWorktreeOptions = {}
|
||||
): Promise<RemoveWorktreeResult> {
|
||||
try {
|
||||
return await performRemoveWorktree(repoPath, worktreePath, force, options)
|
||||
} finally {
|
||||
bumpWorktreeScanGeneration(repoPath)
|
||||
}
|
||||
}
|
||||
|
||||
async function performRemoveWorktree(
|
||||
repoPath: string,
|
||||
worktreePath: string,
|
||||
force = false,
|
||||
options: RemoveWorktreeOptions = {}
|
||||
): Promise<RemoveWorktreeResult> {
|
||||
const removedWorktree =
|
||||
options.knownRemovedWorktree ??
|
||||
|
||||
@@ -245,7 +245,7 @@ export async function getProjectSlug(
|
||||
connectionId?: string | null,
|
||||
options: HostedReviewExecutionOptions = {}
|
||||
): Promise<ProjectRef | null> {
|
||||
const knownHosts = await getGlabKnownHosts()
|
||||
const knownHosts = await getGlabKnownHosts(connectionId)
|
||||
return getProjectRef(
|
||||
repoPath,
|
||||
knownHosts,
|
||||
@@ -265,7 +265,7 @@ export async function getMergeRequest(
|
||||
connectionId?: string | null,
|
||||
options: HostedReviewExecutionOptions = {}
|
||||
): Promise<MRInfo | null> {
|
||||
const knownHosts = await getGlabKnownHosts()
|
||||
const knownHosts = await getGlabKnownHosts(connectionId)
|
||||
const localGitArgs = hostedReviewLocalGitOptionArgs(options)
|
||||
const localGitOptions = localGitArgs[0] ?? {}
|
||||
const projectRef = await getProjectRef(repoPath, knownHosts, connectionId, ...localGitArgs)
|
||||
@@ -316,7 +316,7 @@ export async function getMergeRequestForBranch(
|
||||
if (!branchName && linkedMRIid == null) {
|
||||
return null
|
||||
}
|
||||
const knownHosts = await getGlabKnownHosts()
|
||||
const knownHosts = await getGlabKnownHosts(connectionId)
|
||||
const localGitArgs = hostedReviewLocalGitOptionArgs(options)
|
||||
const localGitOptions = localGitArgs[0] ?? {}
|
||||
const projectRef = await getProjectRef(repoPath, knownHosts, connectionId, ...localGitArgs)
|
||||
@@ -400,7 +400,7 @@ export async function listMergeRequests(
|
||||
connectionId?: string | null,
|
||||
localGitOptions: LocalGitExecOptions = {}
|
||||
): Promise<ListMergeRequestsResult> {
|
||||
const knownHosts = await getGlabKnownHosts()
|
||||
const knownHosts = await getGlabKnownHosts(connectionId)
|
||||
// Why: MRs sit on `origin` in the fork model (the user's fork is where
|
||||
// they push branches and submit MRs). Mirror github's `getOwnerRepo`
|
||||
// call site by going through the upstream/origin preference resolver
|
||||
@@ -429,7 +429,7 @@ export async function listMergeRequests(
|
||||
}
|
||||
}
|
||||
// Why: fallback — let glab infer project from cwd, same as listIssues.
|
||||
// Used when the repo's remote host is not in getGlabKnownHosts()
|
||||
// Used when the repo's remote host is not in getGlabKnownHosts(connectionId)
|
||||
// (e.g. a fresh self-hosted instance), but glab itself can still
|
||||
// resolve it from the local git config.
|
||||
const stateFlag = mrListStateFlags(state)
|
||||
@@ -598,7 +598,7 @@ export async function listWorkItems(
|
||||
localGitOptions: LocalGitExecOptions = {}
|
||||
): Promise<GitLabPagedResult<GitLabWorkItem>> {
|
||||
const issueState = mrStateToIssueState(state)
|
||||
const knownHosts = await getGlabKnownHosts()
|
||||
const knownHosts = await getGlabKnownHosts(connectionId)
|
||||
const { source: projectRef } = await resolveIssueSource(
|
||||
repoPath,
|
||||
preference,
|
||||
@@ -734,7 +734,7 @@ export async function listTodos(
|
||||
): Promise<GitLabTodo[]> {
|
||||
const projectRef = await getProjectRef(
|
||||
repoPath,
|
||||
await getGlabKnownHosts(),
|
||||
await getGlabKnownHosts(connectionId),
|
||||
connectionId,
|
||||
localGitOptions
|
||||
)
|
||||
@@ -813,7 +813,7 @@ async function withProjectRef<T>(
|
||||
await resolveIssueSource(
|
||||
repoPath,
|
||||
preference,
|
||||
await getGlabKnownHosts(),
|
||||
await getGlabKnownHosts(connectionId),
|
||||
connectionId,
|
||||
localGitOptions
|
||||
)
|
||||
|
||||
@@ -20,7 +20,17 @@ export type LocalGitExecOptions = {
|
||||
const PROJECT_REF_CACHE_MAX_ENTRIES = 512
|
||||
const projectRefCache = new Map<string, ProjectRef | null>()
|
||||
|
||||
let knownHostsCache: readonly string[] | null = null
|
||||
// Why: known hosts are cached PER connection. A repo on an SSH connection
|
||||
// authenticates against a different glab context than the local one, so a
|
||||
// process-global cache would leak one connection's hosts into another (and
|
||||
// poison a connection that probes before its tunnel is ready). The local
|
||||
// context uses the `'local'` key.
|
||||
const LOCAL_CONNECTION_KEY = 'local'
|
||||
const knownHostsCacheByConnection = new Map<string, readonly string[]>()
|
||||
|
||||
function connectionCacheKey(connectionId?: string | null): string {
|
||||
return connectionId ?? LOCAL_CONNECTION_KEY
|
||||
}
|
||||
|
||||
/** @internal - exposed for tests only */
|
||||
export function _resetProjectRefCache(): void {
|
||||
@@ -35,7 +45,7 @@ export function _getProjectRefCacheSize(): number {
|
||||
|
||||
/** @internal - exposed for tests only */
|
||||
export function _resetKnownHostsCache(): void {
|
||||
knownHostsCache = null
|
||||
knownHostsCacheByConnection.clear()
|
||||
}
|
||||
|
||||
function rememberProjectRefCacheEntry(cacheKey: string, value: ProjectRef | null): void {
|
||||
@@ -108,7 +118,7 @@ async function resolveProjectRefForRemote(
|
||||
localGitOptions
|
||||
))
|
||||
) {
|
||||
rememberGlabKnownHost(remoteCandidate.host)
|
||||
rememberGlabKnownHost(remoteCandidate.host, connectionId)
|
||||
rememberProjectRefCacheEntry(cacheKey, remoteCandidate)
|
||||
return remoteCandidate
|
||||
}
|
||||
@@ -220,12 +230,14 @@ export function glabHostnameArgs(
|
||||
return connectionId && projectRef?.host ? ['--hostname', projectRef.host] : []
|
||||
}
|
||||
|
||||
function rememberGlabKnownHost(host: string): void {
|
||||
function rememberGlabKnownHost(host: string, connectionId?: string | null): void {
|
||||
const normalizedHost = normalizeGitLabHost(host)
|
||||
if (!knownHostsCache || knownHostsCache.map(normalizeGitLabHost).includes(normalizedHost)) {
|
||||
const key = connectionCacheKey(connectionId)
|
||||
const cached = knownHostsCacheByConnection.get(key)
|
||||
if (!cached || cached.map(normalizeGitLabHost).includes(normalizedHost)) {
|
||||
return
|
||||
}
|
||||
knownHostsCache = [...knownHostsCache, normalizedHost]
|
||||
knownHostsCacheByConnection.set(key, [...cached, normalizedHost])
|
||||
}
|
||||
|
||||
async function isGlabConfiguredForRemoteHost(
|
||||
@@ -251,30 +263,47 @@ async function isGlabConfiguredForRemoteHost(
|
||||
}
|
||||
}
|
||||
|
||||
export async function getGlabKnownHosts(): Promise<readonly string[]> {
|
||||
if (knownHostsCache) {
|
||||
return knownHostsCache
|
||||
export async function getGlabKnownHosts(connectionId?: string | null): Promise<readonly string[]> {
|
||||
const key = connectionCacheKey(connectionId)
|
||||
const cached = knownHostsCacheByConnection.get(key)
|
||||
if (cached) {
|
||||
return cached
|
||||
}
|
||||
try {
|
||||
// Why: `glab auth status` is host-scoped, not cwd-scoped — glab reads its
|
||||
// own config to list authenticated hosts. The connectionId is threaded so
|
||||
// the RESULT is cached per connection (a connected repo can have a
|
||||
// different set of authenticated self-hosted hosts than the local one),
|
||||
// mirroring how project-ref resolution caches per connection.
|
||||
const { stdout, stderr } = await glabExecFileAsync(['auth', 'status'])
|
||||
const hosts = parseGlabAuthStatusHosts(`${stdout}\n${stderr}`)
|
||||
knownHostsCache = Array.from(new Set([...DEFAULT_GITLAB_HOSTS, ...hosts]))
|
||||
return knownHostsCache
|
||||
const merged = Array.from(new Set([...DEFAULT_GITLAB_HOSTS, ...hosts]))
|
||||
knownHostsCacheByConnection.set(key, merged)
|
||||
return merged
|
||||
} catch {
|
||||
knownHostsCache = [...DEFAULT_GITLAB_HOSTS]
|
||||
return knownHostsCache
|
||||
// Auth check failed (glab not installed, no auth, tunnel not ready,
|
||||
// etc.) — fall back to the canonical default for THIS call, but do NOT
|
||||
// cache the fallback. A later probe (e.g. after the SSH tunnel comes
|
||||
// up) must be able to discover the real self-hosted host.
|
||||
return [...DEFAULT_GITLAB_HOSTS]
|
||||
}
|
||||
}
|
||||
|
||||
export function parseGlabAuthStatusHosts(output: string): string[] {
|
||||
const hosts = new Set<string>()
|
||||
for (const m of output.matchAll(/logged in to ([a-zA-Z0-9.-]+)/gi)) {
|
||||
// Why: self-hosted GitLab can run on a non-default port (e.g.
|
||||
// `gitlab.example.com:8443`); capture the optional `:port` so two services
|
||||
// on the same hostname but different ports stay distinct downstream.
|
||||
for (const m of output.matchAll(/logged in to ([a-zA-Z0-9.-]+(?::\d+)?)/gi)) {
|
||||
hosts.add(m[1].toLowerCase())
|
||||
}
|
||||
for (const line of output.split('\n')) {
|
||||
const bareLine = line.trim()
|
||||
const hostLine = bareLine.endsWith(':') ? bareLine.slice(0, -1) : bareLine
|
||||
if (line === bareLine && /^[a-zA-Z0-9](?:[a-zA-Z0-9.-]*[a-zA-Z0-9])?$/.test(hostLine)) {
|
||||
if (
|
||||
line === bareLine &&
|
||||
/^[a-zA-Z0-9](?:[a-zA-Z0-9.-]*[a-zA-Z0-9])?(?::\d+)?$/.test(hostLine)
|
||||
) {
|
||||
hosts.add(hostLine.toLowerCase())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -344,6 +344,30 @@ Self-hosted-git
|
||||
it('returns empty list for output with no hosts', () => {
|
||||
expect(parseGlabAuthStatusHosts('Not logged in.')).toEqual([])
|
||||
})
|
||||
|
||||
it('captures a non-default port on "Logged in to" lines', () => {
|
||||
const out = '✓ Logged in to gitlab.example.com:8080 as user (token)'
|
||||
expect(parseGlabAuthStatusHosts(out)).toEqual(['gitlab.example.com:8080'])
|
||||
})
|
||||
|
||||
it('captures a non-default port on header-style lines', () => {
|
||||
const out = `
|
||||
gitlab.example.com:8080:
|
||||
✓ Logged in as user
|
||||
`
|
||||
expect(parseGlabAuthStatusHosts(out)).toContain('gitlab.example.com:8080')
|
||||
})
|
||||
|
||||
it('keeps two services on the same host distinct by port', () => {
|
||||
const out = `
|
||||
✓ Logged in to gitlab.example.com:8443 as user (token)
|
||||
✓ Logged in to gitlab.example.com:3030 as user (token)
|
||||
`
|
||||
expect(parseGlabAuthStatusHosts(out).sort()).toEqual([
|
||||
'gitlab.example.com:3030',
|
||||
'gitlab.example.com:8443'
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseGlabApiResponse', () => {
|
||||
@@ -428,4 +452,52 @@ describe('getGlabKnownHosts', () => {
|
||||
await getGlabKnownHosts()
|
||||
expect(glabExecFileAsyncMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('recognizes a self-hosted host on a non-default port', async () => {
|
||||
glabExecFileAsyncMock.mockResolvedValueOnce({
|
||||
stdout: '✓ Logged in to gitlab.example.com:8080 as user\n',
|
||||
stderr: ''
|
||||
})
|
||||
|
||||
await expect(getGlabKnownHosts()).resolves.toEqual(['gitlab.com', 'gitlab.example.com:8080'])
|
||||
})
|
||||
|
||||
it('caches per connection — the local probe does not satisfy a connection probe', async () => {
|
||||
glabExecFileAsyncMock
|
||||
.mockResolvedValueOnce({ stdout: '✓ Logged in to gitlab.com as user\n', stderr: '' })
|
||||
.mockResolvedValueOnce({
|
||||
stdout: '✓ Logged in to gitlab.example.com:8080 as user\n',
|
||||
stderr: ''
|
||||
})
|
||||
|
||||
await expect(getGlabKnownHosts()).resolves.toEqual(['gitlab.com'])
|
||||
await expect(getGlabKnownHosts('conn-1')).resolves.toEqual([
|
||||
'gitlab.com',
|
||||
'gitlab.example.com:8080'
|
||||
])
|
||||
// A second probe for the same connection is served from cache.
|
||||
await expect(getGlabKnownHosts('conn-1')).resolves.toEqual([
|
||||
'gitlab.com',
|
||||
'gitlab.example.com:8080'
|
||||
])
|
||||
expect(glabExecFileAsyncMock).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('does not permanently cache the failure fallback — a later probe can re-discover hosts', async () => {
|
||||
glabExecFileAsyncMock
|
||||
.mockRejectedValueOnce(new Error('ssh tunnel not ready'))
|
||||
.mockResolvedValueOnce({
|
||||
stdout: '✓ Logged in to gitlab.example.com:8080 as user\n',
|
||||
stderr: ''
|
||||
})
|
||||
|
||||
// First probe fails → canonical default, NOT cached.
|
||||
await expect(getGlabKnownHosts('conn-1')).resolves.toEqual(['gitlab.com'])
|
||||
// Re-probe (e.g. after tunnel comes up) discovers the real host.
|
||||
await expect(getGlabKnownHosts('conn-1')).resolves.toEqual([
|
||||
'gitlab.com',
|
||||
'gitlab.example.com:8080'
|
||||
])
|
||||
expect(glabExecFileAsyncMock).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -187,31 +187,35 @@ describe('gitlab issue operations', () => {
|
||||
expect(result.error?.type).toBe('permission_denied')
|
||||
})
|
||||
|
||||
it('falls back to glab issue list with updated ordering for unresolved self-hosted repos', async () => {
|
||||
it('returns an isolated not_found error (never a cwd-inferred glab call) when the project is unresolved', async () => {
|
||||
// Why: a cwd-inferred `glab issue list` would hit `git: exit status 128`
|
||||
// on an SSH connection and, in an "All projects" aggregate, sink the
|
||||
// whole panel. The unresolvable project must isolate to a structured
|
||||
// error instead, and must not spawn any glab subprocess.
|
||||
getIssueProjectRefMock.mockResolvedValueOnce(null)
|
||||
|
||||
const result = await listIssues('/repo-root', 5, undefined, 'opened', '@me')
|
||||
|
||||
expect(result.items).toEqual([])
|
||||
expect(result.error?.type).toBe('not_found')
|
||||
expect(glabExecFileAsyncMock).not.toHaveBeenCalled()
|
||||
expect(acquireMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns null for getIssue (and spawns no glab call) when the project is unresolved', async () => {
|
||||
getIssueProjectRefMock.mockResolvedValueOnce(null)
|
||||
|
||||
await expect(getIssue('/repo-root', 7)).resolves.toBeNull()
|
||||
expect(glabExecFileAsyncMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('threads connectionId into getGlabKnownHosts for listIssues', async () => {
|
||||
getIssueProjectRefMock.mockResolvedValueOnce({ host: 'gitlab.com', path: 'stablyai/orca' })
|
||||
glabExecFileAsyncMock.mockResolvedValueOnce({ stdout: '[]' })
|
||||
|
||||
await expect(listIssues('/repo-root', 5, undefined, 'opened', '@me')).resolves.toEqual({
|
||||
items: []
|
||||
})
|
||||
await listIssues('/repo-root', 5, undefined, 'opened', undefined, 'conn-7')
|
||||
|
||||
expect(glabExecFileAsyncMock).toHaveBeenCalledWith(
|
||||
[
|
||||
'issue',
|
||||
'list',
|
||||
'--output',
|
||||
'json',
|
||||
'--per-page',
|
||||
'5',
|
||||
'--order',
|
||||
'updated_at',
|
||||
'--sort',
|
||||
'desc',
|
||||
'--assignee',
|
||||
'@me'
|
||||
],
|
||||
{ cwd: '/repo-root' }
|
||||
)
|
||||
expect(getGlabKnownHostsMock).toHaveBeenCalledWith('conn-7')
|
||||
})
|
||||
|
||||
it('creates an issue and returns its iid + web_url', async () => {
|
||||
|
||||
+45
-57
@@ -44,25 +44,24 @@ export async function getIssue(
|
||||
connectionId?: string | null,
|
||||
localGitOptions: LocalGitExecOptions = {}
|
||||
): Promise<GitLabIssueInfo | null> {
|
||||
const knownHosts = await getGlabKnownHosts()
|
||||
const knownHosts = await getGlabKnownHosts(connectionId)
|
||||
const projectRef = await getIssueProjectRef(repoPath, knownHosts, connectionId, localGitOptions)
|
||||
// Why: don't fall back to a cwd-inferred `glab issue view` when the project
|
||||
// can't be resolved — on an SSH connection cwd is not the repo dir, so glab
|
||||
// hits a non-repo dir and fails with `git: exit status 128`. Return null
|
||||
// (the caller already treats a missing project as "no issue") instead of
|
||||
// spawning a doomed cwd-dependent call.
|
||||
if (!projectRef) {
|
||||
return null
|
||||
}
|
||||
await acquire()
|
||||
try {
|
||||
if (projectRef) {
|
||||
const { stdout } = await glabExecFileAsync(
|
||||
[
|
||||
'api',
|
||||
...glabHostnameArgs(projectRef, connectionId),
|
||||
`projects/${encodedProject(projectRef.path)}/issues/${issueNumber}`
|
||||
],
|
||||
glabRepoExecOptions(repoPath, connectionId, localGitOptions)
|
||||
)
|
||||
const data = JSON.parse(stdout)
|
||||
return mapGitLabIssueInfo(data)
|
||||
}
|
||||
// Fallback for non-GitLab remotes — let glab infer the project from cwd.
|
||||
const { stdout } = await glabExecFileAsync(
|
||||
['issue', 'view', String(issueNumber), '--output', 'json'],
|
||||
[
|
||||
'api',
|
||||
...glabHostnameArgs(projectRef, connectionId),
|
||||
`projects/${encodedProject(projectRef.path)}/issues/${issueNumber}`
|
||||
],
|
||||
glabRepoExecOptions(repoPath, connectionId, localGitOptions)
|
||||
)
|
||||
const data = JSON.parse(stdout)
|
||||
@@ -93,7 +92,7 @@ export async function listIssues(
|
||||
connectionId?: string | null,
|
||||
localGitOptions: LocalGitExecOptions = {}
|
||||
): Promise<IssueListResult> {
|
||||
const knownHosts = await getGlabKnownHosts()
|
||||
const knownHosts = await getGlabKnownHosts(connectionId)
|
||||
const { source: projectRef } = await resolveIssueSource(
|
||||
repoPath,
|
||||
preference,
|
||||
@@ -101,49 +100,38 @@ export async function listIssues(
|
||||
connectionId,
|
||||
localGitOptions
|
||||
)
|
||||
await acquire()
|
||||
try {
|
||||
if (projectRef) {
|
||||
const stateParam = state === 'all' ? '' : `&state=${state}`
|
||||
const scopeParam = assignee === '@me' ? '&scope=assigned_to_me' : ''
|
||||
const { stdout } = await glabExecFileAsync(
|
||||
[
|
||||
'api',
|
||||
...glabHostnameArgs(projectRef, connectionId),
|
||||
`projects/${encodedProject(projectRef.path)}/issues?per_page=${limit}&order_by=updated_at&sort=desc${stateParam}${scopeParam}`
|
||||
],
|
||||
glabRepoExecOptions(repoPath, connectionId, localGitOptions)
|
||||
)
|
||||
const data = JSON.parse(stdout) as Record<string, unknown>[]
|
||||
// Why: GitLab's project issues endpoint returns true issues only
|
||||
// (MRs are a separate endpoint), so no equivalent of GitHub's
|
||||
// pull_request filter is needed here.
|
||||
return {
|
||||
items: data.map((d) => mapGitLabIssueInfo(d as Parameters<typeof mapGitLabIssueInfo>[0]))
|
||||
// Why: when the project can't be resolved we must NOT fall back to an
|
||||
// unscoped `glab issue list` that infers the project from cwd. For a repo
|
||||
// on an SSH connection there is no local cwd matching the repo, so glab
|
||||
// runs git resolution in a non-repo dir and fails with `git: exit status
|
||||
// 128`. In an "All projects" aggregate one such failure must not sink the
|
||||
// whole panel — return a structured, isolated result so the resolvable
|
||||
// projects still load.
|
||||
if (!projectRef) {
|
||||
return {
|
||||
items: [],
|
||||
error: {
|
||||
type: 'not_found',
|
||||
message: 'Could not resolve a GitLab project for this repository.'
|
||||
}
|
||||
}
|
||||
// Fallback — let glab infer project from cwd. glab issue list defaults
|
||||
// to opened; only pass --closed / --all when explicitly requested.
|
||||
const stateFlag = state === 'closed' ? ['--closed'] : state === 'all' ? ['--all'] : []
|
||||
const assigneeFlag = assignee ? ['--assignee', assignee] : []
|
||||
}
|
||||
await acquire()
|
||||
try {
|
||||
const stateParam = state === 'all' ? '' : `&state=${state}`
|
||||
const scopeParam = assignee === '@me' ? '&scope=assigned_to_me' : ''
|
||||
const { stdout } = await glabExecFileAsync(
|
||||
[
|
||||
'issue',
|
||||
'list',
|
||||
'--output',
|
||||
'json',
|
||||
'--per-page',
|
||||
String(limit),
|
||||
'--order',
|
||||
'updated_at',
|
||||
'--sort',
|
||||
'desc',
|
||||
...stateFlag,
|
||||
...assigneeFlag
|
||||
'api',
|
||||
...glabHostnameArgs(projectRef, connectionId),
|
||||
`projects/${encodedProject(projectRef.path)}/issues?per_page=${limit}&order_by=updated_at&sort=desc${stateParam}${scopeParam}`
|
||||
],
|
||||
glabRepoExecOptions(repoPath, connectionId, localGitOptions)
|
||||
)
|
||||
const data = JSON.parse(stdout) as unknown[]
|
||||
const data = JSON.parse(stdout) as Record<string, unknown>[]
|
||||
// Why: GitLab's project issues endpoint returns true issues only
|
||||
// (MRs are a separate endpoint), so no equivalent of GitHub's
|
||||
// pull_request filter is needed here.
|
||||
return {
|
||||
items: data.map((d) => mapGitLabIssueInfo(d as Parameters<typeof mapGitLabIssueInfo>[0]))
|
||||
}
|
||||
@@ -174,7 +162,7 @@ export async function createIssue(
|
||||
if (!trimmedTitle) {
|
||||
return { ok: false, error: 'Title is required' }
|
||||
}
|
||||
const knownHosts = await getGlabKnownHosts()
|
||||
const knownHosts = await getGlabKnownHosts(connectionId)
|
||||
const { source: projectRef } = await resolveIssueSource(
|
||||
repoPath,
|
||||
preference,
|
||||
@@ -244,7 +232,7 @@ export async function updateIssue(
|
||||
await resolveIssueSource(
|
||||
repoPath,
|
||||
preference,
|
||||
await getGlabKnownHosts(),
|
||||
await getGlabKnownHosts(connectionId),
|
||||
connectionId,
|
||||
localGitOptions
|
||||
)
|
||||
@@ -381,7 +369,7 @@ export async function addIssueComment(
|
||||
await resolveIssueSource(
|
||||
repoPath,
|
||||
preference,
|
||||
await getGlabKnownHosts(),
|
||||
await getGlabKnownHosts(connectionId),
|
||||
connectionId,
|
||||
localGitOptions
|
||||
)
|
||||
@@ -439,7 +427,7 @@ export async function listLabels(
|
||||
connectionId?: string | null,
|
||||
localGitOptions: LocalGitExecOptions = {}
|
||||
): Promise<string[]> {
|
||||
const knownHosts = await getGlabKnownHosts()
|
||||
const knownHosts = await getGlabKnownHosts(connectionId)
|
||||
const { source: projectRef } = await resolveIssueSource(
|
||||
repoPath,
|
||||
preference,
|
||||
@@ -480,7 +468,7 @@ export async function listAssignableUsers(
|
||||
connectionId?: string | null,
|
||||
localGitOptions: LocalGitExecOptions = {}
|
||||
): Promise<GitLabAssignableUser[]> {
|
||||
const knownHosts = await getGlabKnownHosts()
|
||||
const knownHosts = await getGlabKnownHosts(connectionId)
|
||||
const { source: projectRef } = await resolveIssueSource(
|
||||
repoPath,
|
||||
preference,
|
||||
|
||||
@@ -38,21 +38,68 @@ describe('gitlab project ref parsing', () => {
|
||||
).toEqual({ host: 'gitlab.example.com', path: 'team/api' })
|
||||
})
|
||||
|
||||
it('parses GitLab remotes with non-standard ports without treating the port as a path segment', () => {
|
||||
it('drops the SSH transport port from the GitLab host identity', () => {
|
||||
// Why: for ssh remotes the port is a transport port (e.g. :2222), not the
|
||||
// web/API endpoint, so it must not become part of the recognized host.
|
||||
expect(
|
||||
parseGitLabProjectRef('ssh://git@gitlab.example.com:2222/team/api.git', [
|
||||
'gitlab.com',
|
||||
'gitlab.example.com'
|
||||
])
|
||||
).toEqual({ host: 'gitlab.example.com', path: 'team/api' })
|
||||
})
|
||||
|
||||
it('keeps the HTTP(S) port as part of the self-hosted GitLab host identity', () => {
|
||||
// Why: a self-hosted GitLab served on a non-default web port (e.g. :8443)
|
||||
// is identified by host:port end-to-end so `glab --hostname` targets it.
|
||||
expect(
|
||||
parseGitLabProjectRef('https://gitlab.example.com:8443/team/api.git', [
|
||||
'gitlab.com',
|
||||
'gitlab.example.com:8443'
|
||||
])
|
||||
).toEqual({ host: 'gitlab.example.com:8443', path: 'team/api' })
|
||||
})
|
||||
|
||||
it('matches a port-bearing http remote against a port-less legacy known host', () => {
|
||||
// Why: a known host recorded without a port (legacy/bare entry) still
|
||||
// recognizes a remote on any port of the same hostname.
|
||||
expect(
|
||||
parseGitLabProjectRef('https://gitlab.example.com:8443/team/api.git', [
|
||||
'gitlab.com',
|
||||
'gitlab.example.com'
|
||||
])
|
||||
).toEqual({ host: 'gitlab.example.com:8443', path: 'team/api' })
|
||||
})
|
||||
|
||||
it('distinguishes two services on the same host by port — only the GitLab one matches', () => {
|
||||
// Why: a GitLab on :8443 and a Gitea on :3030 share a hostname but are
|
||||
// different services. With only the GitLab port in known hosts, the Gitea
|
||||
// remote must NOT be classified as GitLab.
|
||||
const knownHosts = ['gitlab.com', 'gitea.example.com:8443']
|
||||
expect(parseGitLabProjectRef('http://gitea.example.com:8443/team/api.git', knownHosts)).toEqual(
|
||||
{ host: 'gitea.example.com:8443', path: 'team/api' }
|
||||
)
|
||||
expect(
|
||||
parseGitLabProjectRef('http://gitea.example.com:3030/team/api.git', knownHosts)
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('matches an SCP-like self-hosted remote against a port-less known host', () => {
|
||||
expect(
|
||||
parseGitLabProjectRef('git@gitlab.example.com:team/api.git', [
|
||||
'gitlab.com',
|
||||
'gitlab.example.com'
|
||||
])
|
||||
).toEqual({ host: 'gitlab.example.com', path: 'team/api' })
|
||||
})
|
||||
|
||||
it('keeps gitlab.com (no port) recognized as a default host', () => {
|
||||
expect(parseGitLabProjectRef('https://gitlab.com/acme/widgets.git')).toEqual({
|
||||
host: 'gitlab.com',
|
||||
path: 'acme/widgets'
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects single-segment paths (host root or user-only)', () => {
|
||||
expect(parseGitLabProjectRef('git@gitlab.com:foo.git')).toBeNull()
|
||||
expect(parseGitLabProjectRef('https://gitlab.com/foo.git')).toBeNull()
|
||||
|
||||
@@ -12,10 +12,33 @@ export function normalizeGitLabHost(value: string): string {
|
||||
return value.trim().toLowerCase()
|
||||
}
|
||||
|
||||
// Why: host recognition is port-aware so two services on the same hostname
|
||||
// but different ports (e.g. a GitLab on :8080 and a Gitea on :3030) are not
|
||||
// conflated. The hostname (port-less) part is kept for legacy known-host
|
||||
// entries that were recorded without a port.
|
||||
function hostnameOf(host: string): string {
|
||||
// `host` may be `name` or `name:port`. Strip a trailing `:digits` port.
|
||||
return host.replace(/:\d+$/, '')
|
||||
}
|
||||
|
||||
function stripGitSuffix(path: string): string {
|
||||
return path.replace(/\/+$/, '').replace(/\.git$/i, '')
|
||||
}
|
||||
|
||||
// Why: the GitLab host identity is the web/API endpoint, which is what `glab
|
||||
// --hostname` and the known-hosts list speak in terms of. For http(s)
|
||||
// remotes the URL port IS that endpoint port (e.g. self-hosted on :8080),
|
||||
// so it must be kept. For ssh/git remotes the port is a transport port
|
||||
// (e.g. ssh on :2222) that does not identify the GitLab instance, so it is
|
||||
// dropped and only the hostname is used.
|
||||
function hostIdentityFromUrl(url: URL): string {
|
||||
const protocol = url.protocol.toLowerCase()
|
||||
if (protocol === 'http:' || protocol === 'https:') {
|
||||
return url.host
|
||||
}
|
||||
return url.hostname
|
||||
}
|
||||
|
||||
function makeProjectRefForTrustedHost(host: string, path: string): ProjectRef | null {
|
||||
const normalizedHost = normalizeGitLabHost(host)
|
||||
const normalizedPath = stripGitSuffix(path.replace(/^\/+/, '')).trim()
|
||||
@@ -27,6 +50,27 @@ function makeProjectRefForTrustedHost(host: string, path: string): ProjectRef |
|
||||
return { host: normalizedHost, path: normalizedPath }
|
||||
}
|
||||
|
||||
/**
|
||||
* Does `urlHost` (which may include a `:port`) match a known-host entry?
|
||||
* - An exact match (including any port) always counts.
|
||||
* - A known entry WITHOUT a port also matches a URL host on the same
|
||||
* hostname regardless of the URL's port — this preserves recognition for
|
||||
* legacy `gitlab.com` / bare-hostname known entries.
|
||||
* - A known entry WITH a port only matches a URL host with the exact same
|
||||
* port, so `gitlab.example.com:8443` does not accept a
|
||||
* `gitea.example.com:3000` (or same-host different-port) remote.
|
||||
*/
|
||||
function knownHostMatches(urlHost: string, knownHost: string): boolean {
|
||||
if (urlHost === knownHost) {
|
||||
return true
|
||||
}
|
||||
if (hostnameOf(knownHost) === knownHost) {
|
||||
// Known entry has no port — match on hostname alone.
|
||||
return hostnameOf(urlHost) === knownHost
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function makeProjectRef(
|
||||
host: string,
|
||||
path: string,
|
||||
@@ -34,7 +78,7 @@ function makeProjectRef(
|
||||
): ProjectRef | null {
|
||||
const normalizedHost = normalizeGitLabHost(host)
|
||||
const normalizedKnownHosts = knownHosts.map(normalizeGitLabHost)
|
||||
if (!normalizedKnownHosts.includes(normalizedHost)) {
|
||||
if (!normalizedKnownHosts.some((knownHost) => knownHostMatches(normalizedHost, knownHost))) {
|
||||
return null
|
||||
}
|
||||
return makeProjectRefForTrustedHost(normalizedHost, path)
|
||||
@@ -54,7 +98,7 @@ export function parseRemoteProjectRefCandidate(remoteUrl: string): ProjectRef |
|
||||
if (!['http:', 'https:', 'ssh:', 'git:', 'git+ssh:'].includes(url.protocol.toLowerCase())) {
|
||||
return null
|
||||
}
|
||||
return makeProjectRefForTrustedHost(url.hostname, url.pathname)
|
||||
return makeProjectRefForTrustedHost(hostIdentityFromUrl(url), url.pathname)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
@@ -77,7 +121,7 @@ export function parseGitLabProjectRef(
|
||||
if (!['http:', 'https:', 'ssh:', 'git:', 'git+ssh:'].includes(url.protocol.toLowerCase())) {
|
||||
return null
|
||||
}
|
||||
return makeProjectRef(url.hostname, url.pathname, knownHosts)
|
||||
return makeProjectRef(hostIdentityFromUrl(url), url.pathname, knownHosts)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -361,7 +361,7 @@ export async function getWorkItemDetails(
|
||||
await resolveIssueSource(
|
||||
repoPath,
|
||||
preference,
|
||||
await getGlabKnownHosts(),
|
||||
await getGlabKnownHosts(connectionId),
|
||||
connectionId,
|
||||
localGitOptions
|
||||
)
|
||||
|
||||
@@ -76,7 +76,12 @@ describe('GrokHookService', () => {
|
||||
if (process.platform === 'win32') {
|
||||
expect(script).toContain('%SystemRoot%\\System32\\curl.exe')
|
||||
} else {
|
||||
// Why: payload is piped to curl via stdin (`payload@-`) so it never lands
|
||||
// on the curl command line (EDR oversized-command-line false positive).
|
||||
expect(script).toContain('payload=$(cat)')
|
||||
expect(script).toContain('printf \'%s\' "$payload" | curl')
|
||||
expect(script).toContain('--data-urlencode "payload@-"')
|
||||
expect(script).not.toContain('--data-urlencode "payload=${payload}"')
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -90,7 +90,10 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string {
|
||||
' exit 0',
|
||||
'fi',
|
||||
// Timeout caps best-effort hook posts if the local listener stalls.
|
||||
'curl -sS -X POST "http://127.0.0.1:${ORCA_AGENT_HOOK_PORT}/hook/grok" \\',
|
||||
// Why: pipe payload to curl's stdin (`payload@-`) instead of an inline
|
||||
// `payload=$VALUE` arg, so tens-of-KB tool output stays off the curl
|
||||
// command line (EDR command-line false positives). Wire body is identical.
|
||||
'printf \'%s\' "$payload" | curl -sS -X POST "http://127.0.0.1:${ORCA_AGENT_HOOK_PORT}/hook/grok" \\',
|
||||
' --connect-timeout 0.5 --max-time 1.5 \\',
|
||||
' -H "Content-Type: application/x-www-form-urlencoded" \\',
|
||||
' -H "X-Orca-Agent-Hook-Token: ${ORCA_AGENT_HOOK_TOKEN}" \\',
|
||||
@@ -100,7 +103,7 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string {
|
||||
' --data-urlencode "worktreeId=${ORCA_WORKTREE_ID}" \\',
|
||||
' --data-urlencode "env=${ORCA_AGENT_HOOK_ENV}" \\',
|
||||
' --data-urlencode "version=${ORCA_AGENT_HOOK_VERSION}" \\',
|
||||
' --data-urlencode "payload=${payload}" >/dev/null 2>&1 || true',
|
||||
' --data-urlencode "payload@-" >/dev/null 2>&1 || true',
|
||||
'exit 0',
|
||||
''
|
||||
].join('\n')
|
||||
|
||||
+108
-28
@@ -91,13 +91,20 @@ import {
|
||||
shouldBypassSingleInstanceLock
|
||||
} from './startup/single-instance-lock'
|
||||
import { startEventLoopStallProbe } from './startup/event-loop-stall-probe'
|
||||
import { isStartupDiagnosticsEnabled, logStartupDiagnostic } from './startup/startup-diagnostics'
|
||||
import {
|
||||
isStartupDiagnosticsEnabled,
|
||||
logStartupDiagnostic,
|
||||
logStartupMilestone
|
||||
} from './startup/startup-diagnostics'
|
||||
import { ensureWindowsUserDataAclGrant } from './startup/windows-user-data-acl'
|
||||
import { shouldQuitWhenAllWindowsClosed } from './startup/window-all-closed-quit-policy'
|
||||
import { RateLimitService } from './rate-limits/service'
|
||||
import { getInitialClaudeRateLimitTarget } from './rate-limits/claude-rate-limit-target'
|
||||
import { getInitialCodexRateLimitTarget } from './rate-limits/codex-rate-limit-target'
|
||||
import { attachMainWindowServices } from './window/attach-main-window-services'
|
||||
import {
|
||||
attachMainWindowServices,
|
||||
ensureAutoUpdaterConfigured
|
||||
} from './window/attach-main-window-services'
|
||||
import { createMainWindow, loadMainWindow } from './window/createMainWindow'
|
||||
import { createSystemTray, destroySystemTray } from './tray/system-tray'
|
||||
import { focusExistingMainWindow } from './window/focus-existing-window'
|
||||
@@ -171,6 +178,8 @@ import type { TerminalSideEffectBatch } from '../shared/terminal-side-effect-fac
|
||||
import { KeybindingService } from './keybindings/keybinding-service'
|
||||
import { applyElectronProxySettings } from './network/proxy-settings'
|
||||
import { preserveAgentAuthBeforeRestart } from './agent-auth-restart-preservation'
|
||||
import { CliInstaller } from './cli/cli-installer'
|
||||
import { installLinuxBareOrcaDispatcher } from './cli/linux-bare-orca-dispatcher'
|
||||
|
||||
let mainWindow: BrowserWindow | null = null
|
||||
/** Whether a manual app.quit() (Cmd+Q, etc.) is in progress. Shared with the
|
||||
@@ -397,6 +406,11 @@ if (app.isPackaged && process.platform !== 'win32') {
|
||||
}
|
||||
configureDevUserDataPath(is.dev)
|
||||
configureOrcaUserDataPathEnv()
|
||||
|
||||
// Why: just past createMainWindow's win32 10s ready-to-show reveal fallback,
|
||||
// so a window revealed on that path still gets its tray icon.
|
||||
const TRAY_CREATE_FALLBACK_MS = 12_000
|
||||
|
||||
const startupDiagnosticsEnabled = isStartupDiagnosticsEnabled()
|
||||
if (startupDiagnosticsEnabled) {
|
||||
logStartupDiagnostic('before-single-instance-lock', {
|
||||
@@ -410,14 +424,6 @@ if (startupDiagnosticsEnabled) {
|
||||
startEventLoopStallProbe()
|
||||
}
|
||||
|
||||
// Why: startup benchmarking needs in-process timestamps — harness-side stderr
|
||||
// arrival times include pipe buffering jitter. `t` is ms since process start.
|
||||
function logStartupMilestone(event: string, details: Record<string, unknown> = {}): void {
|
||||
if (startupDiagnosticsEnabled) {
|
||||
logStartupDiagnostic(event, { t: Math.round(performance.now()), ...details })
|
||||
}
|
||||
}
|
||||
|
||||
function focusExistingWindow(): void {
|
||||
focusExistingMainWindow({
|
||||
app,
|
||||
@@ -781,9 +787,45 @@ function openMainWindow(): BrowserWindow {
|
||||
})
|
||||
recordCrashBreadcrumb('main_window_created')
|
||||
logStartupMilestone('window-created')
|
||||
// Why: new Tray() is a synchronous Shell_NotifyIcon call that can block for
|
||||
// seconds while explorer.exe's notification area is busy (part of issue
|
||||
// #7225's pre-paint stall), so create it after first paint. The timer
|
||||
// fallback covers windows revealed without ready-to-show ever firing
|
||||
// (createMainWindow's win32 10s reveal fallback) — those can still be
|
||||
// hidden to the tray on close, so the icon must exist by then.
|
||||
let trayCreated = false
|
||||
const createSystemTrayDeferred = (): void => {
|
||||
if (trayCreated || window.isDestroyed() || isQuitting || !store) {
|
||||
return
|
||||
}
|
||||
trayCreated = true
|
||||
// Why: Windows-only system tray. createSystemTray is idempotent and a
|
||||
// no-op off win32, so calling it on each window open keeps exactly one
|
||||
// live icon.
|
||||
createSystemTray({
|
||||
appIcon: store.getSettings().appIcon,
|
||||
onOpen: showMainWindowFromTray,
|
||||
onQuit: () => {
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
// Why: a real quit can still surface renderer save/discard prompts;
|
||||
// the window must be visible if a hidden-to-tray session vetoes
|
||||
// shutdown.
|
||||
showMainWindowFromTray()
|
||||
}
|
||||
// Why: set the quit latch before app.quit() so the window 'close'
|
||||
// handler proceeds to teardown instead of re-hiding to the tray.
|
||||
isQuitting = true
|
||||
app.quit()
|
||||
}
|
||||
})
|
||||
logStartupMilestone('tray-created')
|
||||
}
|
||||
window.once('ready-to-show', () => {
|
||||
logStartupMilestone('ready-to-show')
|
||||
setImmediate(createSystemTrayDeferred)
|
||||
})
|
||||
const trayCreateFallback = setTimeout(createSystemTrayDeferred, TRAY_CREATE_FALLBACK_MS)
|
||||
trayCreateFallback.unref?.()
|
||||
|
||||
// Why: telemetry-plan.md§First-launch experience anchors default-on
|
||||
// `app_opened` to the first main-window load. Existing users in the
|
||||
@@ -879,23 +921,6 @@ function openMainWindow(): BrowserWindow {
|
||||
stopAllSyntheticTitleSpinners()
|
||||
})
|
||||
mainWindow = window
|
||||
// Why: Windows-only system tray. createSystemTray is idempotent and a no-op
|
||||
// off win32, so calling it on each window open keeps exactly one live icon.
|
||||
createSystemTray({
|
||||
appIcon: store.getSettings().appIcon,
|
||||
onOpen: showMainWindowFromTray,
|
||||
onQuit: () => {
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
// Why: a real quit can still surface renderer save/discard prompts; the
|
||||
// window must be visible if a hidden-to-tray session vetoes shutdown.
|
||||
showMainWindowFromTray()
|
||||
}
|
||||
// Why: set the quit latch before app.quit() so the window 'close' handler
|
||||
// proceeds to teardown instead of re-hiding the window to the tray.
|
||||
isQuitting = true
|
||||
app.quit()
|
||||
}
|
||||
})
|
||||
window.on('show', resumeSyntheticTitleSpinnerTimer)
|
||||
window.on('restore', resumeSyntheticTitleSpinnerTimer)
|
||||
window.on('hide', stopSyntheticTitleSpinnerTimer)
|
||||
@@ -1845,7 +1870,12 @@ app.whenReady().then(async () => {
|
||||
logStartupMilestone('i18n-ready')
|
||||
|
||||
registerAppMenu({
|
||||
onCheckForUpdates: (options) => checkForUpdatesFromMenu(options),
|
||||
onCheckForUpdates: (options) => {
|
||||
// Why: the menu is clickable before first paint; a manual check must
|
||||
// run against a configured updater (see attach-main-window-services).
|
||||
ensureAutoUpdaterConfigured()
|
||||
return checkForUpdatesFromMenu(options)
|
||||
},
|
||||
onBeforeReload: ({ ignoreCache, webContentsId }) => {
|
||||
if (mainWindow?.webContents.id === webContentsId) {
|
||||
markExpectedRendererReload(webContentsId)
|
||||
@@ -1989,6 +2019,56 @@ app.whenReady().then(async () => {
|
||||
throw error
|
||||
})
|
||||
installServeSignalHandlers()
|
||||
// Why: the orca CLI command is normally installed by the renderer onboarding /
|
||||
// Settings "Install CLI" flow via the cli:install IPC. Headless serve has no
|
||||
// renderer, so the command is never created and an in-terminal `orca …` fails
|
||||
// with command-not-found. Run the idempotent installer here for the platforms
|
||||
// where it puts a resolvable command on the managed-terminal PATH: macOS (bare
|
||||
// `orca` in /usr/local/bin or ~/.local/bin) and Linux (`orca-ide`; bare `orca`
|
||||
// is added by the dispatcher below). Windows is excluded — there install() would
|
||||
// only mutate the persistent user-registry PATH without helping the current
|
||||
// serve's child terminals. Best-effort: a failure must not block serve start.
|
||||
if (process.platform === 'darwin' || process.platform === 'linux') {
|
||||
try {
|
||||
// Why: serve is headless — never let a missing-write-permission fallback
|
||||
// pop an osascript admin prompt (it would hang serve on a GUI host). Skip
|
||||
// instead; the user-writable ~/.local/bin path needs no elevation anyway.
|
||||
const cliStatus = await new CliInstaller({
|
||||
privilegedRunner: async () => {
|
||||
throw new Error('serve CLI auto-install must not request administrator privileges')
|
||||
}
|
||||
}).install()
|
||||
console.log(
|
||||
`[serve] orca CLI install: ${cliStatus.state}${cliStatus.commandPath ? ` (${cliStatus.commandPath})` : ''}`
|
||||
)
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
'[serve] orca CLI install skipped:',
|
||||
error instanceof Error ? error.message : String(error)
|
||||
)
|
||||
}
|
||||
}
|
||||
// Why: on Linux the CLI installs as `orca-ide`, NOT bare `orca` (above), but the
|
||||
// Claude Team launcher typed into the initial managed terminal invokes bare `orca`.
|
||||
// Drop a bare-`orca` dispatcher on ~/.local/bin (ahead of /usr/bin on the managed
|
||||
// terminal PATH) so `orca claude-teams` resolves. Best-effort: a failure must not
|
||||
// block serve startup. See installLinuxBareOrcaDispatcher for the full rationale.
|
||||
if (process.platform === 'linux' && app.isPackaged && process.resourcesPath) {
|
||||
try {
|
||||
const dispatcher = await installLinuxBareOrcaDispatcher({
|
||||
resourcesPath: process.resourcesPath
|
||||
})
|
||||
console.log(
|
||||
`[serve] bare orca dispatcher ${dispatcher.state}: ${dispatcher.dispatcherPath}` +
|
||||
`${dispatcher.target ? ` -> ${dispatcher.target}` : ''}`
|
||||
)
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
'[serve] bare orca dispatcher install skipped:',
|
||||
error instanceof Error ? error.message : String(error)
|
||||
)
|
||||
}
|
||||
}
|
||||
await printServeReady(serveOptions)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -71,7 +71,6 @@ vi.mock('../git/runner', () => ({
|
||||
|
||||
vi.mock('../git/repo', () => ({
|
||||
isGitRepo: vi.fn().mockReturnValue(true),
|
||||
getGitUsername: vi.fn().mockReturnValue(''),
|
||||
getRepoName: vi.fn().mockImplementation((path: string) => path.split('/').pop()),
|
||||
getBaseRefDefault: vi.fn().mockResolvedValue('origin/main'),
|
||||
searchBaseRefs: vi.fn().mockResolvedValue([])
|
||||
|
||||
@@ -22,7 +22,6 @@ vi.mock('../git/runner', () => ({
|
||||
|
||||
vi.mock('../git/repo', () => ({
|
||||
isGitRepo: vi.fn(),
|
||||
getGitUsername: vi.fn(),
|
||||
getRepoName: vi.fn(),
|
||||
getBaseRefDefault: vi.fn(),
|
||||
searchBaseRefs: vi.fn()
|
||||
|
||||
@@ -94,7 +94,6 @@ vi.mock('../git/repo', async () => {
|
||||
// Stub only the functions that spawn git / touch the filesystem.
|
||||
isGitRepo: vi.fn().mockReturnValue(true),
|
||||
getGitRepoRoot: vi.fn((path: string) => path),
|
||||
getGitUsername: vi.fn().mockReturnValue(''),
|
||||
getRepoName: vi.fn().mockImplementation((path: string) => path.split('/').pop()),
|
||||
getBaseRefDefault: vi.fn().mockResolvedValue('origin/main'),
|
||||
getRemoteCount: vi.fn().mockResolvedValue(1),
|
||||
|
||||
@@ -35,7 +35,6 @@ vi.mock('electron', () => ({
|
||||
|
||||
vi.mock('../git/repo', () => ({
|
||||
isGitRepo: vi.fn().mockReturnValue(true),
|
||||
getGitUsername: vi.fn().mockReturnValue(''),
|
||||
getRepoName: vi.fn().mockImplementation((path: string) => path.split('/').pop()),
|
||||
getBaseRefDefault: vi.fn().mockResolvedValue('origin/main'),
|
||||
searchBaseRefs: vi.fn().mockResolvedValue([]),
|
||||
|
||||
@@ -61,7 +61,6 @@ import { createNestedRepoImportTargetResolver } from '../project-groups/nested-r
|
||||
import {
|
||||
isGitRepo,
|
||||
getGitRepoRoot,
|
||||
getGitUsername,
|
||||
getRepoName,
|
||||
getBaseRefDefault,
|
||||
getRemoteCount,
|
||||
@@ -76,7 +75,8 @@ import {
|
||||
} from '../git/repo'
|
||||
import { getSshGitProvider } from '../providers/ssh-git-dispatch'
|
||||
import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch'
|
||||
import { getSshGitUsername } from '../git/git-username'
|
||||
import { getSshGitUsername, resolveLocalGitUsername } from '../git/git-username'
|
||||
import { enrichRepoGitUsernames } from '../repo-git-username-enrichment'
|
||||
import { getActiveMultiplexer } from './ssh'
|
||||
import { normalizeSparseDirectories } from './sparse-checkout-directories'
|
||||
import { track } from '../telemetry/client'
|
||||
@@ -1163,6 +1163,12 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
|
||||
enrichMissingRepoGitRemoteIdentities(store, {
|
||||
onChanged: () => notifyReposChanged(mainWindow)
|
||||
})
|
||||
// Why: username resolution spawns git/gh and must stay off this handler's
|
||||
// synchronous path (issue #7225); the background pass notifies the
|
||||
// renderer to re-list once values land.
|
||||
enrichRepoGitUsernames(store, {
|
||||
onChanged: () => notifyReposChanged(mainWindow)
|
||||
})
|
||||
return store.getRepos()
|
||||
})
|
||||
|
||||
@@ -2400,7 +2406,7 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
|
||||
}
|
||||
return getSshGitUsername(provider, repo.path)
|
||||
}
|
||||
return getGitUsername(repo.path)
|
||||
return resolveLocalGitUsername(repo.path)
|
||||
})
|
||||
|
||||
ipcMain.handle(
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user