mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eab98a0415 | ||
|
|
a5d349b703 | ||
|
|
e6218fc1dd | ||
|
|
6d69ddb3a1 | ||
|
|
88a51a607b | ||
|
|
d7d0f2084b | ||
|
|
6235799db1 | ||
|
|
28e6303e48 | ||
|
|
7775342453 | ||
|
|
ea28602b23 | ||
|
|
7ae562e3ad | ||
|
|
5e7a1c5d03 | ||
|
|
df2bd62438 | ||
|
+7 |
36d418e639 | ||
|
|
f61ce8de2e | ||
|
|
48f93e359c | ||
|
|
c84fba31db | ||
|
|
2c0b1b4ab9 | ||
|
|
03fd67607a | ||
|
|
19a4231cc0 | ||
|
|
7aa2bf5331 | ||
|
|
0a79d79f09 | ||
|
|
3e584e765c | ||
|
|
e547a77a3f | ||
|
|
c3f5b497d5 | ||
|
|
ce96692e4c | ||
|
|
9f61201be7 | ||
|
|
f731633314 | ||
|
|
5ed16fdefc | ||
|
|
b3941fef7c | ||
|
|
9b9d7ae0fd | ||
|
|
3e5a8f048b | ||
|
|
1170cac985 | ||
|
|
1dc3743303 | ||
|
|
65525046ff | ||
|
|
6d34b84004 | ||
|
|
f272b05eb3 | ||
|
|
489201ba51 | ||
|
|
ed324f1ff2 | ||
|
|
c8664fe5b9 | ||
|
|
f04296ff1a | ||
|
|
538f64e87e | ||
|
|
8a0f628a7b | ||
|
|
c2552758d3 | ||
|
|
2f7ad7de57 | ||
|
|
9f00da2001 | ||
|
|
44222afeba | ||
|
|
67fd53de67 | ||
|
|
68abb7058f | ||
|
|
7cac6a8e61 | ||
|
|
711987ff57 | ||
|
|
ffa9d2d1fd | ||
|
|
be0c9df945 | ||
|
|
0283f75813 | ||
|
|
7f8062dd5a | ||
|
|
c9475ab9c5 | ||
|
|
d33bd3e085 | ||
|
|
dd04a2df47 | ||
|
|
23bff7ce46 | ||
|
|
984a537bd6 | ||
|
|
e6658cdc85 | ||
|
|
e6bb4bae14 | ||
|
|
c2914edfcc | ||
|
|
76a3cebd0c | ||
|
|
26435e9ee8 | ||
|
|
34dd6e02be | ||
|
|
5f94ff8d57 | ||
|
|
bb2c812495 | ||
|
|
453637b147 | ||
|
|
0a31d5cc0f | ||
|
|
0693f3f1df | ||
|
|
6bb4daa1db | ||
|
|
bdd2953147 | ||
|
|
b092379353 | ||
|
|
bccf46f082 | ||
|
|
e3830bfe85 | ||
|
|
ecce16e989 | ||
|
|
ecc69d90af | ||
|
|
090895e2e0 | ||
|
|
d9c9e978de | ||
|
|
3bb39505c7 | ||
|
|
4b2bbcce3e | ||
|
|
f215b0d517 | ||
|
|
e5924af73c | ||
|
|
273b4bf37e | ||
|
|
1259cbcdc0 | ||
|
|
ab55d0c6e3 | ||
|
|
8f73d8b0f4 | ||
|
|
49ebd56dfd | ||
|
|
57f64e0bcd | ||
|
|
5a09a772a2 | ||
|
|
0a98eca6c2 | ||
|
|
931288de16 | ||
|
|
9105163cab | ||
|
|
5a29ca5317 | ||
|
|
2e69bddcb3 | ||
|
|
5051fd2bd6 | ||
|
|
6740659465 | ||
|
|
1076795d92 | ||
|
|
c5d1ef0533 | ||
|
|
aa855246cd | ||
|
|
68a08fa12f | ||
|
|
c946e432ea | ||
|
|
264a937fac | ||
|
|
97411a34d5 | ||
|
|
91633c204f | ||
|
|
aa36888148 | ||
|
|
602b0ddbde | ||
|
|
0a9680d4e3 | ||
|
|
4c3924baaf | ||
|
|
63d87594bc | ||
|
|
02fe40a077 | ||
|
|
7d88bbe837 | ||
|
|
7458b1abbd | ||
|
|
5c82153401 | ||
|
|
ce9fa50c04 | ||
|
|
e42b63ebb0 | ||
|
|
837b9dae19 | ||
|
|
9fb0a2858b | ||
|
|
a215352b6f | ||
|
|
4ae018529d | ||
|
|
69cf62ca85 | ||
|
|
82026885f0 | ||
|
|
58ebc988c9 | ||
|
|
034afa9c2b | ||
|
|
ed0beef744 | ||
|
|
1760e4e70f | ||
|
|
46863c51b6 | ||
|
|
4425092027 | ||
|
|
56c677451a | ||
|
|
72804d8acf | ||
|
|
994c56f0be | ||
|
|
77a14bf3ee | ||
|
|
eb4ea9c455 | ||
|
|
40af7a7cd2 | ||
|
|
aaab10fd89 | ||
|
|
44a38fae6e | ||
|
|
033c9ac19a | ||
|
|
a4f1169326 | ||
|
|
be906c64b9 | ||
|
|
4defd9da12 | ||
|
|
42a962d194 | ||
|
|
c57f592693 | ||
|
|
cc69bbe133 | ||
|
|
514cbc9e6a | ||
|
|
7462994451 | ||
|
|
99d5c4323b | ||
|
|
6e4b9c9be3 | ||
|
|
8b7af4d7ab | ||
|
|
0bba64a9cd | ||
|
|
2d40271a5e | ||
|
|
2fae7e1b16 | ||
|
|
a33553b0c4 | ||
|
|
b9cf32f81f | ||
|
|
ffe9885f5a | ||
|
|
41b5b5a0e0 | ||
|
|
12bb4a9bc1 | ||
|
|
021cd890d6 | ||
|
|
aa82b2f94f | ||
|
|
3ebf6126fb | ||
|
|
925b2eabbb | ||
|
|
e1ea25e88b | ||
|
|
67983354d5 | ||
|
|
491a675720 | ||
|
|
f229d2895f | ||
|
|
53f11f5192 | ||
|
|
b1f60c8138 | ||
|
|
307617372a | ||
|
|
3b0d71cd67 | ||
|
|
9ff292dd79 | ||
|
|
65b32395da | ||
|
|
528bd5fcfa | ||
|
|
ad0e270abf | ||
|
|
2a0d4415c6 | ||
|
|
8bdea533de | ||
|
|
f79da56f62 | ||
|
|
b1091fdd04 | ||
|
|
e9933eee99 | ||
|
|
32fd8b4410 |
No files matched your search
@@ -394,7 +394,7 @@ jobs:
|
||||
pnpm --dir frontend test:motion-browser
|
||||
pnpm --dir frontend test:composer
|
||||
pnpm --dir frontend test:transcript
|
||||
pnpm --dir frontend test:transcript-browser
|
||||
xvfb-run -a env REASONIX_TRANSCRIPT_NATIVE_THUMB=1 pnpm --dir frontend test:transcript-browser
|
||||
|
||||
- name: Test usage statistics frontend
|
||||
run: pnpm --dir frontend test:usage-stats
|
||||
@@ -540,10 +540,36 @@ jobs:
|
||||
- name: Test native motion contracts
|
||||
run: pnpm --dir frontend test:motion
|
||||
|
||||
# Chromium is the closest deterministic browser replay available on the
|
||||
# Windows runner; the native smoke below separately exercises the actual
|
||||
# installed WebView2 runtime. Keep both paths so transcript geometry is
|
||||
# covered before the shell-level smoke starts.
|
||||
- name: Test transcript browser replay on Windows
|
||||
timeout-minutes: 10
|
||||
env:
|
||||
PLAYWRIGHT_BROWSERS_PATH: ${{ github.workspace }}/desktop/frontend/.pw-browsers
|
||||
run: |
|
||||
pnpm --dir frontend exec playwright install chromium
|
||||
pnpm --dir frontend test:transcript-browser
|
||||
|
||||
- name: Test WebView2 native smoke state machine
|
||||
shell: pwsh
|
||||
run: ../scripts/test-webview2-native-smoke.ps1 -SelfTest
|
||||
|
||||
- name: Test WebView2 transcript selection compositor
|
||||
shell: pwsh
|
||||
timeout-minutes: 5
|
||||
run: ../scripts/test-transcript-selection-webview2.ps1 -Iterations 3
|
||||
|
||||
- name: Upload WebView2 transcript selection evidence
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: webview2-transcript-selection-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: ${{ runner.temp }}/reasonix-transcript-selection-smoke
|
||||
if-no-files-found: warn
|
||||
retention-days: 14
|
||||
|
||||
# Build the real Wails executable on every desktop PR and exercise the
|
||||
# exact production startup path in Microsoft's installed WebView2 runtime.
|
||||
# The frontend and bindings were already built above, so this skips only
|
||||
|
||||
@@ -7,6 +7,22 @@ on:
|
||||
- 'workers/crash-report/**'
|
||||
- '.github/workflows/deploy-crash-worker.yml'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
firebase_data_action:
|
||||
description: Firebase crash history operation (migration actions do not deploy the Worker)
|
||||
required: true
|
||||
default: none
|
||||
type: choice
|
||||
options:
|
||||
- none
|
||||
- dry-run
|
||||
- apply
|
||||
- verify-only
|
||||
confirmation:
|
||||
description: Enter APPLY_FIREBASE_CRASH_DATA when selecting apply
|
||||
required: false
|
||||
default: ''
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -17,6 +33,7 @@ concurrency:
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
if: github.event_name == 'push' || inputs.firebase_data_action == 'none'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
@@ -36,6 +53,28 @@ jobs:
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
run: npm run migrate:diagnostics-v2
|
||||
- name: Apply and verify Firebase crash D1 migration
|
||||
working-directory: workers/crash-report
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
run: npm run migrate:firebase-crash
|
||||
- name: Sync Firebase crash secrets
|
||||
working-directory: workers/crash-report
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
FIREBASE_DATABASE_URL: ${{ secrets.FIREBASE_DATABASE_URL }}
|
||||
FIREBASE_CLIENT_EMAIL: ${{ secrets.FIREBASE_CLIENT_EMAIL }}
|
||||
FIREBASE_PRIVATE_KEY: ${{ secrets.FIREBASE_PRIVATE_KEY }}
|
||||
run: |
|
||||
configured=0
|
||||
[ -n "$FIREBASE_DATABASE_URL" ] && configured=$((configured + 1))
|
||||
[ -n "$FIREBASE_CLIENT_EMAIL" ] && configured=$((configured + 1))
|
||||
[ -n "$FIREBASE_PRIVATE_KEY" ] && configured=$((configured + 1))
|
||||
if [ "$configured" -ne 0 ] && [ "$configured" -ne 3 ]; then
|
||||
echo "Firebase crash secrets must be either all configured or all absent."
|
||||
exit 1
|
||||
fi
|
||||
node -e 'process.stdout.write(JSON.stringify({FIREBASE_DATABASE_URL:process.env.FIREBASE_DATABASE_URL||null,FIREBASE_CLIENT_EMAIL:process.env.FIREBASE_CLIENT_EMAIL||null,FIREBASE_PRIVATE_KEY:process.env.FIREBASE_PRIVATE_KEY||null}))' | npx wrangler secret bulk
|
||||
- name: Deploy
|
||||
working-directory: workers/crash-report
|
||||
env:
|
||||
@@ -58,3 +97,67 @@ jobs:
|
||||
exit 0
|
||||
fi
|
||||
printf '%s' "$ALERT_WEBHOOK" | npx wrangler secret put ALERT_WEBHOOK
|
||||
|
||||
migrate-firebase-data:
|
||||
if: github.event_name == 'workflow_dispatch' && inputs.firebase_data_action != 'none'
|
||||
runs-on: ubuntu-latest
|
||||
environment: canary
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- name: Require the protected production branch
|
||||
run: |
|
||||
if [ "$GITHUB_REF" != "refs/heads/main-v2" ]; then
|
||||
echo "Firebase crash data migration must run from main-v2."
|
||||
exit 1
|
||||
fi
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: npm
|
||||
cache-dependency-path: workers/crash-report/package-lock.json
|
||||
- name: Validate
|
||||
working-directory: workers/crash-report
|
||||
run: |
|
||||
npm ci
|
||||
npm run typecheck
|
||||
npm test
|
||||
- name: Run Firebase crash history operation
|
||||
working-directory: workers/crash-report
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
FIREBASE_DATABASE_URL: ${{ secrets.FIREBASE_DATABASE_URL }}
|
||||
FIREBASE_CLIENT_EMAIL: ${{ secrets.FIREBASE_CLIENT_EMAIL }}
|
||||
FIREBASE_PRIVATE_KEY: ${{ secrets.FIREBASE_PRIVATE_KEY }}
|
||||
FIREBASE_DATA_ACTION: ${{ inputs.firebase_data_action }}
|
||||
FIREBASE_DATA_CONFIRMATION: ${{ inputs.confirmation }}
|
||||
run: |
|
||||
configured=0
|
||||
[ -n "$CLOUDFLARE_API_TOKEN" ] && configured=$((configured + 1))
|
||||
[ -n "$FIREBASE_DATABASE_URL" ] && configured=$((configured + 1))
|
||||
[ -n "$FIREBASE_CLIENT_EMAIL" ] && configured=$((configured + 1))
|
||||
[ -n "$FIREBASE_PRIVATE_KEY" ] && configured=$((configured + 1))
|
||||
if [ "$configured" -ne 4 ]; then
|
||||
echo "Cloudflare and Firebase migration secrets must all be configured."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
case "$FIREBASE_DATA_ACTION" in
|
||||
dry-run)
|
||||
npm run migrate:firebase-data
|
||||
;;
|
||||
apply)
|
||||
if [ "$FIREBASE_DATA_CONFIRMATION" != "APPLY_FIREBASE_CRASH_DATA" ]; then
|
||||
echo "Apply requires the exact confirmation APPLY_FIREBASE_CRASH_DATA."
|
||||
exit 1
|
||||
fi
|
||||
npm run migrate:firebase-data -- --apply
|
||||
npm run migrate:firebase-data -- --verify-only
|
||||
;;
|
||||
verify-only)
|
||||
npm run migrate:firebase-data -- --verify-only
|
||||
;;
|
||||
*)
|
||||
echo "Unsupported Firebase crash data action."
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
@@ -4,9 +4,12 @@ on:
|
||||
pull_request:
|
||||
paths:
|
||||
- "release-notes/**"
|
||||
- "scripts/generate-release-notes.mjs"
|
||||
- "scripts/release-notes*.mjs"
|
||||
- "site/src/pages/changelog/**"
|
||||
- "site/src/components/ChangelogPage.astro"
|
||||
- "site/src/data/releases.ts"
|
||||
- "site/src/styles/changelog.css"
|
||||
- "desktop/frontend/src/App.tsx"
|
||||
- "desktop/frontend/src/components/SettingsPanel.tsx"
|
||||
- "desktop/frontend/src/components/UpdateBanner.tsx"
|
||||
|
||||
@@ -36,6 +36,8 @@ mem.prof
|
||||
.env
|
||||
.env.local
|
||||
*.local
|
||||
**/.firebase-crash-migration-state.json
|
||||
**/.firebase-crash-migration-state.json.tmp-*
|
||||
/reasonix.toml
|
||||
/desktop/.env
|
||||
/desktop/reasonix.toml
|
||||
|
||||
@@ -20,6 +20,26 @@ branch.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Compact MCP discovery:** `use_capability(action=list)` now returns one
|
||||
compact summary per configured MCP server instead of expanding every cached
|
||||
tool description, including tools from disabled servers. Inspecting one
|
||||
enabled `mcp-server:<name>` still returns its live or cached directory
|
||||
without starting it, while direct known-ID calls, routing, authorization,
|
||||
and the fixed provider-visible tool schema remain unchanged.
|
||||
|
||||
- **Project MCP session reliability:** The MCP client now uses the official Go
|
||||
SDK for stdio, legacy SSE, and Streamable HTTP while retaining Reasonix's
|
||||
existing configuration, OAuth, process isolation, and schema-cache contracts.
|
||||
Streamable HTTP opens its long-lived GET/SSE listener immediately after
|
||||
initialization, so JetBrains project-level `.mcp.json` servers no longer lose
|
||||
their pending session before the first tool call. Lost sessions converge on
|
||||
one bounded rebuild and one replay, read-only surfaces consume every cursor
|
||||
page, prompts/resources share the tool session, and shutdown terminates HTTP
|
||||
sessions and local processes. MCP calls also accept a single JSON-object
|
||||
string in `use_capability.arguments`, while rejecting arrays, scalars, invalid
|
||||
JSON, and nested encoded strings. `/mcp` and Desktop expose redacted protocol,
|
||||
listening, reconnect, and error-category diagnostics without session IDs.
|
||||
|
||||
- **v1.24.2 session snapshot & recovery root fix:** Keep PR #7982's WAL/CAS/lease
|
||||
safety foundation, but replace process-level "I hold a lease" ownership with a
|
||||
generation-bound `SessionWriteAuthority`. Same-revision tool-preview/load
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"reasonix/internal/cli"
|
||||
"reasonix/internal/config"
|
||||
"reasonix/internal/crashreport"
|
||||
"reasonix/internal/plugin"
|
||||
|
||||
// Blank imports wire compile-time built-ins into their registries.
|
||||
_ "reasonix/internal/provider/anthropic"
|
||||
@@ -36,6 +37,7 @@ var runCLI = func(args []string, buildVersion string) int {
|
||||
}
|
||||
|
||||
func main() {
|
||||
plugin.SetMCPClientVersion(version)
|
||||
os.Exit(runWithCrashCapture(os.Args[1:], version))
|
||||
}
|
||||
|
||||
|
||||
+127
-153
@@ -127,6 +127,11 @@ type PromptHistoryResult struct {
|
||||
type App struct {
|
||||
ctx context.Context
|
||||
workspaceHub *workspaceChangeHub
|
||||
topicState *topicStateManager
|
||||
// topicTitleMutationMu keeps the authoritative title commit and its Tab /
|
||||
// session-sidecar publication in the same order for manual and automatic
|
||||
// renames. It is never held by generic topic-state reads or other metadata.
|
||||
topicTitleMutationMu sync.Mutex
|
||||
|
||||
// sessionCatalog is a disposable, asynchronously opened projection of
|
||||
// authoritative session sidecars. Project-shell APIs must tolerate nil here:
|
||||
@@ -165,6 +170,9 @@ type App struct {
|
||||
tabOrder []string
|
||||
activeTabID string
|
||||
readyHook func()
|
||||
// tabSelectionMu serializes cross-registry activation. A remote selection
|
||||
// must not overtake the local-session snapshot that makes switching safe.
|
||||
tabSelectionMu sync.Mutex
|
||||
|
||||
// Ticketed topic activation bookkeeping (StartTopicActivation). Guarded by
|
||||
// mu. activationGen bumps on every activation-or-supersede so a background
|
||||
@@ -335,6 +343,22 @@ type App struct {
|
||||
remoteWindows *remoteWindowRegistry
|
||||
remoteWindowLifecycles remoteWindowLifecycleRegistry
|
||||
remoteWindowOpener func(remoteWindowLaunch) error // test-only injection
|
||||
// Remote project tabs are in-app surfaces bound to a remote workspace.
|
||||
// Project pins persist in user config; open tab shells persist separately
|
||||
// and restore disconnected until the user activates them.
|
||||
remoteTabMu sync.Mutex
|
||||
remoteTabs map[string]*remoteTab
|
||||
remoteTabLayout remoteTabLayoutState
|
||||
remoteTabTasks sync.WaitGroup
|
||||
// remoteTabModelMu makes the caller's current-model snapshot, the remote
|
||||
// Serve rebuild, and the tab metadata commit one transaction. Without it,
|
||||
// overlapping switches could roll remote config back to a stale model.
|
||||
remoteTabModelMu sync.Mutex
|
||||
// remoteEventHook observes remote events in tests; production leaves it nil.
|
||||
remoteEventHook func(name string, payload any)
|
||||
// credProxy is the lazy app-wide key holder for local-proxy mode.
|
||||
credProxyMu sync.Mutex
|
||||
credProxy *credentialProxy
|
||||
// remoteWindowTicket/remoteWindowHostKey are set from argv before Wails
|
||||
// starts in a child process. They gate the blank-shell middleware and the
|
||||
// startup branches so the child never initializes local runtimes.
|
||||
@@ -422,6 +446,7 @@ func NewApp() *App {
|
||||
botRuntime: newDesktopBotRuntime(),
|
||||
remoteWindows: newRemoteWindowRegistry(),
|
||||
remoteWindowOwnerID: newRemoteWindowOwnerID(),
|
||||
topicState: desktopTopicState,
|
||||
}
|
||||
a.desktopShell.trayState = "probing"
|
||||
a.webView2Recovery = newWebView2RecoveryCoordinator(a)
|
||||
@@ -475,6 +500,9 @@ func (a *App) startup(ctx context.Context) {
|
||||
slog.Debug("desktop: repair native icon integration", "err", err)
|
||||
}
|
||||
})
|
||||
a.goSafe("applyWindowIconsFromExecutable", func() {
|
||||
applyWindowIconsFromExecutable()
|
||||
})
|
||||
|
||||
if cfg, err := config.Load(); err == nil && cfg.DesktopMetrics() && version != "dev" {
|
||||
a.metrics.Store(newMetricsAggregator(config.MemoryUserDir()))
|
||||
@@ -706,7 +734,9 @@ func (a *App) restoreOrBuildTabs() {
|
||||
if cfgErr != nil || singleSurfaceLayoutStyle(startupCfg.DesktopLayoutStyle()) {
|
||||
f = singleSurfaceTabsFile(f)
|
||||
}
|
||||
|
||||
// Restore remote tabs as disconnected shells; activation performs the
|
||||
// first network work so desktop startup remains offline-safe.
|
||||
a.restoreRemoteTabShells(f)
|
||||
if len(f.Tabs) > 0 {
|
||||
toBuild := make([]*WorkspaceTab, 0, len(f.Tabs))
|
||||
for _, entry := range f.Tabs {
|
||||
@@ -767,6 +797,11 @@ func (a *App) restoreOrBuildTabs() {
|
||||
}
|
||||
return
|
||||
}
|
||||
if len(f.RemoteTabs) > 0 {
|
||||
// A remote-only single-surface layout is restored above as disconnected
|
||||
// shells. It is not a first launch and must not grow a fallback Global tab.
|
||||
return
|
||||
}
|
||||
|
||||
// First launch: create a default Global tab.
|
||||
tab := a.createTabEntry("global", globalTabWorkspaceRoot(), "")
|
||||
@@ -1754,6 +1789,9 @@ func (a *App) SetCollaborationMode(mode string) {
|
||||
// turn cannot observe collaboration, approval, and goal from different UI
|
||||
// generations.
|
||||
func (a *App) SetComposerProfileForTab(tabID, collaborationMode, toolApprovalMode, goal string) ([]string, error) {
|
||||
if a.isRemoteTab(tabID) {
|
||||
return []string{}, nil
|
||||
}
|
||||
collaborationMode = normalizeCollaborationMode(collaborationMode)
|
||||
toolApprovalMode = normalizeToolApprovalMode(toolApprovalMode)
|
||||
goal = strings.TrimSpace(goal)
|
||||
@@ -2038,8 +2076,7 @@ func (a *App) assignFreshSessionTopic(tab *WorkspaceTab) {
|
||||
// topic index repair fails here, keep the session usable and let persisted
|
||||
// session metadata repair the topic index later instead of surfacing a false
|
||||
// "new session failed" error to the frontend.
|
||||
_ = ensureTopicIndexed(scope, workspaceRoot, topicID, defaultTopicTitle, topicTitleSourceAuto)
|
||||
_ = setTopicCreatedAt(topicTitleRoot(scope, workspaceRoot), topicID, time.Now().UnixMilli())
|
||||
_ = ensureTopicIndexedWithCreatedAt(scope, workspaceRoot, topicID, defaultTopicTitle, topicTitleSourceAuto, time.Now().UnixMilli())
|
||||
}
|
||||
|
||||
func (a *App) ensureTabTopicIndexedForUserTurn(tab *WorkspaceTab) {
|
||||
@@ -2069,8 +2106,7 @@ func (a *App) ensureTabTopicIndexedForUserTurn(tab *WorkspaceTab) {
|
||||
workspaceRoot = normalizeProjectRoot(workspaceRoot)
|
||||
}
|
||||
|
||||
_ = ensureTopicIndexed(scope, workspaceRoot, topicID, defaultTopicTitle, topicTitleSourceAuto)
|
||||
_ = setTopicCreatedAt(topicTitleRoot(scope, workspaceRoot), topicID, time.Now().UnixMilli())
|
||||
_ = ensureTopicIndexedWithCreatedAt(scope, workspaceRoot, topicID, defaultTopicTitle, topicTitleSourceAuto, time.Now().UnixMilli())
|
||||
path := a.currentSessionPathFor(tab)
|
||||
a.persistTabSessionPath(tab, path)
|
||||
a.emitProjectTreeChangedForSessionDirs(sessionDirectoryForPath(path))
|
||||
@@ -6506,6 +6542,12 @@ func (a *App) CancelJobForTab(tabID, jobID string) (bool, error) {
|
||||
return false, fmt.Errorf("job id is required")
|
||||
}
|
||||
if tabID != "" {
|
||||
if a.isRemoteTab(tabID) {
|
||||
if err := a.CancelRemoteTabJobs(tabID, []string{jobID}); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
ctrl := a.ctrlForRuntimeTabID(tabID)
|
||||
if ctrl != nil {
|
||||
return cancelJobForController(ctrl, jobID)
|
||||
@@ -6538,24 +6580,25 @@ func (a *App) jobsForCtrl(ctrl control.SessionAPI, out []JobView) []JobView {
|
||||
|
||||
// Meta describes the session for the frontend's header and status line.
|
||||
type Meta struct {
|
||||
Label string `json:"label"`
|
||||
Ready bool `json:"ready"`
|
||||
Runtime SessionRuntimeView `json:"runtime"`
|
||||
StartupErr string `json:"startupErr,omitempty"`
|
||||
EventChannel string `json:"eventChannel"`
|
||||
SessionPath string `json:"sessionPath,omitempty"`
|
||||
SessionRevision int64 `json:"sessionRevision,omitempty"`
|
||||
SessionDigest string `json:"sessionDigest,omitempty"`
|
||||
Cwd string `json:"cwd"`
|
||||
WorkspaceRoot string `json:"workspaceRoot,omitempty"`
|
||||
WorkspaceName string `json:"workspaceName,omitempty"`
|
||||
WorkspacePath string `json:"workspacePath,omitempty"`
|
||||
GitBranch string `json:"gitBranch,omitempty"`
|
||||
ImageInputEnabled bool `json:"imageInputEnabled"`
|
||||
AutoApproveTools bool `json:"autoApproveTools"`
|
||||
Bypass bool `json:"bypass"` // legacy JSON key for YOLO/full-access tool auto-approval
|
||||
CollaborationMode string `json:"collaborationMode"`
|
||||
ToolApprovalMode string `json:"toolApprovalMode"`
|
||||
Label string `json:"label"`
|
||||
Ready bool `json:"ready"`
|
||||
Runtime SessionRuntimeView `json:"runtime"`
|
||||
StartupErr string `json:"startupErr,omitempty"`
|
||||
EventChannel string `json:"eventChannel"`
|
||||
SessionPath string `json:"sessionPath,omitempty"`
|
||||
SessionRevision int64 `json:"sessionRevision,omitempty"`
|
||||
SessionDigest string `json:"sessionDigest,omitempty"`
|
||||
Cwd string `json:"cwd"`
|
||||
WorkspaceRoot string `json:"workspaceRoot,omitempty"`
|
||||
WorkspaceName string `json:"workspaceName,omitempty"`
|
||||
WorkspacePath string `json:"workspacePath,omitempty"`
|
||||
GitBranch string `json:"gitBranch,omitempty"`
|
||||
ImageInputEnabled bool `json:"imageInputEnabled"`
|
||||
VisionFallbackEnabled bool `json:"visionFallbackEnabled,omitempty"`
|
||||
AutoApproveTools bool `json:"autoApproveTools"`
|
||||
Bypass bool `json:"bypass"` // legacy JSON key for YOLO/full-access tool auto-approval
|
||||
CollaborationMode string `json:"collaborationMode"`
|
||||
ToolApprovalMode string `json:"toolApprovalMode"`
|
||||
// TokenMode and AgentPreset are deprecated dual-write wire values pinned to
|
||||
// their safe defaults; one-version-old frontends still parse them.
|
||||
TokenMode string `json:"tokenMode"`
|
||||
@@ -6567,6 +6610,9 @@ type Meta struct {
|
||||
CanonicalTodos *[]evidence.TodoItem `json:"canonicalTodos,omitempty"`
|
||||
// Closed completed todo fingerprints from this session and its lineage.
|
||||
DismissedTodoBatches []string `json:"dismissedTodoBatches,omitempty"`
|
||||
// Remote marks a remote session tab; its readiness is carried by the
|
||||
// remote-tab state channel rather than a local controller.
|
||||
Remote *RemoteTabRef `json:"remote,omitempty"`
|
||||
}
|
||||
|
||||
type GoalRuntimeView struct {
|
||||
@@ -6610,24 +6656,11 @@ func (a *App) Meta() Meta {
|
||||
return a.MetaForTab("")
|
||||
}
|
||||
|
||||
// imageInputEnabledForRootModel reports whether the resolved model supports
|
||||
// image input. EXPENSIVE: it loads the workspace config and resolves the model
|
||||
// catalog. Never call it on a bound-method request path — MetaForTab serves
|
||||
// the cached tabMetaExtras instead, and only refreshTabMetaExtras (background)
|
||||
// calls this.
|
||||
func (a *App) imageInputEnabledForRootModel(root, ref string) bool {
|
||||
func (a *App) loadConfigForVision(root string) (*config.Config, error) {
|
||||
if hook := a.configLoadForRootHook; hook != nil {
|
||||
hook(root)
|
||||
}
|
||||
cfg, err := config.LoadForRoot(root)
|
||||
if err == nil && ref == "" {
|
||||
ref = cfg.DefaultModel
|
||||
}
|
||||
if err != nil || ref == "" {
|
||||
return false
|
||||
}
|
||||
entry, ok := cfg.ResolveModel(ref)
|
||||
return ok && config.EffectiveVision(entry)
|
||||
return config.LoadForRoot(root)
|
||||
}
|
||||
|
||||
func (a *App) MetaForTab(tabID string) Meta {
|
||||
@@ -6637,7 +6670,11 @@ func (a *App) MetaForTab(tabID string) Meta {
|
||||
runtimeView := a.sessionRuntimeViewLocked(tab)
|
||||
a.mu.RUnlock()
|
||||
if tab == nil {
|
||||
return Meta{EventChannel: eventChannel}
|
||||
meta := Meta{EventChannel: eventChannel}
|
||||
if ref, ok := a.remoteTabRefFor(tabID); ok {
|
||||
meta.Remote = &ref
|
||||
}
|
||||
return meta
|
||||
}
|
||||
cwd := snap.workspaceRoot
|
||||
if cwd == "" {
|
||||
@@ -6669,31 +6706,32 @@ func (a *App) MetaForTab(tabID string) Meta {
|
||||
sessionDigest = branchMeta.ContentDigest
|
||||
}
|
||||
return Meta{
|
||||
Label: snap.label,
|
||||
Ready: runtimeView.Phase == sessionRuntimeReady && snap.ctrl != nil,
|
||||
Runtime: runtimeView,
|
||||
StartupErr: snap.startupErr,
|
||||
EventChannel: eventChannel,
|
||||
SessionPath: sessionPath,
|
||||
SessionRevision: sessionRevision,
|
||||
SessionDigest: sessionDigest,
|
||||
Cwd: cwd,
|
||||
WorkspaceRoot: cwd,
|
||||
WorkspaceName: tabWorkspaceNameForScope(snap.scope, cwd),
|
||||
WorkspacePath: cwd,
|
||||
GitBranch: extras.gitBranch,
|
||||
ImageInputEnabled: extras.imageInputEnabled,
|
||||
AutoApproveTools: autoApproveTools,
|
||||
Bypass: autoApproveTools,
|
||||
CollaborationMode: collaborationMode,
|
||||
TokenMode: tokenMode,
|
||||
AgentPreset: agentPreset,
|
||||
ToolApprovalMode: toolApprovalMode,
|
||||
Goal: goal,
|
||||
GoalStatus: goalStatus,
|
||||
GoalRuntime: goalRuntimeViewFromController(snap.ctrl),
|
||||
CanonicalTodos: ctrlTodos(snap.ctrl),
|
||||
DismissedTodoBatches: a.dismissedTodoBatchesForSession(sessionPath),
|
||||
Label: snap.label,
|
||||
Ready: runtimeView.Phase == sessionRuntimeReady && snap.ctrl != nil,
|
||||
Runtime: runtimeView,
|
||||
StartupErr: snap.startupErr,
|
||||
EventChannel: eventChannel,
|
||||
SessionPath: sessionPath,
|
||||
SessionRevision: sessionRevision,
|
||||
SessionDigest: sessionDigest,
|
||||
Cwd: cwd,
|
||||
WorkspaceRoot: cwd,
|
||||
WorkspaceName: tabWorkspaceNameForScope(snap.scope, cwd),
|
||||
WorkspacePath: cwd,
|
||||
GitBranch: extras.gitBranch,
|
||||
ImageInputEnabled: extras.imageInputEnabled,
|
||||
VisionFallbackEnabled: extras.visionFallbackEnabled,
|
||||
AutoApproveTools: autoApproveTools,
|
||||
Bypass: autoApproveTools,
|
||||
CollaborationMode: collaborationMode,
|
||||
TokenMode: tokenMode,
|
||||
AgentPreset: agentPreset,
|
||||
ToolApprovalMode: toolApprovalMode,
|
||||
Goal: goal,
|
||||
GoalStatus: goalStatus,
|
||||
GoalRuntime: goalRuntimeViewFromController(snap.ctrl),
|
||||
CanonicalTodos: ctrlTodos(snap.ctrl),
|
||||
DismissedTodoBatches: a.dismissedTodoBatchesForSession(sessionPath),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7066,6 +7104,10 @@ type ServerView struct {
|
||||
Status string `json:"status"`
|
||||
StartIntent string `json:"startIntent,omitempty"` // deprecated: derived from Enabled
|
||||
RuntimeState string `json:"runtimeState,omitempty"`
|
||||
ProtocolVersion string `json:"protocolVersion,omitempty"`
|
||||
SessionState string `json:"sessionState,omitempty"`
|
||||
ReconnectAttempts int `json:"reconnectAttempts,omitempty"`
|
||||
ErrorKind string `json:"errorKind,omitempty"`
|
||||
Availability string `json:"availability,omitempty"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Installed bool `json:"installed"`
|
||||
@@ -7720,12 +7762,7 @@ func (a *App) mcpServersView() []ServerView {
|
||||
}
|
||||
seen[s.Name] = true
|
||||
connected[s.Name] = true
|
||||
view := ServerView{
|
||||
Name: s.Name, Transport: s.Transport, Status: "connected", RuntimeState: "ready",
|
||||
Tools: s.Tools, Prompts: s.Prompts, Resources: s.Resources,
|
||||
HasTools: s.HasTools,
|
||||
ToolList: pluginToolsToView(s.ToolList),
|
||||
}
|
||||
view := pluginServerToView(s)
|
||||
if p, ok := configured[s.Name]; ok {
|
||||
view = withPluginConfigInWorkspace(view, p, workspaceRoot)
|
||||
}
|
||||
@@ -9184,13 +9221,7 @@ func findMCPServerView(ctrl control.SessionAPI, name string) (ServerView, bool)
|
||||
}
|
||||
for _, s := range ctrl.Host().Servers() {
|
||||
if s.Name == name {
|
||||
view := ServerView{
|
||||
Name: s.Name, Transport: s.Transport, Status: "connected",
|
||||
Tools: s.Tools, Prompts: s.Prompts, Resources: s.Resources,
|
||||
HasTools: s.HasTools,
|
||||
ToolList: pluginToolsToView(s.ToolList),
|
||||
}
|
||||
return view, true
|
||||
return pluginServerToView(s), true
|
||||
}
|
||||
}
|
||||
for _, f := range ctrl.Host().Failures() {
|
||||
@@ -9309,45 +9340,6 @@ func (a *App) Models() []ModelInfo {
|
||||
return a.ModelsForTab("")
|
||||
}
|
||||
|
||||
func (a *App) ModelsForTab(tabID string) []ModelInfo {
|
||||
a.mu.RLock()
|
||||
curModel := ""
|
||||
workspaceRoot := ""
|
||||
var ctrl control.SessionAPI
|
||||
if tab := a.tabByIDLocked(tabID); tab != nil {
|
||||
curModel = tab.model
|
||||
workspaceRoot = tab.WorkspaceRoot
|
||||
ctrl = tab.Ctrl
|
||||
}
|
||||
a.mu.RUnlock()
|
||||
// The tab controller's merged catalog carries extension sidecar providers
|
||||
// (plugin/... refs). Read it off-lock: a cold catalog fetch can block on a
|
||||
// sidecar RPC and must never park a.mu.
|
||||
var extensionCatalog []provider.Descriptor
|
||||
if ctrl != nil {
|
||||
extensionCatalog = ctrl.ProviderCatalog()
|
||||
}
|
||||
cfg, err := config.LoadForRoot(workspaceRoot)
|
||||
if err != nil {
|
||||
return []ModelInfo{}
|
||||
}
|
||||
if entry, ok := cfg.ResolveModel(curModel); ok {
|
||||
curModel = entry.Name + "/" + entry.Model
|
||||
}
|
||||
out := []ModelInfo{}
|
||||
for i := range cfg.Providers {
|
||||
p := &cfg.Providers[i]
|
||||
if !modelProviderAccessAllowed(cfg.Desktop.ProviderAccess, p.Name) || !p.Configured() {
|
||||
continue
|
||||
}
|
||||
for _, m := range p.ChatModelList() {
|
||||
ref := p.Name + "/" + m
|
||||
out = append(out, ModelInfo{Ref: ref, Provider: p.Name, Model: m, Current: ref == curModel})
|
||||
}
|
||||
}
|
||||
return mergeExtensionModelInfos(out, extensionCatalog, curModel)
|
||||
}
|
||||
|
||||
// mergeExtensionModelInfos adds namespaced plugin models from the controller's
|
||||
// merged provider catalog. Base descriptors are already represented by out;
|
||||
// plugin refs need no provider-access gate because enabling the package grants
|
||||
@@ -9524,24 +9516,23 @@ func sessionPathAfterSnapshot(ctrl control.SessionAPI, fallback string) string {
|
||||
|
||||
var (
|
||||
// sessionLeaseContentionRetryInterval and sessionLeaseContentionRetryAttempts
|
||||
// bound the retry window for startup session-lease binds that hit a
|
||||
// transient in-process holder. CleanupStaleRunning probes a running
|
||||
// sub-agent's parent session lease inside every controller build, holding
|
||||
// it only for the duration of a metadata rewrite (sub-millisecond); a
|
||||
// concurrent tab build that races that probe must not surface a spurious
|
||||
// "already open in another Reasonix window" error for a lease that is
|
||||
// genuinely free once the probe releases it. A lease held by another
|
||||
// window or process stays held for its whole lifetime, so the bounded
|
||||
// retry still fails fast there.
|
||||
// bound the retry window for lease or removal-guard acquisition that hits a
|
||||
// transient in-process holder. CleanupStaleRunning and catalog persistence
|
||||
// can hold the session lease or save lock briefly while a concurrent tab
|
||||
// bind or archive begins; those callers must not surface a spurious
|
||||
// "already open in another Reasonix window" error for ownership that is
|
||||
// genuinely free once the short operation finishes. A lease held by another
|
||||
// window or process stays held for its whole lifetime, so the bounded retry
|
||||
// still fails fast there.
|
||||
sessionLeaseContentionRetryInterval = 50 * time.Millisecond
|
||||
sessionLeaseContentionRetryAttempts = 2
|
||||
)
|
||||
|
||||
// withSessionLeaseContentionRetry retries acquire while it fails with
|
||||
// agent.ErrSessionLeaseHeld, absorbing sub-second contention windows created
|
||||
// by transient in-process lease probes. Any other error is returned
|
||||
// immediately, and a lease that remains held after the bounded retries is
|
||||
// reported as-is.
|
||||
// by transient in-process lease or save-lock holders. Any other error is
|
||||
// returned immediately, and a lease that remains held after the bounded
|
||||
// retries is reported as-is.
|
||||
func withSessionLeaseContentionRetry[T any](acquire func() (T, error)) (T, error) {
|
||||
var zero T
|
||||
for attempt := 0; ; attempt++ {
|
||||
@@ -9674,7 +9665,13 @@ type modelSwitchTiming struct {
|
||||
}
|
||||
|
||||
func (a *App) SetModelForTab(tabID, name string) (retErr error) {
|
||||
if a.ctx == nil || name == "" {
|
||||
if name == "" {
|
||||
return nil
|
||||
}
|
||||
if a.isRemoteTab(tabID) {
|
||||
return a.SetRemoteTabModel(tabID, name)
|
||||
}
|
||||
if a.ctx == nil {
|
||||
return nil
|
||||
}
|
||||
tab := a.tabByID(tabID)
|
||||
@@ -9689,30 +9686,7 @@ func (a *App) SetModelForTab(tabID, name string) (retErr error) {
|
||||
}
|
||||
timing := modelSwitchTiming{}
|
||||
totalStarted := time.Now()
|
||||
defer func() {
|
||||
timing.Total = time.Since(totalStarted)
|
||||
if retErr != nil {
|
||||
timing.Outcome = "failed"
|
||||
} else {
|
||||
timing.Outcome = "ok"
|
||||
}
|
||||
slog.Debug(
|
||||
"desktop: model switch timing",
|
||||
"tab", tab.ID,
|
||||
"outcome", timing.Outcome,
|
||||
"total_ms", timing.Total.Milliseconds(),
|
||||
"lock_wait_ms", timing.LockWait.Milliseconds(),
|
||||
"prepare_ms", timing.Prepare.Milliseconds(),
|
||||
"config_ms", timing.Config.Milliseconds(),
|
||||
"snapshot_ms", timing.Snapshot.Milliseconds(),
|
||||
"build_ms", timing.Build.Milliseconds(),
|
||||
"lease_resume_ms", timing.LeaseAndResume.Milliseconds(),
|
||||
"swap_persist_ms", timing.SwapAndPersist.Milliseconds(),
|
||||
)
|
||||
if a.modelSwitchTimingHook != nil {
|
||||
a.modelSwitchTimingHook(timing)
|
||||
}
|
||||
}()
|
||||
defer a.recordModelSwitchTiming(tab.ID, &timing, totalStarted, &retErr)
|
||||
// Same build+swap shape as rebuildSetting; hold the same lock so a settings
|
||||
// rebuild (manual or from the deferred-rebuild retry loop) and a model
|
||||
// switch cannot interleave on one tab.
|
||||
|
||||
@@ -525,6 +525,7 @@ func TestEnsureBlankTabKeepsActiveTabWhenTitleResetFails(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
projectRoot := t.TempDir()
|
||||
seedLegacyTopicBridge(t, projectRoot)
|
||||
app := NewApp()
|
||||
topic, err := app.CreateTopic("project", projectRoot, "")
|
||||
if err != nil {
|
||||
|
||||
+184
-7
@@ -237,11 +237,11 @@ func isolateDesktopUserDirs(t *testing.T) string {
|
||||
t.Setenv("REASONIX_STATE_HOME", filepath.Join(home, "state"))
|
||||
t.Setenv("REASONIX_CACHE_HOME", filepath.Join(home, "cache"))
|
||||
t.Setenv("AppData", appData)
|
||||
// Process-local catalog projections pin SQLite files under cache. Close them
|
||||
// before TempDir cleanup so Windows does not fail unlinkat on open handles.
|
||||
// Close process-local SQLite handles before TempDir cleanup for Windows.
|
||||
t.Cleanup(func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||
defer cancel()
|
||||
desktopTopicState.close()
|
||||
_ = history.CloseSharedCatalog(ctx)
|
||||
_ = stats.CloseUsageCatalogs(ctx)
|
||||
_ = taskcatalog.ShutdownShared(ctx)
|
||||
@@ -1513,7 +1513,7 @@ api_key_env = "DEEPSEEK_API_KEY"
|
||||
if !p.BuiltIn {
|
||||
t.Fatalf("deepseek provider should be marked built-in for official endpoint: %+v", p)
|
||||
}
|
||||
if !p.Added || !p.KeySet || len(p.Models) != 2 || p.Models[0] != "deepseek-v4-flash" || p.Models[1] != "deepseek-v4-pro" || p.Default != "deepseek-v4-flash" {
|
||||
if !p.Added || !p.KeySet || len(p.Models) != 3 || p.Models[0] != "deepseek-v4-flash" || p.Models[1] != "deepseek-v4-pro" || p.Models[2] != "deepseek-v4-flash-vision-exp" || !slices.Equal(p.VisionModels, []string{"deepseek-v4-flash-vision-exp"}) || p.Default != "deepseek-v4-flash" {
|
||||
t.Fatalf("deepseek provider = %+v, want added repaired official model list", p)
|
||||
}
|
||||
if got.DefaultModel != "deepseek/deepseek-v4-flash" {
|
||||
@@ -1704,7 +1704,7 @@ api_key_env = "DEEPSEEK_API_KEY"
|
||||
if !ok {
|
||||
t.Fatal("deepseek provider not saved")
|
||||
}
|
||||
if len(p.Models) != 2 || p.Models[0] != "deepseek-v4-flash" || p.Models[1] != "deepseek-v4-pro" || p.Default != "deepseek-v4-flash" {
|
||||
if len(p.Models) != 3 || p.Models[0] != "deepseek-v4-flash" || p.Models[1] != "deepseek-v4-pro" || p.Models[2] != "deepseek-v4-flash-vision-exp" || !slices.Equal(p.VisionModels, []string{"deepseek-v4-flash-vision-exp"}) || p.Default != "deepseek-v4-flash" {
|
||||
t.Fatalf("deepseek provider after add = %+v, want official model list", p)
|
||||
}
|
||||
if !providerAccessSet(cfg.Desktop.ProviderAccess)["deepseek"] {
|
||||
@@ -1735,6 +1735,9 @@ func TestSettingsSurfacesCuratedProviderPresets(t *testing.T) {
|
||||
if preset.KeyEnv == "" || len(preset.ProviderNames) == 0 || len(preset.Models) == 0 {
|
||||
t.Fatalf("preset %q view has missing fields: %+v", id, preset)
|
||||
}
|
||||
if preset.ID == "opencode-go-recommended" && (preset.DisplayGroup != "opencode" || preset.DisplaySection != "go" || preset.DisplayTier != "primary" || preset.RouteKind != "bundle") {
|
||||
t.Fatalf("recommended OpenCode metadata = %+v", preset)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2140,6 +2143,180 @@ func TestAddEveryProviderPresetAccessInstallsTemplate(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddOpenCodeGoRecommendedPresetCompletesMissingRoutes(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
t.Setenv("OPENCODE_GO_API_KEY", "")
|
||||
os.Unsetenv("OPENCODE_GO_API_KEY")
|
||||
|
||||
preset, ok := config.CuratedProviderPreset("opencode-go-recommended")
|
||||
if !ok || len(preset.Entries) != 3 {
|
||||
t.Fatalf("recommended preset = %+v, found=%v", preset, ok)
|
||||
}
|
||||
cfg := config.Default()
|
||||
seed := preset.Entries[0]
|
||||
seed.PresetID = "opencode-go"
|
||||
if err := cfg.UpsertProvider(seed); err != nil {
|
||||
t.Fatalf("seed existing OpenCode Go route: %v", err)
|
||||
}
|
||||
if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
|
||||
t.Fatalf("save seed config: %v", err)
|
||||
}
|
||||
partial := providerPresetViewByID(t, NewApp().Settings(), preset.ID)
|
||||
if partial.Status != providerPresetStatusPartial || len(partial.MissingProviderNames) != 2 {
|
||||
t.Fatalf("recommended preset partial view = %+v, want two missing routes", partial)
|
||||
}
|
||||
|
||||
if warning, err := NewApp().AddProviderPresetAccess(preset.ID, "sk-opencode"); err != nil {
|
||||
t.Fatalf("AddProviderPresetAccess: %v", err)
|
||||
} else if warning != "" {
|
||||
t.Fatalf("AddProviderPresetAccess warning = %q, want none", warning)
|
||||
}
|
||||
|
||||
cfg = config.LoadForEdit(config.UserConfigPath())
|
||||
for _, entry := range preset.Entries {
|
||||
if _, ok := cfg.Provider(entry.Name); !ok {
|
||||
t.Fatalf("missing recommended route %q after completion", entry.Name)
|
||||
}
|
||||
}
|
||||
data, err := os.ReadFile(config.UserCredentialsPath())
|
||||
if err != nil {
|
||||
t.Fatalf("read saved credentials: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(data), "OPENCODE_GO_API_KEY=sk-opencode") {
|
||||
t.Fatalf("saved credentials missing Go key: %s", data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddOpenCodeGoRecommendedPresetSelectsUsableDefaultForFreshSetup(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
t.Setenv("OPENCODE_GO_API_KEY", "")
|
||||
os.Unsetenv("OPENCODE_GO_API_KEY")
|
||||
|
||||
cfg := config.Default()
|
||||
if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
|
||||
t.Fatalf("save fresh config: %v", err)
|
||||
}
|
||||
if _, err := NewApp().AddProviderPresetAccess("opencode-go-recommended", "sk-opencode"); err != nil {
|
||||
t.Fatalf("AddProviderPresetAccess: %v", err)
|
||||
}
|
||||
|
||||
got := config.LoadForEdit(config.UserConfigPath())
|
||||
if got.DefaultModel != "opencode-go/glm-5.3" {
|
||||
t.Fatalf("default model = %q, want ready-to-use OpenCode Go default", got.DefaultModel)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddOpenCodeGoRecommendedPresetPreservesConfiguredDefault(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
t.Setenv("OPENCODE_GO_API_KEY", "")
|
||||
os.Unsetenv("OPENCODE_GO_API_KEY")
|
||||
|
||||
cfg := config.Default()
|
||||
if err := cfg.UpsertProvider(config.ProviderEntry{
|
||||
Name: "local-ready",
|
||||
Kind: "openai",
|
||||
BaseURL: "http://127.0.0.1:11434/v1",
|
||||
Models: []string{"local-model"},
|
||||
Default: "local-model",
|
||||
}); err != nil {
|
||||
t.Fatalf("upsert configured provider: %v", err)
|
||||
}
|
||||
if err := cfg.SetDefaultModel("local-ready/local-model"); err != nil {
|
||||
t.Fatalf("set configured default: %v", err)
|
||||
}
|
||||
if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
|
||||
t.Fatalf("save configured default: %v", err)
|
||||
}
|
||||
|
||||
if _, err := NewApp().AddProviderPresetAccess("opencode-go-recommended", "sk-opencode"); err != nil {
|
||||
t.Fatalf("AddProviderPresetAccess: %v", err)
|
||||
}
|
||||
if got := config.LoadForEdit(config.UserConfigPath()).DefaultModel; got != "local-ready/local-model" {
|
||||
t.Fatalf("default model = %q, want existing configured default preserved", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddOpenCodeGoRecommendedPresetPreservesModifiedRoute(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
t.Setenv("OPENCODE_GO_API_KEY", "")
|
||||
os.Unsetenv("OPENCODE_GO_API_KEY")
|
||||
|
||||
preset, ok := config.CuratedProviderPreset("opencode-go-recommended")
|
||||
if !ok || len(preset.Entries) != 3 {
|
||||
t.Fatalf("recommended preset = %+v, found=%v", preset, ok)
|
||||
}
|
||||
cfg := config.Default()
|
||||
modified := preset.Entries[0]
|
||||
modified.BaseURL = "https://custom.example/v1"
|
||||
modified.PresetID = "opencode-go"
|
||||
if err := cfg.UpsertProvider(modified); err != nil {
|
||||
t.Fatalf("seed modified OpenCode Go route: %v", err)
|
||||
}
|
||||
if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
|
||||
t.Fatalf("save modified config: %v", err)
|
||||
}
|
||||
|
||||
if _, err := NewApp().AddProviderPresetAccess(preset.ID, "sk-opencode"); err != nil {
|
||||
t.Fatalf("AddProviderPresetAccess: %v", err)
|
||||
}
|
||||
cfg = config.LoadForEdit(config.UserConfigPath())
|
||||
got, ok := cfg.Provider("opencode-go")
|
||||
if !ok || got.BaseURL != "https://custom.example/v1" {
|
||||
t.Fatalf("modified route = %+v, want preserved custom endpoint", got)
|
||||
}
|
||||
for _, name := range []string{"opencode-go-anthropic", "opencode-go-responses"} {
|
||||
if _, ok := cfg.Provider(name); !ok {
|
||||
t.Fatalf("missing route %q after completing bundle around modified route", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddOpenCodeGoRecommendedPresetRejectsConflictAtomically(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
t.Setenv("OPENCODE_GO_API_KEY", "")
|
||||
os.Unsetenv("OPENCODE_GO_API_KEY")
|
||||
|
||||
cfg := config.Default()
|
||||
conflict := config.ProviderEntry{
|
||||
Name: "opencode-go",
|
||||
Kind: "openai",
|
||||
BaseURL: "https://custom.example/v1",
|
||||
Models: []string{"custom-model"},
|
||||
Default: "custom-model",
|
||||
APIKeyEnv: "OPENCODE_GO_API_KEY",
|
||||
PresetID: "custom",
|
||||
PresetVersion: 1,
|
||||
}
|
||||
if err := cfg.UpsertProvider(conflict); err != nil {
|
||||
t.Fatalf("upsert conflicting provider: %v", err)
|
||||
}
|
||||
if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
|
||||
t.Fatalf("save conflict config: %v", err)
|
||||
}
|
||||
|
||||
if warning, err := NewApp().AddProviderPresetAccess("opencode-go-recommended", "sk-should-not-save"); err == nil {
|
||||
t.Fatal("AddProviderPresetAccess unexpectedly accepted same-name conflict")
|
||||
} else if !strings.Contains(err.Error(), "opencode-go") {
|
||||
t.Fatalf("AddProviderPresetAccess error = %v, want opencode-go conflict", err)
|
||||
} else if warning != "" {
|
||||
t.Fatalf("AddProviderPresetAccess warning = %q, want none", warning)
|
||||
}
|
||||
|
||||
cfg = config.LoadForEdit(config.UserConfigPath())
|
||||
if _, ok := cfg.Provider("opencode-go-anthropic"); ok {
|
||||
t.Fatal("conflicting bundle partially installed Anthropic route")
|
||||
}
|
||||
if _, err := os.Stat(config.UserCredentialsPath()); err == nil {
|
||||
data, readErr := os.ReadFile(config.UserCredentialsPath())
|
||||
if readErr != nil {
|
||||
t.Fatalf("read credentials: %v", readErr)
|
||||
}
|
||||
if strings.Contains(string(data), "sk-should-not-save") {
|
||||
t.Fatalf("conflicting bundle saved credentials: %s", data)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddOfficialProviderAccessRejectsBackgroundJobsBeforeSavingKey(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
t.Setenv("DEEPSEEK_API_KEY", "")
|
||||
@@ -3065,8 +3242,8 @@ func TestModelsForTabOnlyListsProviderAccessWhenConfigured(t *testing.T) {
|
||||
t.Fatalf("Models() refs = %+v, should not include hidden provider %s", models, hidden)
|
||||
}
|
||||
}
|
||||
if len(models) != 4 {
|
||||
t.Fatalf("Models() len = %d, want 4: %+v", len(models), models)
|
||||
if len(models) != 5 {
|
||||
t.Fatalf("Models() len = %d, want 5: %+v", len(models), models)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4421,7 +4598,7 @@ func TestListSessionsUsesPinnedSessionOwnerBeforeStaleRuntimeDir(t *testing.T) {
|
||||
app.activeTabID = tab.ID
|
||||
installSessionCatalogForTest(t, app, sessionDirA, "project", projectA)
|
||||
t.Cleanup(oldCtrl.Close)
|
||||
sessions := app.ListSessions()
|
||||
sessions := listSessionsAfterPinnedOwnerReconcile(t, app, sessionDirA, projectA)
|
||||
if len(sessions) == 0 {
|
||||
t.Fatal("ListSessions() returned no sessions")
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -75,9 +76,52 @@ func (v ActiveWorkView) active() bool {
|
||||
// ActiveWorkForTab returns the precise blocker state for one tab. It is a
|
||||
// preflight aid only; rebuild paths still re-check active work atomically.
|
||||
func (a *App) ActiveWorkForTab(tabID string) ActiveWorkView {
|
||||
if a.isRemoteTab(tabID) {
|
||||
view, err := a.remoteActiveWorkForTab(tabID)
|
||||
if err != nil {
|
||||
// A remote tab whose status cannot be observed must fail closed: the
|
||||
// caller must not silently detach potentially mutating work.
|
||||
return ActiveWorkView{Running: true, Cancellable: true, Jobs: []JobView{}}
|
||||
}
|
||||
return view
|
||||
}
|
||||
return activeWorkForController(a.ctrlForRuntimeTabID(tabID))
|
||||
}
|
||||
|
||||
func (a *App) remoteActiveWorkForTab(tabID string) (ActiveWorkView, error) {
|
||||
raw, err := a.RemoteTabStatus(tabID)
|
||||
view := ActiveWorkView{Jobs: []JobView{}}
|
||||
if err != nil {
|
||||
return view, err
|
||||
}
|
||||
var status struct {
|
||||
Running bool `json:"running"`
|
||||
PendingPrompt bool `json:"pendingPrompt"`
|
||||
BackgroundJobs int `json:"backgroundJobs"`
|
||||
Cancellable bool `json:"cancellable"`
|
||||
Jobs []struct {
|
||||
ID string `json:"id"`
|
||||
Kind string `json:"kind"`
|
||||
Label string `json:"label"`
|
||||
Status string `json:"status"`
|
||||
StartedAt int64 `json:"startedAt"`
|
||||
} `json:"jobs"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &status); err != nil {
|
||||
return view, err
|
||||
}
|
||||
view.Running = status.Running || status.BackgroundJobs > 0 || len(status.Jobs) > 0
|
||||
view.PendingPrompt = status.PendingPrompt
|
||||
view.Cancellable = status.Cancellable || status.Running || status.PendingPrompt || status.BackgroundJobs > 0 || len(status.Jobs) > 0
|
||||
for _, job := range status.Jobs {
|
||||
view.Jobs = append(view.Jobs, JobView{
|
||||
ID: job.ID, Kind: job.Kind, Label: job.Label,
|
||||
Status: job.Status, StartedAt: job.StartedAt,
|
||||
})
|
||||
}
|
||||
return view, nil
|
||||
}
|
||||
|
||||
// CancelJobsForTab requests cancellation for a stable tab id. The job manager
|
||||
// keeps cancelled-but-unwinding jobs visible until their done channels close.
|
||||
func (a *App) CancelJobsForTab(tabID string, jobIDs []string) (JobCancelBatchView, error) {
|
||||
@@ -311,6 +355,55 @@ const stopAndCloseGrace = 15 * time.Second
|
||||
// CloseTabWithPolicy makes the old implicit detach behavior an explicit user
|
||||
// choice. stop_and_close never removes the tab until all owned work is idle.
|
||||
func (a *App) CloseTabWithPolicy(tabID, policy string) error {
|
||||
a.remoteTabMu.Lock()
|
||||
_, isRemote := a.remoteTabs[tabID]
|
||||
a.remoteTabMu.Unlock()
|
||||
if isRemote {
|
||||
switch strings.TrimSpace(policy) {
|
||||
case "keep_running":
|
||||
return a.CloseRemoteTab(tabID)
|
||||
case "stop_and_close":
|
||||
work, err := a.remoteActiveWorkForTab(tabID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("remote work status is unavailable; the task was kept open: %w", err)
|
||||
}
|
||||
if !work.active() {
|
||||
return a.CloseRemoteTab(tabID)
|
||||
}
|
||||
if err := a.CancelRemoteTab(tabID); err != nil {
|
||||
return err
|
||||
}
|
||||
ids := make([]string, 0, len(work.Jobs))
|
||||
for _, job := range work.Jobs {
|
||||
ids = append(ids, job.ID)
|
||||
}
|
||||
if len(ids) > 0 {
|
||||
if err := a.CancelRemoteTabJobs(tabID, ids); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
deadline := time.NewTimer(stopAndCloseGrace)
|
||||
defer deadline.Stop()
|
||||
ticker := time.NewTicker(50 * time.Millisecond)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
work, err = a.remoteActiveWorkForTab(tabID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("remote work status is unavailable; the task was kept open: %w", err)
|
||||
}
|
||||
if !work.active() {
|
||||
return a.CloseRemoteTab(tabID)
|
||||
}
|
||||
select {
|
||||
case <-deadline.C:
|
||||
return fmt.Errorf("remote work did not stop within %s; the task was kept open", stopAndCloseGrace)
|
||||
case <-ticker.C:
|
||||
}
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("unknown close policy %q", policy)
|
||||
}
|
||||
}
|
||||
switch strings.TrimSpace(policy) {
|
||||
case "keep_running":
|
||||
return a.closeTab(tabID, true)
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
//go:build windows && reasonix_transcript_smoke
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/png"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/wailsapp/go-webview2/pkg/edge"
|
||||
|
||||
"reasonix/desktop/internal/transcriptsmoke"
|
||||
)
|
||||
|
||||
func (host *selectionSmokeHost) captureStage(
|
||||
iteration int,
|
||||
stage captureStage,
|
||||
baseline smokeGeometry,
|
||||
baselineImage image.Image,
|
||||
) (smokeFrameResult, error) {
|
||||
label := fmt.Sprintf("iteration-%d-%s", iteration, stage.label)
|
||||
snapshot, err := requestSnapshot(host.chromium, host.messages, host.webViewErrors, label, stage.frames, stage.delay)
|
||||
if err != nil {
|
||||
return smokeFrameResult{}, err
|
||||
}
|
||||
currentImage, capturePath, err := host.captureAndSave(label)
|
||||
if err != nil {
|
||||
return smokeFrameResult{}, err
|
||||
}
|
||||
pixelResult, diff, err := transcriptsmoke.Compare(
|
||||
baselineImage, currentImage, snapshot.Geometry.Shell, selectionMasks(snapshot.Geometry),
|
||||
transcriptsmoke.CompareOptions{
|
||||
ChannelThreshold: 12, MaxChangedRatio: 0.0005, MaxConnectedComponent: 128,
|
||||
ViewportWidth: snapshot.Geometry.Viewport.Width, ViewportHeight: snapshot.Geometry.Viewport.Height,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return smokeFrameResult{}, err
|
||||
}
|
||||
frame := smokeFrameResult{
|
||||
Label: label, CapturePath: capturePath, Geometry: snapshot.Geometry,
|
||||
EventSamples: snapshot.EventSamples, Pixel: pixelResult,
|
||||
GeometryOK: geometryStable(baseline, snapshot.Geometry) && eventSamplesStable(baseline, snapshot.EventSamples),
|
||||
}
|
||||
if pixelResult.Passed && frame.GeometryOK {
|
||||
return frame, nil
|
||||
}
|
||||
frame.DiffPath = filepath.Join(host.artifacts, label+"-diff.png")
|
||||
if err := writePNG(frame.DiffPath, diff); err != nil {
|
||||
return smokeFrameResult{}, err
|
||||
}
|
||||
return frame, nil
|
||||
}
|
||||
|
||||
func (host *selectionSmokeHost) captureAndSave(label string) (image.Image, string, error) {
|
||||
data, err := capturePreview(host.chromium)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("capture %s: %w", label, err)
|
||||
}
|
||||
path := filepath.Join(host.artifacts, label+".png")
|
||||
if err := os.WriteFile(path, data, 0o600); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
value, err := decodePNG(data)
|
||||
return value, path, err
|
||||
}
|
||||
|
||||
func (host *selectionSmokeHost) warmCompositor() error {
|
||||
for attempt := 1; attempt <= 3; attempt++ {
|
||||
if _, err := capturePreview(host.chromium); err != nil {
|
||||
return fmt.Errorf("warm WebView2 compositor (attempt %d): %w", attempt, err)
|
||||
}
|
||||
pumpFor(100 * time.Millisecond)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func selectionMasks(geometry smokeGeometry) []transcriptsmoke.Rect {
|
||||
masks := make([]transcriptsmoke.Rect, 0, len(geometry.SelectionRects)+1)
|
||||
for _, selectionRect := range geometry.SelectionRects {
|
||||
masks = append(masks, selectionRect.Expand(6))
|
||||
}
|
||||
if geometry.Toolbar != nil {
|
||||
masks = append(masks, geometry.Toolbar.Expand(24))
|
||||
}
|
||||
return masks
|
||||
}
|
||||
|
||||
func requestSnapshot(
|
||||
chromium *edge.Chromium,
|
||||
messages <-chan string,
|
||||
webViewErrors <-chan error,
|
||||
label string,
|
||||
frames int,
|
||||
delay int,
|
||||
) (smokeMessage, error) {
|
||||
encodedLabel, _ := json.Marshal(label)
|
||||
chromium.Eval(fmt.Sprintf("window.__reasonixSelectionSmoke.snapshot(%s,%d,%d)", encodedLabel, frames, delay))
|
||||
message, err := waitForMessage(messages, webViewErrors, "snapshot", interactionTimeout)
|
||||
if err != nil {
|
||||
return smokeMessage{}, err
|
||||
}
|
||||
if message.Geometry.Label != label {
|
||||
return smokeMessage{}, fmt.Errorf("snapshot label mismatch: got %q, want %q", message.Geometry.Label, label)
|
||||
}
|
||||
return message, nil
|
||||
}
|
||||
|
||||
func waitForMessage(messages <-chan string, webViewErrors <-chan error, messageType string, timeout time.Duration) (smokeMessage, error) {
|
||||
deadline := time.Now().Add(timeout)
|
||||
for time.Now().Before(deadline) {
|
||||
pumpWindowsMessages()
|
||||
if err := pollWebViewError(webViewErrors); err != nil {
|
||||
return smokeMessage{}, err
|
||||
}
|
||||
message, found, err := pollSmokeMessage(messages)
|
||||
if err != nil {
|
||||
return smokeMessage{}, err
|
||||
}
|
||||
if found && message.Type == "error" {
|
||||
return smokeMessage{}, fmt.Errorf("fixture: %s", message.Message)
|
||||
}
|
||||
if found && message.Type == messageType {
|
||||
return message, nil
|
||||
}
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
return smokeMessage{}, fmt.Errorf("timed out waiting for fixture message %q", messageType)
|
||||
}
|
||||
|
||||
func pollSmokeMessage(messages <-chan string) (smokeMessage, bool, error) {
|
||||
select {
|
||||
case raw := <-messages:
|
||||
var message smokeMessage
|
||||
if err := json.Unmarshal([]byte(raw), &message); err != nil {
|
||||
return smokeMessage{}, false, fmt.Errorf("decode fixture message %q: %w", raw, err)
|
||||
}
|
||||
return message, true, nil
|
||||
default:
|
||||
return smokeMessage{}, false, nil
|
||||
}
|
||||
}
|
||||
|
||||
func pollWebViewError(errors <-chan error) error {
|
||||
select {
|
||||
case err := <-errors:
|
||||
return fmt.Errorf("WebView2: %w", err)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func capturePreview(chromium *edge.Chromium) ([]byte, error) {
|
||||
capture, err := chromium.StartSmokePreviewCapture()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
deadline := time.Now().Add(previewCaptureTimeout)
|
||||
for time.Now().Before(deadline) {
|
||||
pumpWindowsMessages()
|
||||
data, done, pollErr := capture.Poll()
|
||||
if done {
|
||||
return data, pollErr
|
||||
}
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
return nil, fmt.Errorf("CapturePreview timed out after %s", previewCaptureTimeout)
|
||||
}
|
||||
|
||||
func decodePNG(data []byte) (image.Image, error) {
|
||||
value, err := png.Decode(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decode WebView2 preview PNG: %w", err)
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func writePNG(path string, value image.Image) error {
|
||||
file, err := os.Create(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
encodeErr := png.Encode(file, value)
|
||||
closeErr := file.Close()
|
||||
if encodeErr != nil {
|
||||
return encodeErr
|
||||
}
|
||||
return closeErr
|
||||
}
|
||||
|
||||
func geometryStable(baseline, current smokeGeometry) bool {
|
||||
return baseline.ScrollTop == current.ScrollTop &&
|
||||
baseline.ScrollHeight == current.ScrollHeight &&
|
||||
baseline.ClientHeight == current.ClientHeight &&
|
||||
current.HostCount == 1 && current.HostStable &&
|
||||
rectDelta(baseline.Table, current.Table) <= 0.5 &&
|
||||
rectDelta(baseline.Row, current.Row) <= 0.5 &&
|
||||
rectDelta(baseline.Target, current.Target) <= 0.5
|
||||
}
|
||||
|
||||
func eventSamplesStable(baseline smokeGeometry, samples []smokeGeometry) bool {
|
||||
if len(samples) == 0 {
|
||||
return false
|
||||
}
|
||||
for _, sample := range samples {
|
||||
if !geometryStable(baseline, sample) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func rectDelta(left, right transcriptsmoke.Rect) float64 {
|
||||
maximum := 0.0
|
||||
for _, value := range []float64{
|
||||
abs(left.Left - right.Left), abs(left.Top - right.Top),
|
||||
abs(left.Right - right.Right), abs(left.Bottom - right.Bottom),
|
||||
} {
|
||||
if value > maximum {
|
||||
maximum = value
|
||||
}
|
||||
}
|
||||
return maximum
|
||||
}
|
||||
|
||||
func abs(value float64) float64 {
|
||||
if value < 0 {
|
||||
return -value
|
||||
}
|
||||
return value
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
//go:build windows && reasonix_transcript_smoke
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"image"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/wailsapp/go-webview2/pkg/edge"
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
const (
|
||||
startupTimeout = 60 * time.Second
|
||||
interactionTimeout = 15 * time.Second
|
||||
previewCaptureTimeout = 10 * time.Second
|
||||
)
|
||||
|
||||
type selectionSmokeHost struct {
|
||||
chromium *edge.Chromium
|
||||
messages chan string
|
||||
webViewErrors chan error
|
||||
artifacts string
|
||||
}
|
||||
|
||||
type captureStage struct {
|
||||
label string
|
||||
frames int
|
||||
delay int
|
||||
}
|
||||
|
||||
var selectionCaptureStages = []captureStage{
|
||||
{"pointer-up", 0, 0},
|
||||
{"raf-1", 1, 0},
|
||||
{"raf-2", 1, 0},
|
||||
{"after-100ms", 0, 100},
|
||||
{"after-300ms", 0, 300},
|
||||
}
|
||||
|
||||
func runSelectionSmoke(url, script, artifacts string, iterations, width, height int, commit string) (smokeResult, error) {
|
||||
result := smokeResult{Commit: commit, ClickIntervals: []int{400, 320, 180}}
|
||||
if iterations <= 0 || width <= 0 || height <= 0 {
|
||||
return result, fmt.Errorf("iterations and viewport dimensions must be positive")
|
||||
}
|
||||
if err := os.MkdirAll(artifacts, 0o755); err != nil {
|
||||
return result, fmt.Errorf("create artifact directory: %w", err)
|
||||
}
|
||||
hwnd, err := createSmokeWindow(width, height)
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
defer destroyWindow.Call(uintptr(hwnd))
|
||||
dataPath, err := os.MkdirTemp("", "reasonix-selection-webview2-")
|
||||
if err != nil {
|
||||
return result, fmt.Errorf("create WebView2 data directory: %w", err)
|
||||
}
|
||||
defer os.RemoveAll(dataPath)
|
||||
|
||||
host, ready, err := startSelectionSmokeHost(hwnd, url, script, dataPath, artifacts, width, height)
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
defer host.chromium.ShuttingDown()
|
||||
if ready.Platform != "windows" {
|
||||
return result, fmt.Errorf("selection fixture reported platform %q, expected windows", ready.Platform)
|
||||
}
|
||||
result.WebView2Runtime = host.chromium.SmokeRuntimeVersion()
|
||||
version := windows.RtlGetVersion()
|
||||
result.WindowsVersion = fmt.Sprintf("%d.%d.%d", version.MajorVersion, version.MinorVersion, version.BuildNumber)
|
||||
result.Viewport = ready.Geometry.Viewport
|
||||
result.DPR = ready.Geometry.DPR
|
||||
movePointerToClientPoint(hwnd, ready.Point)
|
||||
if err := host.warmCompositor(); err != nil {
|
||||
return result, err
|
||||
}
|
||||
result.Passed = true
|
||||
for iteration := 1; iteration <= iterations; iteration++ {
|
||||
iterationResult, iterationErr := host.runIteration(iteration)
|
||||
if iterationErr != nil {
|
||||
return result, iterationErr
|
||||
}
|
||||
result.Iterations = append(result.Iterations, iterationResult)
|
||||
result.Passed = result.Passed && iterationResult.Passed
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func startSelectionSmokeHost(
|
||||
hwnd windows.Handle,
|
||||
url, script, dataPath, artifacts string, width, height int,
|
||||
) (*selectionSmokeHost, smokeMessage, error) {
|
||||
host := &selectionSmokeHost{
|
||||
chromium: edge.NewChromium(), messages: make(chan string, 256),
|
||||
webViewErrors: make(chan error, 1), artifacts: artifacts,
|
||||
}
|
||||
navigated := make(chan struct{}, 1)
|
||||
host.chromium.DataPath = filepath.Clean(dataPath)
|
||||
host.chromium.SetErrorCallback(func(err error) { offerError(host.webViewErrors, err) })
|
||||
host.chromium.MessageCallback = func(message string, _ *edge.ICoreWebView2, _ *edge.ICoreWebView2WebMessageReceivedEventArgs) {
|
||||
select {
|
||||
case host.messages <- message:
|
||||
default:
|
||||
}
|
||||
}
|
||||
host.chromium.NavigationCompletedCallback = func(_ *edge.ICoreWebView2, _ *edge.ICoreWebView2NavigationCompletedEventArgs) {
|
||||
select {
|
||||
case navigated <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
if !host.chromium.Embed(uintptr(hwnd)) {
|
||||
return nil, smokeMessage{}, fmt.Errorf("embed WebView2 controller")
|
||||
}
|
||||
host.chromium.ResizeWithBounds(&edge.Rect{Left: 0, Top: 0, Right: int32(width), Bottom: int32(height)})
|
||||
_ = host.chromium.Show()
|
||||
host.chromium.Focus()
|
||||
host.chromium.Navigate(url)
|
||||
ready, err := waitForSelectionFixture(host, navigated, script)
|
||||
if err != nil {
|
||||
host.chromium.ShuttingDown()
|
||||
return nil, smokeMessage{}, err
|
||||
}
|
||||
return host, ready, nil
|
||||
}
|
||||
|
||||
func waitForSelectionFixture(host *selectionSmokeHost, navigated <-chan struct{}, script string) (smokeMessage, error) {
|
||||
deadline := time.Now().Add(startupTimeout)
|
||||
injected := false
|
||||
for time.Now().Before(deadline) {
|
||||
pumpWindowsMessages()
|
||||
if err := pollWebViewError(host.webViewErrors); err != nil {
|
||||
return smokeMessage{}, err
|
||||
}
|
||||
if !injected {
|
||||
select {
|
||||
case <-navigated:
|
||||
injected = true
|
||||
host.chromium.Eval(script)
|
||||
default:
|
||||
}
|
||||
}
|
||||
message, found, err := pollSmokeMessage(host.messages)
|
||||
if err != nil {
|
||||
return smokeMessage{}, err
|
||||
}
|
||||
if found && message.Type == "error" {
|
||||
return smokeMessage{}, fmt.Errorf("fixture: %s", message.Message)
|
||||
}
|
||||
if found && message.Type == "ready" {
|
||||
return message, nil
|
||||
}
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
return smokeMessage{}, fmt.Errorf("selection fixture did not become ready within %s", startupTimeout)
|
||||
}
|
||||
|
||||
func (host *selectionSmokeHost) runIteration(iteration int) (smokeIterationResult, error) {
|
||||
result := smokeIterationResult{Iteration: iteration, Passed: true}
|
||||
if iteration > 1 {
|
||||
host.chromium.Eval(fmt.Sprintf("window.__reasonixSelectionSmoke.reset(%d)", iteration))
|
||||
if _, err := waitForMessage(host.messages, host.webViewErrors, "reset", interactionTimeout); err != nil {
|
||||
return result, err
|
||||
}
|
||||
pumpFor(600 * time.Millisecond)
|
||||
}
|
||||
baseline, err := requestSnapshot(
|
||||
host.chromium, host.messages, host.webViewErrors, fmt.Sprintf("iteration-%d-baseline", iteration), 0, 0,
|
||||
)
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
baselineImage, baselinePath, err := host.captureAndSave(fmt.Sprintf("iteration-%d-baseline", iteration))
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
result.Baseline = baselinePath
|
||||
if err := sendNativeClicksBeforeFinal(); err != nil {
|
||||
return result, err
|
||||
}
|
||||
if err := sendLeftButton(mouseEventFLeftDown); err != nil {
|
||||
return result, err
|
||||
}
|
||||
pumpFor(30 * time.Millisecond)
|
||||
downFrame, err := host.captureStage(
|
||||
iteration, captureStage{"pointer-down", 0, 0}, baseline.Geometry, baselineImage,
|
||||
)
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
result.Frames = append(result.Frames, downFrame)
|
||||
result.Passed = result.Passed && downFrame.Pixel.Passed && downFrame.GeometryOK
|
||||
if err := sendLeftButton(mouseEventFLeftUp); err != nil {
|
||||
return result, err
|
||||
}
|
||||
return host.captureIterationStages(iteration, baseline.Geometry, baselineImage, result, time.Now())
|
||||
}
|
||||
|
||||
func (host *selectionSmokeHost) captureIterationStages(
|
||||
iteration int,
|
||||
baseline smokeGeometry,
|
||||
baselineImage image.Image,
|
||||
result smokeIterationResult,
|
||||
pointerUpAt time.Time,
|
||||
) (smokeIterationResult, error) {
|
||||
for _, stage := range selectionCaptureStages {
|
||||
if stage.delay > 0 {
|
||||
if remaining := time.Until(pointerUpAt.Add(time.Duration(stage.delay) * time.Millisecond)); remaining > 0 {
|
||||
pumpFor(remaining)
|
||||
}
|
||||
}
|
||||
capture := stage
|
||||
capture.delay = 0
|
||||
frame, err := host.captureStage(iteration, capture, baseline, baselineImage)
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
result.Frames = append(result.Frames, frame)
|
||||
result.Passed = result.Passed && frame.Pixel.Passed && frame.GeometryOK
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func offerError(target chan<- error, err error) {
|
||||
select {
|
||||
case target <- err:
|
||||
default:
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
//go:build windows && reasonix_transcript_smoke
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"runtime"
|
||||
|
||||
"reasonix/desktop/internal/transcriptsmoke"
|
||||
)
|
||||
|
||||
type smokeGeometry struct {
|
||||
Label string `json:"label"`
|
||||
DPR float64 `json:"dpr"`
|
||||
Viewport smokeViewport `json:"viewport"`
|
||||
Shell transcriptsmoke.Rect `json:"shell"`
|
||||
Table transcriptsmoke.Rect `json:"table"`
|
||||
Row transcriptsmoke.Rect `json:"row"`
|
||||
Target transcriptsmoke.Rect `json:"target"`
|
||||
Toolbar *transcriptsmoke.Rect `json:"toolbar"`
|
||||
SelectionRects []transcriptsmoke.Rect `json:"selectionRects"`
|
||||
ScrollTop float64 `json:"scrollTop"`
|
||||
ScrollHeight float64 `json:"scrollHeight"`
|
||||
ClientHeight float64 `json:"clientHeight"`
|
||||
HostCount int `json:"hostCount"`
|
||||
HostStable bool `json:"hostStable"`
|
||||
HostState string `json:"hostState"`
|
||||
}
|
||||
|
||||
type smokeViewport struct {
|
||||
Width float64 `json:"width"`
|
||||
Height float64 `json:"height"`
|
||||
}
|
||||
|
||||
type smokePoint struct {
|
||||
X int `json:"x"`
|
||||
Y int `json:"y"`
|
||||
}
|
||||
|
||||
type smokeMessage struct {
|
||||
Type string `json:"type"`
|
||||
Message string `json:"message,omitempty"`
|
||||
Point smokePoint `json:"point,omitempty"`
|
||||
Geometry smokeGeometry `json:"geometry,omitempty"`
|
||||
EventSamples []smokeGeometry `json:"eventSamples,omitempty"`
|
||||
Platform string `json:"platform,omitempty"`
|
||||
Iteration int `json:"iteration,omitempty"`
|
||||
}
|
||||
|
||||
type smokeFrameResult struct {
|
||||
Label string `json:"label"`
|
||||
CapturePath string `json:"capturePath"`
|
||||
DiffPath string `json:"diffPath,omitempty"`
|
||||
Geometry smokeGeometry `json:"geometry"`
|
||||
EventSamples []smokeGeometry `json:"eventSamples"`
|
||||
Pixel transcriptsmoke.CompareResult `json:"pixel"`
|
||||
GeometryOK bool `json:"geometryOK"`
|
||||
}
|
||||
|
||||
type smokeIterationResult struct {
|
||||
Iteration int `json:"iteration"`
|
||||
Baseline string `json:"baseline"`
|
||||
Frames []smokeFrameResult `json:"frames"`
|
||||
Passed bool `json:"passed"`
|
||||
}
|
||||
|
||||
type smokeResult struct {
|
||||
Passed bool `json:"passed"`
|
||||
Commit string `json:"commit"`
|
||||
WindowsVersion string `json:"windowsVersion"`
|
||||
WebView2Runtime string `json:"webView2Runtime"`
|
||||
Viewport smokeViewport `json:"viewport"`
|
||||
DPR float64 `json:"dpr"`
|
||||
ClickIntervals []int `json:"clickIntervalsMs"`
|
||||
Iterations []smokeIterationResult `json:"iterations"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
func init() {
|
||||
runtime.LockOSThread()
|
||||
}
|
||||
|
||||
func main() {
|
||||
url := flag.String("url", "http://127.0.0.1:4173/?mock=bench&bench=1", "built Reasonix bench URL")
|
||||
scriptPath := flag.String("script", "transcript_selection_smoke_contract.js", "selection assertion contract")
|
||||
artifacts := flag.String("artifacts", "transcript-selection-smoke-artifacts", "PNG and JSON artifact directory")
|
||||
resultPath := flag.String("result-file", "", "optional result JSON path")
|
||||
iterations := flag.Int("iterations", 3, "native multi-click iterations")
|
||||
width := flag.Int("width", 1200, "WebView2 viewport width")
|
||||
height := flag.Int("height", 800, "WebView2 viewport height")
|
||||
commit := flag.String("commit", os.Getenv("GITHUB_SHA"), "tested source commit")
|
||||
flag.Parse()
|
||||
|
||||
result := smokeResult{Commit: *commit, ClickIntervals: []int{400, 320, 180}}
|
||||
script, err := os.ReadFile(*scriptPath)
|
||||
if err == nil {
|
||||
result, err = runSelectionSmoke(*url, string(script), *artifacts, *iterations, *width, *height, *commit)
|
||||
}
|
||||
if err != nil {
|
||||
result.Passed = false
|
||||
result.Error = err.Error()
|
||||
}
|
||||
payload, marshalErr := json.MarshalIndent(result, "", " ")
|
||||
if marshalErr != nil {
|
||||
fmt.Fprintf(os.Stderr, "native selection smoke: encode result: %v\n", marshalErr)
|
||||
os.Exit(1)
|
||||
}
|
||||
if *resultPath != "" {
|
||||
if writeErr := os.WriteFile(*resultPath, payload, 0o600); writeErr != nil {
|
||||
fmt.Fprintf(os.Stderr, "native selection smoke: write result: %v\n", writeErr)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Printf("native selection smoke: passed=%t iterations=%d result=%s\n", result.Passed, len(result.Iterations), *resultPath)
|
||||
} else {
|
||||
fmt.Println(string(payload))
|
||||
}
|
||||
if err != nil || !result.Passed {
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
//go:build windows && reasonix_transcript_smoke
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
"unsafe"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
var (
|
||||
user32 = windows.NewLazySystemDLL("user32.dll")
|
||||
registerClassExW = user32.NewProc("RegisterClassExW")
|
||||
createWindowExW = user32.NewProc("CreateWindowExW")
|
||||
destroyWindow = user32.NewProc("DestroyWindow")
|
||||
showWindow = user32.NewProc("ShowWindow")
|
||||
updateWindow = user32.NewProc("UpdateWindow")
|
||||
setForegroundWindow = user32.NewProc("SetForegroundWindow")
|
||||
setFocus = user32.NewProc("SetFocus")
|
||||
setCursorPos = user32.NewProc("SetCursorPos")
|
||||
clientToScreen = user32.NewProc("ClientToScreen")
|
||||
peekMessageW = user32.NewProc("PeekMessageW")
|
||||
translateMessage = user32.NewProc("TranslateMessage")
|
||||
dispatchMessageW = user32.NewProc("DispatchMessageW")
|
||||
defWindowProcW = user32.NewProc("DefWindowProcW")
|
||||
sendInput = user32.NewProc("SendInput")
|
||||
)
|
||||
|
||||
const (
|
||||
wsOverlappedWindow = 0x00CF0000
|
||||
swShow = 5
|
||||
pmRemove = 0x0001
|
||||
inputMouse = 0
|
||||
mouseEventFLeftDown = 0x0002
|
||||
mouseEventFLeftUp = 0x0004
|
||||
)
|
||||
|
||||
type smokeWindowClass struct {
|
||||
cbSize uint32
|
||||
style uint32
|
||||
wndProc uintptr
|
||||
clsExtra int32
|
||||
wndExtra int32
|
||||
instance windows.Handle
|
||||
icon windows.Handle
|
||||
cursor windows.Handle
|
||||
background windows.Handle
|
||||
menuName *uint16
|
||||
className *uint16
|
||||
iconSmall windows.Handle
|
||||
}
|
||||
|
||||
type nativePoint struct{ x, y int32 }
|
||||
|
||||
type smokeMessageLoopEntry struct {
|
||||
hwnd windows.Handle
|
||||
message uint32
|
||||
wParam uintptr
|
||||
lParam uintptr
|
||||
time uint32
|
||||
point nativePoint
|
||||
private uint32
|
||||
}
|
||||
|
||||
type smokeMouseInput struct {
|
||||
dx, dy int32
|
||||
mouseData uint32
|
||||
flags uint32
|
||||
time uint32
|
||||
extraInfo uintptr
|
||||
}
|
||||
|
||||
type smokeInput struct {
|
||||
typeID uint32
|
||||
_ uint32
|
||||
mouse smokeMouseInput
|
||||
}
|
||||
|
||||
func createSmokeWindow(width, height int) (windows.Handle, error) {
|
||||
var instance windows.Handle
|
||||
if err := windows.GetModuleHandleEx(0, nil, &instance); err != nil {
|
||||
return 0, fmt.Errorf("get module handle: %w", err)
|
||||
}
|
||||
className, _ := windows.UTF16PtrFromString(fmt.Sprintf("ReasonixSelectionSmoke-%d", os.Getpid()))
|
||||
windowName, _ := windows.UTF16PtrFromString("Reasonix transcript selection smoke")
|
||||
windowClass := smokeWindowClass{
|
||||
cbSize: uint32(unsafe.Sizeof(smokeWindowClass{})),
|
||||
wndProc: windows.NewCallback(func(hwnd windows.Handle, message uint32, wParam, lParam uintptr) uintptr {
|
||||
result, _, _ := defWindowProcW.Call(uintptr(hwnd), uintptr(message), wParam, lParam)
|
||||
return result
|
||||
}),
|
||||
instance: instance, className: className,
|
||||
}
|
||||
if registered, _, err := registerClassExW.Call(uintptr(unsafe.Pointer(&windowClass))); registered == 0 {
|
||||
return 0, fmt.Errorf("register window class: %w", err)
|
||||
}
|
||||
hwnd, _, err := createWindowExW.Call(
|
||||
0, uintptr(unsafe.Pointer(className)), uintptr(unsafe.Pointer(windowName)),
|
||||
wsOverlappedWindow, 0, 0, uintptr(width), uintptr(height), 0, 0, uintptr(instance), 0,
|
||||
)
|
||||
if hwnd == 0 {
|
||||
return 0, fmt.Errorf("create WebView2 host window: %w", err)
|
||||
}
|
||||
showWindow.Call(hwnd, swShow)
|
||||
updateWindow.Call(hwnd)
|
||||
setForegroundWindow.Call(hwnd)
|
||||
setFocus.Call(hwnd)
|
||||
return windows.Handle(hwnd), nil
|
||||
}
|
||||
|
||||
func movePointerToClientPoint(hwnd windows.Handle, point smokePoint) {
|
||||
screenPoint := nativePoint{x: int32(point.X), y: int32(point.Y)}
|
||||
clientToScreen.Call(uintptr(hwnd), uintptr(unsafe.Pointer(&screenPoint)))
|
||||
setCursorPos.Call(uintptr(screenPoint.x), uintptr(screenPoint.y))
|
||||
}
|
||||
|
||||
func sendNativeClicksBeforeFinal() error {
|
||||
for _, delay := range []time.Duration{400 * time.Millisecond, 320 * time.Millisecond, 180 * time.Millisecond} {
|
||||
if err := sendLeftButton(mouseEventFLeftDown); err != nil {
|
||||
return err
|
||||
}
|
||||
pumpFor(30 * time.Millisecond)
|
||||
if err := sendLeftButton(mouseEventFLeftUp); err != nil {
|
||||
return err
|
||||
}
|
||||
pumpFor(delay)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func sendLeftButton(flags uint32) error {
|
||||
input := smokeInput{typeID: inputMouse}
|
||||
input.mouse.flags = flags
|
||||
inserted, _, err := sendInput.Call(1, uintptr(unsafe.Pointer(&input)), unsafe.Sizeof(input))
|
||||
if inserted != 1 {
|
||||
return fmt.Errorf("send WebView2 controller mouse input: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func pumpFor(duration time.Duration) {
|
||||
deadline := time.Now().Add(duration)
|
||||
for time.Now().Before(deadline) {
|
||||
pumpWindowsMessages()
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
func pumpWindowsMessages() {
|
||||
var message smokeMessageLoopEntry
|
||||
for {
|
||||
hasMessage, _, _ := peekMessageW.Call(uintptr(unsafe.Pointer(&message)), 0, 0, 0, pmRemove)
|
||||
if hasMessage == 0 {
|
||||
return
|
||||
}
|
||||
translateMessage.Call(uintptr(unsafe.Pointer(&message)))
|
||||
dispatchMessageW.Call(uintptr(unsafe.Pointer(&message)))
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,9 @@ package main
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
@@ -62,6 +65,8 @@ type crashBreadcrumb struct {
|
||||
}
|
||||
|
||||
type crashReport struct {
|
||||
EventID string `json:"eventId,omitempty"`
|
||||
DedupKey string `json:"dedupKey,omitempty"`
|
||||
InstallID string `json:"installId,omitempty"`
|
||||
Kind string `json:"kind"`
|
||||
Version string `json:"version"`
|
||||
@@ -161,6 +166,37 @@ func baseCrashReport(kind string) crashReport {
|
||||
}
|
||||
}
|
||||
|
||||
func ensureCrashIdentity(report *crashReport) error {
|
||||
if report.EventID == "" {
|
||||
value := make([]byte, 16)
|
||||
if _, err := rand.Read(value); err != nil {
|
||||
return fmt.Errorf("generate crash event id: %w", err)
|
||||
}
|
||||
report.EventID = hex.EncodeToString(value)
|
||||
}
|
||||
if report.DedupKey == "" {
|
||||
basis := strings.Join([]string{
|
||||
report.Kind,
|
||||
report.Version,
|
||||
report.Source,
|
||||
report.Label,
|
||||
report.ErrorType,
|
||||
normalizeCrashFingerprintField(report.ErrorMessage),
|
||||
normalizeCrashFingerprintField(report.TopFrame),
|
||||
normalizeCrashFingerprintField(report.FingerprintHint),
|
||||
}, "\n")
|
||||
sum := sha256.Sum256([]byte(basis))
|
||||
report.DedupKey = hex.EncodeToString(sum[:])
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var crashFingerprintNumber = regexp.MustCompile(`\b\d+\b`)
|
||||
|
||||
func normalizeCrashFingerprintField(value string) string {
|
||||
return crashFingerprintNumber.ReplaceAllString(strings.ToLower(strings.TrimSpace(value)), "<n>")
|
||||
}
|
||||
|
||||
func topFrameFromStack(stack string) string {
|
||||
for line := range strings.SplitSeq(stack, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
@@ -261,6 +297,9 @@ func (a *App) ReportCrash(kind, detail string) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ensureCrashIdentity(&r); err != nil {
|
||||
return err
|
||||
}
|
||||
return postCrashReport(a.reqCtx(), c, crashEndpoint, r)
|
||||
}
|
||||
|
||||
|
||||
+127
-3
@@ -1,6 +1,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
@@ -13,6 +14,8 @@ import (
|
||||
"time"
|
||||
|
||||
"reasonix/internal/config"
|
||||
"reasonix/internal/filelock"
|
||||
"reasonix/internal/fileutil"
|
||||
)
|
||||
|
||||
// crash_pending.go captures Go-side panics to disk and ships them on the next
|
||||
@@ -25,6 +28,10 @@ const (
|
||||
pendingCrashFile = "crash-pending.json" // legacy single-report path
|
||||
pendingCrashQueueDir = "crash-pending"
|
||||
maxPendingCrashes = 10
|
||||
currentCrashSchema = 3
|
||||
crashLedgerFile = "crash-upload-ledger-v1.json"
|
||||
maxCrashLedger = 512
|
||||
crashLedgerRetention = 180 * 24 * time.Hour
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -71,6 +78,9 @@ func writePendingCrash(site string, r any, stack []byte) {
|
||||
|
||||
func writePendingReport(report crashReport, overwrite bool) bool {
|
||||
_ = overwrite // retained for source compatibility; the queue never overwrites.
|
||||
if ensureCrashIdentity(&report) != nil {
|
||||
return false
|
||||
}
|
||||
body, err := json.Marshal(report)
|
||||
if err != nil {
|
||||
return false
|
||||
@@ -95,12 +105,82 @@ func writePendingReport(report crashReport, overwrite bool) bool {
|
||||
_ = os.Remove(path)
|
||||
return false
|
||||
}
|
||||
return prunePendingCrashQueue(path)
|
||||
}
|
||||
|
||||
func prunePendingCrashQueue(writtenPath string) bool {
|
||||
paths := pendingCrashQueuePaths()
|
||||
for len(paths) > maxPendingCrashes {
|
||||
_ = os.Remove(paths[0])
|
||||
paths = paths[1:]
|
||||
victim := -1
|
||||
for index, candidate := range paths {
|
||||
body, err := os.ReadFile(candidate)
|
||||
var header struct {
|
||||
SchemaVersion int `json:"schemaVersion"`
|
||||
}
|
||||
if err != nil || json.Unmarshal(body, &header) != nil || header.SchemaVersion <= currentCrashSchema {
|
||||
victim = index
|
||||
break
|
||||
}
|
||||
}
|
||||
if victim < 0 {
|
||||
break
|
||||
}
|
||||
_ = os.Remove(paths[victim])
|
||||
paths = append(paths[:victim], paths[victim+1:]...)
|
||||
}
|
||||
return true
|
||||
_, err := os.Stat(writtenPath)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
type crashUploadLedger struct {
|
||||
Version int `json:"version"`
|
||||
Entries map[string]string `json:"entries"`
|
||||
}
|
||||
|
||||
func crashLedgerPath() string {
|
||||
return filepath.Join(config.MemoryUserDir(), "diagnostics", crashLedgerFile)
|
||||
}
|
||||
|
||||
func crashLedgerKey(report crashReport) string {
|
||||
return report.Version + ":" + report.DedupKey
|
||||
}
|
||||
|
||||
func loadCrashLedger(path string, now time.Time) crashUploadLedger {
|
||||
ledger := crashUploadLedger{Version: 1, Entries: map[string]string{}}
|
||||
body, err := os.ReadFile(path)
|
||||
if err != nil || json.Unmarshal(body, &ledger) != nil || ledger.Version != 1 || ledger.Entries == nil {
|
||||
return crashUploadLedger{Version: 1, Entries: map[string]string{}}
|
||||
}
|
||||
cutoff := now.Add(-crashLedgerRetention)
|
||||
for key, value := range ledger.Entries {
|
||||
at, err := time.Parse(time.RFC3339Nano, value)
|
||||
if err != nil || at.Before(cutoff) {
|
||||
delete(ledger.Entries, key)
|
||||
}
|
||||
}
|
||||
return ledger
|
||||
}
|
||||
|
||||
func saveCrashLedger(path string, ledger crashUploadLedger) error {
|
||||
if len(ledger.Entries) > maxCrashLedger {
|
||||
type row struct{ key, at string }
|
||||
rows := make([]row, 0, len(ledger.Entries))
|
||||
for key, at := range ledger.Entries {
|
||||
rows = append(rows, row{key: key, at: at})
|
||||
}
|
||||
sort.Slice(rows, func(i, j int) bool { return rows[i].at > rows[j].at })
|
||||
for _, item := range rows[maxCrashLedger:] {
|
||||
delete(ledger.Entries, item.key)
|
||||
}
|
||||
}
|
||||
body, err := json.Marshal(ledger)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
return fileutil.AtomicWriteFile(path, body, 0o600)
|
||||
}
|
||||
|
||||
func pendingCrashQueuePaths() []string {
|
||||
@@ -130,6 +210,8 @@ func removeAllPendingCrashes() {
|
||||
_ = os.Remove(pendingCrashPath())
|
||||
_ = os.RemoveAll(pendingCrashDir())
|
||||
_ = os.Remove(fatalCrashCoveredPath())
|
||||
_ = os.Remove(crashLedgerPath())
|
||||
_ = os.Remove(crashLedgerPath() + ".lock")
|
||||
}
|
||||
|
||||
func (a *App) goSafe(site string, fn func()) {
|
||||
@@ -139,6 +221,14 @@ func (a *App) goSafe(site string, fn func()) {
|
||||
}()
|
||||
}
|
||||
|
||||
func (a *App) goRemoteTabSafe(site string, fn func()) {
|
||||
a.remoteTabTasks.Add(1)
|
||||
a.goSafe(site, func() {
|
||||
defer a.remoteTabTasks.Done()
|
||||
fn()
|
||||
})
|
||||
}
|
||||
|
||||
// flushPendingCrash drains a Go panic captured on a prior run and POSTs it, then
|
||||
// clears it. Runs at launch alongside the ping; honours the telemetry opt-out by
|
||||
// dropping the file unsent.
|
||||
@@ -162,6 +252,18 @@ func (a *App) flushPendingCrash() {
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
lockContext, cancel := context.WithTimeout(a.bootContext(), 3*time.Second)
|
||||
defer cancel()
|
||||
ledgerPath := crashLedgerPath()
|
||||
if os.MkdirAll(filepath.Dir(ledgerPath), 0o700) != nil {
|
||||
return
|
||||
}
|
||||
release, err := filelock.AcquireWithExternalTimeout(lockContext, ledgerPath+".lock", 2*time.Second)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer release()
|
||||
ledger := loadCrashLedger(ledgerPath, time.Now().UTC())
|
||||
for _, path := range paths {
|
||||
body, readErr := readFileUTF8(path)
|
||||
if readErr != nil {
|
||||
@@ -172,9 +274,31 @@ func (a *App) flushPendingCrash() {
|
||||
_ = os.Remove(path)
|
||||
continue
|
||||
}
|
||||
if r.SchemaVersion > currentCrashSchema {
|
||||
continue
|
||||
}
|
||||
identityChanged := r.EventID == "" || r.DedupKey == ""
|
||||
if ensureCrashIdentity(&r) != nil {
|
||||
break
|
||||
}
|
||||
if identityChanged {
|
||||
updated, marshalErr := json.Marshal(r)
|
||||
if marshalErr != nil || fileutil.AtomicWriteFile(path, updated, 0o600) != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
key := crashLedgerKey(r)
|
||||
if _, alreadySent := ledger.Entries[key]; alreadySent {
|
||||
_ = os.Remove(path)
|
||||
continue
|
||||
}
|
||||
if postCrashReport(a.bootContext(), c, crashEndpoint, r) != nil {
|
||||
break
|
||||
}
|
||||
ledger.Entries[key] = time.Now().UTC().Format(time.RFC3339Nano)
|
||||
if saveCrashLedger(ledgerPath, ledger) != nil {
|
||||
break
|
||||
}
|
||||
_ = os.Remove(path)
|
||||
}
|
||||
_ = os.Remove(pendingCrashDir())
|
||||
|
||||
@@ -3,13 +3,17 @@ package main
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func readPending(t *testing.T) (crashReport, bool) {
|
||||
@@ -245,3 +249,229 @@ func TestPendingCrashDoesNotPersistInstallID(t *testing.T) {
|
||||
t.Fatalf("pending report persisted an installation identity: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFlushPendingCrashDeduplicatesSameVersionAndResendsAfterUpgrade(t *testing.T) {
|
||||
removeAllPendingCrashes()
|
||||
oldVersion, oldEndpoint := version, crashEndpoint
|
||||
t.Cleanup(func() {
|
||||
version, crashEndpoint = oldVersion, oldEndpoint
|
||||
removeAllPendingCrashes()
|
||||
})
|
||||
version = "v9.9.9"
|
||||
var hits atomic.Int32
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
hits.Add(1)
|
||||
w.WriteHeader(http.StatusAccepted)
|
||||
}))
|
||||
defer srv.Close()
|
||||
crashEndpoint = srv.URL
|
||||
|
||||
report := baseCrashReport("crash")
|
||||
report.Source = "go"
|
||||
report.ErrorType = "panic"
|
||||
report.TopFrame = "main.go:12"
|
||||
report.Message = "same crash"
|
||||
firstQueued := writePendingReport(report, false)
|
||||
secondQueued := writePendingReport(report, false)
|
||||
if !firstQueued || !secondQueued {
|
||||
t.Fatal("failed to queue duplicate reports")
|
||||
}
|
||||
NewApp().flushPendingCrash()
|
||||
if got := hits.Load(); got != 1 {
|
||||
t.Fatalf("same-version uploads = %d, want 1", got)
|
||||
}
|
||||
|
||||
report.Version = "v10.0.0"
|
||||
report.EventID, report.DedupKey = "", ""
|
||||
if !writePendingReport(report, false) {
|
||||
t.Fatal("failed to queue upgraded report")
|
||||
}
|
||||
NewApp().flushPendingCrash()
|
||||
if got := hits.Load(); got != 2 {
|
||||
t.Fatalf("uploads after version upgrade = %d, want 2", got)
|
||||
}
|
||||
info, err := os.Stat(crashLedgerPath())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if runtime.GOOS != "windows" && info.Mode().Perm() != 0o600 {
|
||||
t.Fatalf("ledger permissions = %o, want 600", info.Mode().Perm())
|
||||
}
|
||||
}
|
||||
|
||||
func TestConcurrentFlushPendingCrashUsesOneCrossProcessLedgerOwner(t *testing.T) {
|
||||
removeAllPendingCrashes()
|
||||
oldVersion, oldEndpoint := version, crashEndpoint
|
||||
t.Cleanup(func() {
|
||||
version, crashEndpoint = oldVersion, oldEndpoint
|
||||
removeAllPendingCrashes()
|
||||
})
|
||||
version = "v9.9.9"
|
||||
var hits atomic.Int32
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
hits.Add(1)
|
||||
w.WriteHeader(http.StatusAccepted)
|
||||
}))
|
||||
defer srv.Close()
|
||||
crashEndpoint = srv.URL
|
||||
writePendingCrash("concurrent", "boom", []byte("stack"))
|
||||
|
||||
start := make(chan struct{})
|
||||
var done sync.WaitGroup
|
||||
for range 2 {
|
||||
done.Go(func() {
|
||||
<-start
|
||||
NewApp().flushPendingCrash()
|
||||
})
|
||||
}
|
||||
close(start)
|
||||
done.Wait()
|
||||
if got := hits.Load(); got != 1 {
|
||||
t.Fatalf("concurrent uploads = %d, want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFlushPendingCrashFailureDoesNotRecordOrDelete(t *testing.T) {
|
||||
removeAllPendingCrashes()
|
||||
oldVersion, oldEndpoint := version, crashEndpoint
|
||||
t.Cleanup(func() {
|
||||
version, crashEndpoint = oldVersion, oldEndpoint
|
||||
removeAllPendingCrashes()
|
||||
})
|
||||
version = "v9.9.9"
|
||||
status := atomic.Int32{}
|
||||
status.Store(http.StatusServiceUnavailable)
|
||||
var hits atomic.Int32
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
hits.Add(1)
|
||||
w.WriteHeader(int(status.Load()))
|
||||
}))
|
||||
defer srv.Close()
|
||||
crashEndpoint = srv.URL
|
||||
writePendingCrash("retry", "boom", []byte("stack"))
|
||||
|
||||
NewApp().flushPendingCrash()
|
||||
if _, ok := readPending(t); !ok {
|
||||
t.Fatal("failed upload removed pending crash")
|
||||
}
|
||||
if ledger := loadCrashLedger(crashLedgerPath(), time.Now().UTC()); len(ledger.Entries) != 0 {
|
||||
t.Fatalf("failed upload recorded ledger entry: %+v", ledger.Entries)
|
||||
}
|
||||
status.Store(http.StatusAccepted)
|
||||
NewApp().flushPendingCrash()
|
||||
if hits.Load() != 2 {
|
||||
t.Fatalf("retry requests = %d, want 2", hits.Load())
|
||||
}
|
||||
if _, ok := readPending(t); ok {
|
||||
t.Fatal("successful retry left pending crash")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFlushPendingCrashBackfillsOldIdentityAndPreservesFutureSchema(t *testing.T) {
|
||||
removeAllPendingCrashes()
|
||||
oldVersion, oldEndpoint := version, crashEndpoint
|
||||
t.Cleanup(func() {
|
||||
version, crashEndpoint = oldVersion, oldEndpoint
|
||||
removeAllPendingCrashes()
|
||||
})
|
||||
version = "v9.9.9"
|
||||
if err := os.MkdirAll(pendingCrashDir(), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
oldPath := filepath.Join(pendingCrashDir(), "001-old.json")
|
||||
futurePath := filepath.Join(pendingCrashDir(), "002-future.json")
|
||||
oldReport := `{"kind":"crash","version":"v9.9.9","os":"linux","arch":"amd64","message":"old","schemaVersion":2,"source":"go","errorType":"panic","topFrame":"main.go:12"}`
|
||||
futureReport := `{"kind":"crash","version":"v10.0.0","os":"linux","arch":"amd64","message":"future","schemaVersion":99,"futureField":"keep"}`
|
||||
if err := os.WriteFile(oldPath, []byte(oldReport), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(futurePath, []byte(futureReport), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var uploaded crashReport
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := json.NewDecoder(r.Body).Decode(&uploaded); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
w.WriteHeader(http.StatusAccepted)
|
||||
}))
|
||||
defer srv.Close()
|
||||
crashEndpoint = srv.URL
|
||||
|
||||
NewApp().flushPendingCrash()
|
||||
if len(uploaded.EventID) != 32 || len(uploaded.DedupKey) != 64 {
|
||||
t.Fatalf("old report identity not backfilled: %+v", uploaded)
|
||||
}
|
||||
if _, err := os.Stat(oldPath); !os.IsNotExist(err) {
|
||||
t.Fatalf("old report was not removed after send: %v", err)
|
||||
}
|
||||
body, err := os.ReadFile(futurePath)
|
||||
if err != nil || !bytes.Contains(body, []byte(`"futureField":"keep"`)) {
|
||||
t.Fatalf("future schema was not preserved: body=%s err=%v", body, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFlushPendingCrashUploadsCurrentSchemaThree(t *testing.T) {
|
||||
removeAllPendingCrashes()
|
||||
oldVersion, oldEndpoint := version, crashEndpoint
|
||||
t.Cleanup(func() {
|
||||
version, crashEndpoint = oldVersion, oldEndpoint
|
||||
removeAllPendingCrashes()
|
||||
})
|
||||
version = "v9.9.9"
|
||||
var hits atomic.Int32
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
hits.Add(1)
|
||||
w.WriteHeader(http.StatusAccepted)
|
||||
}))
|
||||
defer srv.Close()
|
||||
crashEndpoint = srv.URL
|
||||
|
||||
report := desktopLifecycleReport(desktopLifecycleObservation{
|
||||
Version: "v9.9.8",
|
||||
Phase: "ready",
|
||||
})
|
||||
if report.SchemaVersion != currentCrashSchema {
|
||||
t.Fatalf("current producer schema = %d, supported = %d", report.SchemaVersion, currentCrashSchema)
|
||||
}
|
||||
if !writePendingReport(report, false) {
|
||||
t.Fatal("failed to queue current-schema report")
|
||||
}
|
||||
NewApp().flushPendingCrash()
|
||||
if got := hits.Load(); got != 1 {
|
||||
t.Fatalf("current-schema uploads = %d, want 1", got)
|
||||
}
|
||||
if got := len(pendingCrashPaths()); got != 0 {
|
||||
t.Fatalf("pending reports after upload = %d, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWritePendingReportPrunesKnownReportsWithoutDeletingFutureSchema(t *testing.T) {
|
||||
removeAllPendingCrashes()
|
||||
t.Cleanup(removeAllPendingCrashes)
|
||||
if err := os.MkdirAll(pendingCrashDir(), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
futurePath := filepath.Join(pendingCrashDir(), "000-future.json")
|
||||
futureReport := `{"schemaVersion":99,"futureField":"keep"}`
|
||||
if err := os.WriteFile(futurePath, []byte(futureReport), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for index := range maxPendingCrashes {
|
||||
report := baseCrashReport("crash")
|
||||
report.SchemaVersion = currentCrashSchema
|
||||
report.Source = "go"
|
||||
report.Label = fmt.Sprintf("panic-%d", index)
|
||||
report.Message = "bounded current report"
|
||||
if !writePendingReport(report, false) {
|
||||
t.Fatalf("failed to queue current report %d", index)
|
||||
}
|
||||
}
|
||||
body, err := os.ReadFile(futurePath)
|
||||
if err != nil || !bytes.Contains(body, []byte(`"futureField":"keep"`)) {
|
||||
t.Fatalf("future schema was pruned: body=%s err=%v", body, err)
|
||||
}
|
||||
if got := len(pendingCrashQueuePaths()); got != maxPendingCrashes {
|
||||
t.Fatalf("pending reports = %d, want bounded queue of %d", got, maxPendingCrashes)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,365 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/hmac"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/binary"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/config"
|
||||
)
|
||||
|
||||
// Local-proxy mode tunnels model calls to this desktop, which swaps a scoped
|
||||
// virtual token for the real provider key. The real key never leaves desktop.
|
||||
|
||||
// credentialProxyProviderName is the provider entry the bootstrap installs in
|
||||
// the remote config; the serve launches with --model <name>.
|
||||
const credentialProxyProviderName = "reasonix-desktop-proxy"
|
||||
|
||||
type credProxyRoute struct {
|
||||
proxy *httputil.ReverseProxy
|
||||
model string
|
||||
}
|
||||
|
||||
// credentialProxy is the desktop-side key holder: a loopback HTTP endpoint
|
||||
// that authenticates requests by virtual token and forwards them to the real
|
||||
// provider with the real key. One instance serves the whole app.
|
||||
type credentialProxy struct {
|
||||
mu sync.Mutex
|
||||
ln net.Listener
|
||||
server *http.Server
|
||||
port int
|
||||
routes map[string]*credProxyRoute
|
||||
}
|
||||
|
||||
func (p *credentialProxy) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
// Unauthenticated liveness endpoint for the desktop's reverse-tunnel
|
||||
// probe: the listener only exists behind the SSH reverse forward, so a
|
||||
// 204 here proves serve → remote loopback → tunnel → desktop end to end.
|
||||
if r.URL.Path == "/healthz" {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
token := bearerToken(r.Header.Get("Authorization"))
|
||||
p.mu.Lock()
|
||||
route := p.routes[token]
|
||||
routeCount := len(p.routes)
|
||||
p.mu.Unlock()
|
||||
if route == nil {
|
||||
log.Printf("[remote] credProxy: rejected %s %s routeCount=%d", r.Method, r.URL.Path, routeCount)
|
||||
http.Error(w, "invalid credential proxy token", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
if route.model != "" && r.Body != nil && (r.Method == http.MethodPost || r.Method == http.MethodPut) {
|
||||
const rewriteLimit = 64 << 20
|
||||
if r.ContentLength > rewriteLimit {
|
||||
http.Error(w, "credential proxy request body is too large", http.StatusRequestEntityTooLarge)
|
||||
return
|
||||
}
|
||||
buffered, err := io.ReadAll(io.LimitReader(r.Body, rewriteLimit+1))
|
||||
switch {
|
||||
case err != nil:
|
||||
_ = r.Body.Close()
|
||||
http.Error(w, "credential proxy could not read request body", http.StatusBadRequest)
|
||||
return
|
||||
case int64(len(buffered)) > rewriteLimit:
|
||||
_ = r.Body.Close()
|
||||
http.Error(w, "credential proxy request body is too large", http.StatusRequestEntityTooLarge)
|
||||
return
|
||||
default:
|
||||
_ = r.Body.Close()
|
||||
body := rewriteJSONModel(buffered, route.model)
|
||||
r.Body = io.NopCloser(bytes.NewReader(body))
|
||||
r.ContentLength = int64(len(body))
|
||||
r.Header.Set("Content-Length", strconv.Itoa(len(body)))
|
||||
}
|
||||
}
|
||||
route.proxy.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
func rewriteJSONModel(body []byte, model string) []byte {
|
||||
if model == "" || len(body) == 0 {
|
||||
return body
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(body, &payload); err != nil || payload == nil {
|
||||
// Unparseable or a literal null body ("null" decodes into a nil
|
||||
// map): assigning into nil would panic, and there is nothing to
|
||||
// rewrite — pass the body through untouched.
|
||||
return body
|
||||
}
|
||||
payload["model"] = model
|
||||
out, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return body
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (p *credentialProxy) setRoute(token string, upstream *url.URL, apiKey, model, kind string) {
|
||||
if kind == "" {
|
||||
kind = "openai"
|
||||
}
|
||||
proxy := &httputil.ReverseProxy{FlushInterval: -1}
|
||||
proxy.Rewrite = func(req *httputil.ProxyRequest) {
|
||||
req.SetURL(upstream)
|
||||
for _, header := range []string{"Forwarded", "X-Forwarded-For", "X-Forwarded-Host", "X-Forwarded-Proto", "X-Real-IP", "Via"} {
|
||||
req.Out.Header.Del(header)
|
||||
}
|
||||
if kind == "anthropic" {
|
||||
req.Out.Header.Del("Authorization")
|
||||
req.Out.Header.Set("x-api-key", apiKey)
|
||||
req.Out.Header.Set("anthropic-version", "2023-06-01")
|
||||
} else {
|
||||
req.Out.Header.Del("x-api-key")
|
||||
req.Out.Header.Set("Authorization", "Bearer "+apiKey)
|
||||
}
|
||||
}
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
p.routes[token] = &credProxyRoute{proxy: proxy, model: model}
|
||||
}
|
||||
|
||||
func (p *credentialProxy) close() {
|
||||
p.mu.Lock()
|
||||
server, listener := p.server, p.ln
|
||||
p.server, p.ln = nil, nil
|
||||
p.mu.Unlock()
|
||||
if server != nil {
|
||||
_ = server.Close()
|
||||
}
|
||||
if listener != nil {
|
||||
_ = listener.Close()
|
||||
}
|
||||
}
|
||||
|
||||
func bearerToken(header string) string {
|
||||
prefix, value, ok := strings.Cut(strings.TrimSpace(header), " ")
|
||||
if !ok || !strings.EqualFold(prefix, "Bearer") {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
|
||||
// credentialProxyPort returns the proxy's loopback port, starting the proxy
|
||||
// on first use.
|
||||
func (a *App) credentialProxyPort() (int, error) {
|
||||
a.credProxyMu.Lock()
|
||||
defer a.credProxyMu.Unlock()
|
||||
if a.credProxy != nil {
|
||||
return a.credProxy.port, nil
|
||||
}
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("credential proxy: listen: %w", err)
|
||||
}
|
||||
p := &credentialProxy{ln: ln, port: ln.Addr().(*net.TCPAddr).Port, routes: map[string]*credProxyRoute{}}
|
||||
server := &http.Server{
|
||||
Handler: p,
|
||||
ReadHeaderTimeout: 10 * time.Second,
|
||||
IdleTimeout: 2 * time.Minute,
|
||||
MaxHeaderBytes: 1 << 20,
|
||||
}
|
||||
p.server = server
|
||||
a.credProxy = p
|
||||
a.goSafe("credentialProxy", func() { _ = server.Serve(ln) })
|
||||
return p.port, nil
|
||||
}
|
||||
|
||||
func (a *App) closeCredentialProxy() {
|
||||
a.credProxyMu.Lock()
|
||||
defer a.credProxyMu.Unlock()
|
||||
if a.credProxy != nil {
|
||||
a.credProxy.close()
|
||||
a.credProxy = nil
|
||||
}
|
||||
}
|
||||
|
||||
// credentialProxySecret loads (creating on first use) the persisted random
|
||||
// secret every virtual token derives from. Rotating it revokes all tokens.
|
||||
func (a *App) credentialProxySecret() (string, error) {
|
||||
remotePrefsMu.Lock()
|
||||
defer remotePrefsMu.Unlock()
|
||||
p, err := updateRemotePrefsLocked(func(p *remotePrefs) (bool, error) {
|
||||
if p.CredentialProxySecret != "" {
|
||||
return false, nil
|
||||
}
|
||||
buf := make([]byte, 32)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
return false, fmt.Errorf("credential proxy: generate secret: %w", err)
|
||||
}
|
||||
p.CredentialProxySecret = hex.EncodeToString(buf)
|
||||
return true, nil
|
||||
})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("credential proxy: persist secret: %w", err)
|
||||
}
|
||||
return p.CredentialProxySecret, nil
|
||||
}
|
||||
|
||||
// credentialProxyModelTokenFor gives each staged model an immutable route.
|
||||
// A controller already running with the previous virtual token therefore keeps
|
||||
// its old upstream for the whole turn while Serve builds and publishes the new
|
||||
// controller. This is the cross-process half of failure-atomic model switches.
|
||||
func credentialProxyModelTokenFor(secret, hostID, workspace, modelRef string) string {
|
||||
mac := hmac.New(sha256.New, []byte(secret))
|
||||
_, _ = mac.Write([]byte("reasonix-credential-proxy-model:v2"))
|
||||
for _, field := range []string{hostID, workspace, modelRef} {
|
||||
var size [8]byte
|
||||
binary.BigEndian.PutUint64(size[:], uint64(len(field)))
|
||||
_, _ = mac.Write(size[:])
|
||||
_, _ = mac.Write([]byte(field))
|
||||
}
|
||||
return hex.EncodeToString(mac.Sum(nil))[:32]
|
||||
}
|
||||
|
||||
func (a *App) credentialProxyModelToken(hostID, workspace, modelRef string) (string, error) {
|
||||
secret, err := a.credentialProxySecret()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return credentialProxyModelTokenFor(secret, hostID, workspace, modelRef), nil
|
||||
}
|
||||
|
||||
// credentialProxyRouteInfo is everything a serve bootstrap needs to install
|
||||
// the desktop hop on the remote: the virtual token, the model name and
|
||||
// provider kind the remote provider entry should carry, and the proxy's
|
||||
// loopback port.
|
||||
type credentialProxyRouteInfo struct {
|
||||
token string
|
||||
model string
|
||||
kind string
|
||||
port int
|
||||
}
|
||||
|
||||
// proxyUpstream is the resolved desktop-side provider a route forwards to.
|
||||
type proxyUpstream struct {
|
||||
apiKey string
|
||||
url *url.URL
|
||||
model string
|
||||
kind string
|
||||
}
|
||||
|
||||
// resolveProxyProvider resolves a desktop model ref into the upstream the
|
||||
// credential proxy should forward to, including the auth-header shape its
|
||||
// provider kind expects.
|
||||
func resolveProxyProvider(cfg *config.Config, ref string) (proxyUpstream, error) {
|
||||
entry, ok := cfg.ResolveModel(ref)
|
||||
if !ok {
|
||||
return proxyUpstream{}, fmt.Errorf("credential proxy: model %q has no provider", ref)
|
||||
}
|
||||
apiKey := config.ResolveCredential(entry.APIKeyEnv).Value
|
||||
if apiKey == "" {
|
||||
return proxyUpstream{}, fmt.Errorf("credential proxy: the local provider credential is not configured")
|
||||
}
|
||||
base := strings.TrimSpace(entry.BaseURL)
|
||||
if base == "" {
|
||||
base = "https://api.openai.com"
|
||||
}
|
||||
upstream, err := url.Parse(strings.TrimRight(base, "/") + "/")
|
||||
if err != nil {
|
||||
return proxyUpstream{}, fmt.Errorf("credential proxy: provider base_url: %w", err)
|
||||
}
|
||||
if (upstream.Scheme != "http" && upstream.Scheme != "https") || upstream.Host == "" || upstream.User != nil || upstream.Fragment != "" {
|
||||
return proxyUpstream{}, fmt.Errorf("credential proxy: provider base_url must be an http(s) URL without credentials or a fragment")
|
||||
}
|
||||
kind := strings.TrimSpace(entry.Kind)
|
||||
if kind == "" {
|
||||
kind = "openai"
|
||||
}
|
||||
return proxyUpstream{apiKey: apiKey, url: upstream, model: entry.Model, kind: kind}, nil
|
||||
}
|
||||
|
||||
// registerCredentialProxyRoute binds one workspace token to the current
|
||||
// desktop default provider without exposing its real key to the remote.
|
||||
func (a *App) registerCredentialProxyRoute(hostID, workspace string) (credentialProxyRouteInfo, error) {
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
return credentialProxyRouteInfo{}, err
|
||||
}
|
||||
ref := strings.TrimSpace(cfg.DefaultModel)
|
||||
if workspaceModel := a.desktopModelForWorkspace(hostID, workspace); workspaceModel != "" {
|
||||
ref = workspaceModel
|
||||
}
|
||||
return a.applyCredentialProxyModel(hostID, workspace, ref)
|
||||
}
|
||||
|
||||
// desktopModelForWorkspace deterministically selects the newest tab-owned
|
||||
// model for a workspace; map iteration order must never choose a route.
|
||||
func (a *App) desktopModelForWorkspace(hostID, workspace string) string {
|
||||
a.remoteTabMu.Lock()
|
||||
defer a.remoteTabMu.Unlock()
|
||||
var selected string
|
||||
var selectedSeq uint64
|
||||
for _, tab := range a.remoteTabs {
|
||||
if tab == nil || tab.ref.HostID != hostID || tab.ref.Workspace != workspace || strings.TrimSpace(tab.model) == "" {
|
||||
continue
|
||||
}
|
||||
if tab.modelSeq >= selectedSeq {
|
||||
selected, selectedSeq = tab.model, tab.modelSeq
|
||||
}
|
||||
}
|
||||
return selected
|
||||
}
|
||||
|
||||
func (a *App) applyCredentialProxyModel(hostID, workspace, ref string) (credentialProxyRouteInfo, error) {
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
return credentialProxyRouteInfo{}, err
|
||||
}
|
||||
up, err := resolveProxyProvider(cfg, ref)
|
||||
if err != nil {
|
||||
return credentialProxyRouteInfo{}, err
|
||||
}
|
||||
port, err := a.credentialProxyPort()
|
||||
if err != nil {
|
||||
return credentialProxyRouteInfo{}, err
|
||||
}
|
||||
a.credProxyMu.Lock()
|
||||
proxy := a.credProxy
|
||||
a.credProxyMu.Unlock()
|
||||
if proxy == nil {
|
||||
return credentialProxyRouteInfo{}, fmt.Errorf("credential proxy: not running")
|
||||
}
|
||||
// Route tokens include the canonical desktop model ref. Never mutate the
|
||||
// route held by an in-flight controller during a model switch.
|
||||
token, err := a.credentialProxyModelToken(hostID, workspace, ref)
|
||||
if err != nil {
|
||||
return credentialProxyRouteInfo{}, err
|
||||
}
|
||||
proxy.setRoute(token, up.url, up.apiKey, up.model, up.kind)
|
||||
return credentialProxyRouteInfo{token: token, model: up.model, kind: up.kind, port: port}, nil
|
||||
}
|
||||
|
||||
// credentialModeView returns the host entry's normalized credential mode for
|
||||
// views ("" reads as "remote" — the default).
|
||||
func credentialModeView(h config.RemoteHostEntry) string {
|
||||
if h.CredentialProxyEnabled() {
|
||||
return "local-proxy"
|
||||
}
|
||||
return "remote"
|
||||
}
|
||||
|
||||
// normalizeCredentialMode validates an input credential mode.
|
||||
func normalizeCredentialMode(mode string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(mode)) {
|
||||
case "local-proxy":
|
||||
return "local-proxy"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/ssh"
|
||||
|
||||
"reasonix/internal/remote/forward"
|
||||
"reasonix/internal/remote/sshtest"
|
||||
)
|
||||
|
||||
// TestCredentialProxyReverseForwardEndToEnd drives the real SSH forwarding
|
||||
// protocol against an sshtest server: the "remote" loopback listener forwards
|
||||
// connections back through the SSH channel to the desktop-side target, which
|
||||
// is exactly the path local-proxy model calls take. The helper is idempotent.
|
||||
func TestCredentialProxyReverseForwardEndToEnd(t *testing.T) {
|
||||
target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte("proxy-ok"))
|
||||
}))
|
||||
defer target.Close()
|
||||
targetPort := target.Listener.Addr().(*net.TCPAddr).Port
|
||||
|
||||
srv := sshtest.Start(t, sshtest.Options{})
|
||||
cfg := &ssh.ClientConfig{User: "t", HostKeyCallback: ssh.InsecureIgnoreHostKey(), Timeout: 5 * time.Second}
|
||||
cl, err := ssh.Dial("tcp", srv.Addr, cfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = cl.Close() })
|
||||
set := forward.NewSet(nil)
|
||||
set.Attach(cl)
|
||||
lc := newLifecycleSSHClient(nil)
|
||||
lc.forwards = set
|
||||
|
||||
remotePort, err := ensureCredentialProxyForward(lc, "box", targetPort)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if remotePort <= 0 || remotePort > 65535 {
|
||||
t.Fatalf("remote port = %d, want an ephemeral bound port", remotePort)
|
||||
}
|
||||
// Idempotent: a second ensure neither errors nor adds a second forward,
|
||||
// and reports the SAME bound port.
|
||||
again, err := ensureCredentialProxyForward(lc, "box", targetPort)
|
||||
if err != nil {
|
||||
t.Fatalf("second ensure: %v", err)
|
||||
}
|
||||
if again != remotePort {
|
||||
t.Fatalf("second ensure port = %d, want %d", again, remotePort)
|
||||
}
|
||||
if n := len(set.List()); n != 1 {
|
||||
t.Fatalf("forward count = %d, want 1", n)
|
||||
}
|
||||
|
||||
// Dial the REMOTE-side bind address. sshtest runs on localhost, so the
|
||||
// remote loopback port is reachable here; the bytes travel
|
||||
// dial → ssh server → forwarded-tcpip → ssh client → target.
|
||||
resp, err := (&http.Client{Timeout: 5 * time.Second}).Get(fmt.Sprintf("http://127.0.0.1:%d/hello", remotePort))
|
||||
if err != nil {
|
||||
t.Fatalf("reverse forward dial: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
if string(body) != "proxy-ok" {
|
||||
t.Fatalf("body = %q, want proxy-ok", body)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/cookiejar"
|
||||
"os"
|
||||
"os/user"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestCredentialProxyRealHostSmoke is a manual E2E against a real SSH host:
|
||||
// set CRED_PROXY_SMOKE_HOST=<hostID> (whose config entry has
|
||||
// credential_mode = "local-proxy") to run. It drives the full desktop path —
|
||||
// connect, reverse tunnel, credential proxy, serve launch with the virtual
|
||||
// token — then submits one real model turn and asserts the reply arrived
|
||||
// through desktop-held credentials. Costs one tiny provider call.
|
||||
func TestCredentialProxyRealHostSmoke(t *testing.T) {
|
||||
hostID := os.Getenv("CRED_PROXY_SMOKE_HOST")
|
||||
if hostID == "" {
|
||||
t.Skip("set CRED_PROXY_SMOKE_HOST=<hostID with credential_mode=local-proxy> to run the real-host smoke")
|
||||
}
|
||||
// The desktop test binary's TestMain redirects HOME to a scratch dir; the
|
||||
// smoke needs the real user config (hosts, provider, .env key). Point the
|
||||
// config root back at the real ~/.reasonix for this test only.
|
||||
if real, err := user.Current(); err == nil && real.HomeDir != "" {
|
||||
t.Setenv("HOME", real.HomeDir)
|
||||
t.Setenv("REASONIX_HOME", filepath.Join(real.HomeDir, ".reasonix"))
|
||||
} else {
|
||||
t.Fatal("cannot resolve the real home directory")
|
||||
}
|
||||
workspace := os.Getenv("CRED_PROXY_SMOKE_WORKSPACE")
|
||||
if workspace == "" {
|
||||
workspace = "/root/smoke-a"
|
||||
}
|
||||
|
||||
a := &App{ctx: context.Background()}
|
||||
mgr := newDesktopRemoteManager(a)
|
||||
a.remoteRuntime = mgr
|
||||
t.Cleanup(func() { _ = mgr.Close(); a.closeCredentialProxy() })
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
if err := mgr.Connect(hostID); err != nil {
|
||||
t.Fatalf("connect: %v", err)
|
||||
}
|
||||
if err := waitForRemoteHostSmoke(mgr, hostID, 60*time.Second); err != nil {
|
||||
t.Fatalf("host never connected: %v", err)
|
||||
}
|
||||
|
||||
view, token, err := mgr.EnsureServer(ctx, hostID, workspace)
|
||||
if err != nil {
|
||||
t.Fatalf("EnsureServer: %v", err)
|
||||
}
|
||||
if view.State != "ready" || view.LocalURL == "" {
|
||||
t.Fatalf("serve view = %+v", view)
|
||||
}
|
||||
base := strings.TrimRight(view.LocalURL, "/")
|
||||
t.Logf("serve ready at %s (workspace %s)", base, workspace)
|
||||
|
||||
// Verify the reverse tunnel is registered and the remote provider entry
|
||||
// points at it.
|
||||
foundForward := false
|
||||
for _, f := range mgr.client(hostID).Forwards().List() {
|
||||
if f.Spec.Name == "cred-proxy:"+hostID && f.Up {
|
||||
foundForward = true
|
||||
}
|
||||
}
|
||||
if !foundForward {
|
||||
t.Fatal("credential proxy reverse forward is not up")
|
||||
}
|
||||
|
||||
// One real model turn through the desktop-held key.
|
||||
jar, _ := cookiejar.New(nil)
|
||||
client := &http.Client{Jar: jar, Timeout: 120 * time.Second}
|
||||
if err := serveHandshake(ctx, client, base, token); err != nil {
|
||||
t.Fatalf("handshake: %v", err)
|
||||
}
|
||||
post := func(path string, body any) (int, []byte) {
|
||||
data, _ := json.Marshal(body)
|
||||
req, _ := http.NewRequest(http.MethodPost, base+path, strings.NewReader(string(data)))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", path, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
out, _ := io.ReadAll(resp.Body)
|
||||
return resp.StatusCode, out
|
||||
}
|
||||
if code, body := post("/new", map[string]string{}); code != 204 {
|
||||
t.Fatalf("/new: %d %s", code, body)
|
||||
}
|
||||
if code, body := post("/submit", map[string]string{"input": "Reply with exactly the word PROXY-OK and nothing else."}); code != 202 {
|
||||
t.Fatalf("/submit: %d %s", code, body)
|
||||
}
|
||||
|
||||
// Poll history for the assistant reply (the turn rides the tunnel).
|
||||
deadline := time.Now().Add(90 * time.Second)
|
||||
for {
|
||||
resp, err := client.Get(base + "/history")
|
||||
if err == nil {
|
||||
out, _ := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
if strings.Contains(string(out), "PROXY-OK") {
|
||||
t.Logf("model replied through the desktop credential proxy")
|
||||
return
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("/history: %d %s", resp.StatusCode, out)
|
||||
}
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf("no PROXY-OK reply within 90s (last history fetch err=%v)", err)
|
||||
}
|
||||
time.Sleep(2 * time.Second)
|
||||
}
|
||||
}
|
||||
|
||||
func waitForRemoteHostSmoke(rt remoteKernel, hostID string, timeout time.Duration) error {
|
||||
deadline := time.Now().Add(timeout)
|
||||
for {
|
||||
for _, status := range rt.Statuses() {
|
||||
if status.HostID != hostID {
|
||||
continue
|
||||
}
|
||||
switch status.State {
|
||||
case "connected", "degraded":
|
||||
return nil
|
||||
case "stopped":
|
||||
if status.Error != "" {
|
||||
return fmt.Errorf("remote host %q: %s", hostID, status.Error)
|
||||
}
|
||||
return fmt.Errorf("remote host %q stopped", hostID)
|
||||
}
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
return fmt.Errorf("remote host %q: connection timed out", hostID)
|
||||
}
|
||||
time.Sleep(250 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,424 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/config"
|
||||
"reasonix/internal/remote/bootstrap"
|
||||
)
|
||||
|
||||
type failingRequestBody struct{}
|
||||
|
||||
func (failingRequestBody) Read([]byte) (int, error) { return 0, errors.New("read failed") }
|
||||
func (failingRequestBody) Close() error { return nil }
|
||||
|
||||
func mustParseURL(t *testing.T, raw string) *url.URL {
|
||||
t.Helper()
|
||||
u, err := url.Parse(strings.TrimRight(raw, "/") + "/")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return u
|
||||
}
|
||||
|
||||
// TestCredentialProxyAuthSwap covers the desktop key holder over real HTTP:
|
||||
// the registered virtual token forwards to the provider with the real key,
|
||||
// anything else is rejected without reaching the provider.
|
||||
func TestCredentialProxyAuthSwap(t *testing.T) {
|
||||
var gotAuth, gotForwarded string
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotAuth = r.Header.Get("Authorization")
|
||||
for _, h := range []string{"Forwarded", "X-Forwarded-For", "X-Forwarded-Host", "X-Forwarded-Proto", "X-Real-IP", "Via"} {
|
||||
gotForwarded += r.Header.Get(h)
|
||||
}
|
||||
_, _ = w.Write([]byte("model-ok"))
|
||||
}))
|
||||
defer upstream.Close()
|
||||
|
||||
seedBridgeTestHost(t, "box")
|
||||
a := &App{}
|
||||
t.Cleanup(a.closeCredentialProxy)
|
||||
port, err := a.credentialProxyPort()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
const token = "virtual-tok"
|
||||
a.credProxy.setRoute(token, mustParseURL(t, upstream.URL), "sk-real-key", "", "")
|
||||
proxyURL := fmt.Sprintf("http://127.0.0.1:%d/v1/chat", port)
|
||||
|
||||
do := func(auth string) (int, string) {
|
||||
req, err := http.NewRequest(http.MethodPost, proxyURL, strings.NewReader("{}"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req.Header.Set("Authorization", auth)
|
||||
req.Header.Set("Connection", "Authorization")
|
||||
req.Header.Set("Forwarded", "for=attacker")
|
||||
req.Header.Set("X-Forwarded-For", "203.0.113.9")
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
buf := make([]byte, 64)
|
||||
n, _ := resp.Body.Read(buf)
|
||||
return resp.StatusCode, string(buf[:n])
|
||||
}
|
||||
|
||||
if code, body := do("Bearer virtual-tok"); code != 200 || body != "model-ok" {
|
||||
t.Fatalf("valid token: code=%d body=%q", code, body)
|
||||
}
|
||||
if gotAuth != "Bearer sk-real-key" {
|
||||
t.Fatalf("upstream auth = %q, want the real key", gotAuth)
|
||||
}
|
||||
if gotForwarded != "" {
|
||||
t.Fatalf("forwarding identity leaked upstream: %q", gotForwarded)
|
||||
}
|
||||
if code, _ := do("Bearer wrong"); code != 401 {
|
||||
t.Fatalf("wrong token: code=%d, want 401", code)
|
||||
}
|
||||
if gotAuth != "Bearer sk-real-key" {
|
||||
t.Fatalf("rejected request reached the upstream: %q", gotAuth)
|
||||
}
|
||||
if code, _ := do(""); code != 401 {
|
||||
t.Fatalf("missing token: code=%d, want 401", code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCredentialProxyRewritesRequestModel: desktop owns the current model, so
|
||||
// the proxy replaces the serve's request-body model with the desktop selection
|
||||
// before the real provider sees it. The provider must also see its OWN host
|
||||
// in the Host header — the inbound loopback host must not leak through
|
||||
// (CloudFront-fronted APIs 403 a foreign Host).
|
||||
func TestCredentialProxyRewritesRequestModel(t *testing.T) {
|
||||
var gotBody, gotHost string
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
buf, _ := io.ReadAll(r.Body)
|
||||
gotBody = string(buf)
|
||||
gotHost = r.Host
|
||||
_, _ = w.Write([]byte("model-ok"))
|
||||
}))
|
||||
defer upstream.Close()
|
||||
|
||||
seedBridgeTestHost(t, "box")
|
||||
a := &App{}
|
||||
t.Cleanup(a.closeCredentialProxy)
|
||||
port, err := a.credentialProxyPort()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
const token = "virtual-tok"
|
||||
a.credProxy.setRoute(token, mustParseURL(t, upstream.URL), "sk-real-key", "deepseek-v4-pro", "openai")
|
||||
|
||||
req, err := http.NewRequest(http.MethodPost, fmt.Sprintf("http://127.0.0.1:%d/v1/chat/completions", port), strings.NewReader(`{"model":"deepseek-v4-flash","messages":[]}`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer virtual-tok")
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d", resp.StatusCode)
|
||||
}
|
||||
if !strings.Contains(gotBody, `"model":"deepseek-v4-pro"`) {
|
||||
t.Fatalf("upstream body = %q, want rewritten model deepseek-v4-pro", gotBody)
|
||||
}
|
||||
if strings.Contains(gotBody, "deepseek-v4-flash") {
|
||||
t.Fatalf("upstream still saw the serve's model: %q", gotBody)
|
||||
}
|
||||
if want := strings.TrimPrefix(strings.TrimPrefix(upstream.URL, "http://"), "http://"); gotHost != want {
|
||||
t.Fatalf("upstream Host = %q, want the upstream's own host %q", gotHost, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCredentialProxyRejectsUnreadableOrOversizeBodies(t *testing.T) {
|
||||
upstreamCalls := 0
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
|
||||
upstreamCalls++
|
||||
}))
|
||||
defer upstream.Close()
|
||||
p := &credentialProxy{routes: map[string]*credProxyRoute{}}
|
||||
p.setRoute("virtual-tok", mustParseURL(t, upstream.URL), "sk-real-key", "model", "openai")
|
||||
|
||||
request := func(body io.ReadCloser, contentLength int64) int {
|
||||
req := httptest.NewRequest(http.MethodPost, "http://127.0.0.1/v1/chat/completions", body)
|
||||
req.Header.Set("Authorization", "Bearer virtual-tok")
|
||||
req.ContentLength = contentLength
|
||||
recorder := httptest.NewRecorder()
|
||||
p.ServeHTTP(recorder, req)
|
||||
return recorder.Code
|
||||
}
|
||||
if code := request(failingRequestBody{}, -1); code != http.StatusBadRequest {
|
||||
t.Fatalf("unreadable body status = %d, want 400", code)
|
||||
}
|
||||
if code := request(io.NopCloser(strings.NewReader("{}")), (64<<20)+1); code != http.StatusRequestEntityTooLarge {
|
||||
t.Fatalf("oversize body status = %d, want 413", code)
|
||||
}
|
||||
if upstreamCalls != 0 {
|
||||
t.Fatalf("invalid bodies reached upstream %d times", upstreamCalls)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCredentialProxyTokenStableAcrossRestarts: the virtual token derives
|
||||
// from a persisted secret plus host/workspace/model identity, so a restarted
|
||||
// desktop keeps the same route while distinct workspaces stay isolated.
|
||||
func TestCredentialProxyTokenStableAcrossRestarts(t *testing.T) {
|
||||
seedBridgeTestHost(t, "box")
|
||||
a1 := &App{}
|
||||
t.Cleanup(a1.closeCredentialProxy)
|
||||
i1, err := a1.registerCredentialProxyRoute("box", "~/app")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a2 := &App{}
|
||||
t.Cleanup(a2.closeCredentialProxy)
|
||||
i2, err := a2.registerCredentialProxyRoute("box", "~/app")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if i1.token == "" || i1.token != i2.token {
|
||||
t.Fatalf("token drifted across App instances: %q vs %q", i1.token, i2.token)
|
||||
}
|
||||
i3, err := a2.registerCredentialProxyRoute("other", "~/app")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if i3.token == i1.token {
|
||||
t.Fatalf("different hosts share a token: %q", i1.token)
|
||||
}
|
||||
i4, err := a2.registerCredentialProxyRoute("box", "~/other")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if i4.token == i1.token {
|
||||
t.Fatalf("different workspaces share a token: %q", i1.token)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCredentialProxyModelTokensKeepRoutesImmutable(t *testing.T) {
|
||||
secret := strings.Repeat("ab", 32)
|
||||
one := credentialProxyModelTokenFor(secret, "box", "~/app", "provider/model-a")
|
||||
two := credentialProxyModelTokenFor(secret, "box", "~/app", "provider/model-b")
|
||||
again := credentialProxyModelTokenFor(secret, "box", "~/app", "provider/model-a")
|
||||
if one == two {
|
||||
t.Fatal("different models shared one mutable credential proxy route token")
|
||||
}
|
||||
if one != again {
|
||||
t.Fatal("the same model route token was not stable across registration")
|
||||
}
|
||||
if collision := credentialProxyModelTokenFor(secret, "box", "~/app", "provider:model-a"); collision == credentialProxyModelTokenFor(secret, "box", "~/app:provider", "model-a") {
|
||||
t.Fatal("length-framed route identities shared a token")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCredentialProxyReconnectRegistersTrackedWorkspaces(t *testing.T) {
|
||||
seedBridgeTestHost(t, "box")
|
||||
app := &App{}
|
||||
t.Cleanup(app.closeCredentialProxy)
|
||||
mgr := newDesktopRemoteManager(app)
|
||||
mgr.hosts["box"] = &managedHost{serves: map[string]*serveEntry{
|
||||
"~/app": {},
|
||||
"~/other": {},
|
||||
}}
|
||||
info, err := mgr.registerTrackedCredentialRoutes(app, "box", "~/app")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
otherToken, err := app.credentialProxyModelToken("box", "~/other", cfg.DefaultModel)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
app.credProxy.mu.Lock()
|
||||
defer app.credProxy.mu.Unlock()
|
||||
if info.token == "" || app.credProxy.routes[info.token] == nil || app.credProxy.routes[otherToken] == nil {
|
||||
t.Fatalf("tracked routes were not registered together: current=%q count=%d", info.token, len(app.credProxy.routes))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCredentialWatchdogHealsEveryTrackedWorkspace(t *testing.T) {
|
||||
mgr := newDesktopRemoteManager(nil)
|
||||
mgr.hosts["box"] = &managedHost{serves: map[string]*serveEntry{
|
||||
"~/alpha": {},
|
||||
"~/beta": {},
|
||||
"~/gamma": {},
|
||||
}}
|
||||
workspaces := mgr.trackedCredentialWorkspaces("box", "~/beta")
|
||||
want := []string{"~/beta", "~/alpha", "~/gamma"}
|
||||
if !slices.Equal(workspaces, want) {
|
||||
t.Fatalf("tracked credential workspaces = %v, want %v", workspaces, want)
|
||||
}
|
||||
|
||||
var setupCalls, healCalls []string
|
||||
err := healTrackedCredentialProviders(context.Background(), workspaces,
|
||||
func(workspace string) (*bootstrap.CredentialProxyOptions, error) {
|
||||
setupCalls = append(setupCalls, workspace)
|
||||
return &bootstrap.CredentialProxyOptions{Provider: workspace}, nil
|
||||
},
|
||||
func(_ context.Context, opts *bootstrap.CredentialProxyOptions) error {
|
||||
healCalls = append(healCalls, opts.Provider)
|
||||
return nil
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !slices.Equal(setupCalls, want) || !slices.Equal(healCalls, want) {
|
||||
t.Fatalf("credential heals = setup:%v heal:%v, want every workspace %v", setupCalls, healCalls, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCredentialEnsureHealsEveryConfigBeforeReload(t *testing.T) {
|
||||
workspaces := []string{"~/current", "~/peer"}
|
||||
var calls []string
|
||||
err := healCredentialConfigsBeforeReload(t.Context(), workspaces,
|
||||
func(workspace string) (*bootstrap.CredentialProxyOptions, error) {
|
||||
calls = append(calls, "setup:"+workspace)
|
||||
return &bootstrap.CredentialProxyOptions{Provider: workspace}, nil
|
||||
},
|
||||
func(_ context.Context, opts *bootstrap.CredentialProxyOptions) error {
|
||||
calls = append(calls, "heal:"+opts.Provider)
|
||||
return nil
|
||||
},
|
||||
func() bool {
|
||||
calls = append(calls, "reload")
|
||||
return true
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := []string{"setup:~/current", "heal:~/current", "setup:~/peer", "heal:~/peer", "reload"}
|
||||
if !slices.Equal(calls, want) {
|
||||
t.Fatalf("ensure heal order = %v, want %v", calls, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureServerRejectsRemovedHost(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("REASONIX_HOME", home)
|
||||
t.Setenv("HOME", home)
|
||||
client := newLifecycleSSHClient(nil)
|
||||
mgr := newDesktopRemoteManager(nil)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
t.Cleanup(cancel)
|
||||
mgr.hosts["removed"] = &managedHost{ctx: ctx, cancel: cancel, client: client, serves: map[string]*serveEntry{}}
|
||||
called := false
|
||||
mgr.ensureServe = func(context.Context, bootstrap.Conn, bootstrap.Options) (bootstrap.Result, error) {
|
||||
called = true
|
||||
return bootstrap.Result{}, nil
|
||||
}
|
||||
if _, _, err := mgr.EnsureServer(context.Background(), "removed", "~/app"); err == nil || !strings.Contains(err.Error(), "no longer configured") {
|
||||
t.Fatalf("EnsureServer removed host error = %v", err)
|
||||
}
|
||||
if called {
|
||||
t.Fatal("removed host reached remote bootstrap")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCredentialProxyAnthropicAuthShape: an anthropic-kind route swaps the
|
||||
// virtual token for x-api-key (+ anthropic-version) instead of a bearer
|
||||
// header.
|
||||
func TestCredentialProxyAnthropicAuthShape(t *testing.T) {
|
||||
var gotKey, gotVersion, gotAuth string
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotKey = r.Header.Get("x-api-key")
|
||||
gotVersion = r.Header.Get("anthropic-version")
|
||||
gotAuth = r.Header.Get("Authorization")
|
||||
_, _ = w.Write([]byte("ok"))
|
||||
}))
|
||||
defer upstream.Close()
|
||||
|
||||
seedBridgeTestHost(t, "box")
|
||||
a := &App{}
|
||||
t.Cleanup(a.closeCredentialProxy)
|
||||
port, err := a.credentialProxyPort()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a.credProxy.setRoute("virtual-tok", mustParseURL(t, upstream.URL), "sk-real-key", "", "anthropic")
|
||||
req, err := http.NewRequest(http.MethodPost, fmt.Sprintf("http://127.0.0.1:%d/v1/messages", port), strings.NewReader("{}"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer virtual-tok")
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d", resp.StatusCode)
|
||||
}
|
||||
if gotKey != "sk-real-key" {
|
||||
t.Fatalf("x-api-key = %q, want the real key", gotKey)
|
||||
}
|
||||
if gotVersion == "" {
|
||||
t.Fatal("anthropic-version header missing")
|
||||
}
|
||||
if gotAuth != "" {
|
||||
t.Fatalf("Authorization header leaked to the anthropic upstream: %q", gotAuth)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRewriteJSONModelGuards: a literal null body must pass through without
|
||||
// panicking (assigning into a nil map would), and a non-JSON body stays
|
||||
// untouched.
|
||||
func TestRewriteJSONModelGuards(t *testing.T) {
|
||||
if got := rewriteJSONModel([]byte("null"), "m"); string(got) != "null" {
|
||||
t.Fatalf("null body rewritten: %q", got)
|
||||
}
|
||||
if got := rewriteJSONModel([]byte("not json"), "m"); string(got) != "not json" {
|
||||
t.Fatalf("non-JSON body rewritten: %q", got)
|
||||
}
|
||||
if got := rewriteJSONModel([]byte(`{"model":"a"}`), ""); string(got) != `{"model":"a"}` {
|
||||
t.Fatalf("empty model rewrote the body: %q", got)
|
||||
}
|
||||
if got := rewriteJSONModel([]byte(`{"model":"a"}`), "b"); !strings.Contains(string(got), `"model":"b"`) {
|
||||
t.Fatalf("model not rewritten: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCredentialModeConfigRoundTrip pins the host entry field end to end.
|
||||
func TestCredentialModeConfigRoundTrip(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("REASONIX_HOME", home)
|
||||
t.Setenv("HOME", home)
|
||||
if err := editUserConfig(func(c *config.Config) error {
|
||||
return c.UpsertRemoteHost(config.RemoteHostEntry{
|
||||
Name: "p", Host: "127.0.0.1", CredentialMode: "local-proxy",
|
||||
})
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
entry, ok := cfg.RemoteHost("p")
|
||||
if !ok || !entry.CredentialProxyEnabled() {
|
||||
t.Fatalf("credential mode did not round-trip: %+v", entry)
|
||||
}
|
||||
if v := credentialModeView(entry); v != "local-proxy" {
|
||||
t.Fatalf("view mode = %q", v)
|
||||
}
|
||||
if n := normalizeCredentialMode("bogus"); n != "" {
|
||||
t.Fatalf("bogus mode normalized to %q", n)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const frontendDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
process.env.PLAYWRIGHT_BROWSERS_PATH = !process.env.PLAYWRIGHT_BROWSERS_PATH || process.env.PLAYWRIGHT_BROWSERS_PATH === ".pw-browsers"
|
||||
? path.join(frontendDir, ".pw-browsers")
|
||||
: process.env.PLAYWRIGHT_BROWSERS_PATH;
|
||||
const { chromium } = await import("playwright");
|
||||
|
||||
const styles = fs.readFileSync(path.join(frontendDir, "src", "styles.css"), "utf8");
|
||||
const themeStyles = ["graphite", "aurora", "slate", "carbon", "nocturne", "amber"];
|
||||
const modes = [
|
||||
{ name: "forced dark", attribute: "dark", media: "dark" },
|
||||
{ name: "forced light", attribute: "light", media: "dark" },
|
||||
{ name: "auto dark", attribute: null, media: "dark" },
|
||||
{ name: "auto light", attribute: null, media: "light" },
|
||||
];
|
||||
const overlaySelectors = [
|
||||
".anchored-popover",
|
||||
".slashmenu",
|
||||
".composer-access-menu",
|
||||
".jobs-popover",
|
||||
".modelsw__menu",
|
||||
".workspace-recent-menu",
|
||||
".floating-menu",
|
||||
".mem-ws-select__menu",
|
||||
".menu",
|
||||
".sound-select__menu",
|
||||
".settings-model-picker__menu",
|
||||
".external-opener__menu",
|
||||
".context-menu",
|
||||
".theme-gallery__menu",
|
||||
".theme-gallery__editor",
|
||||
".theme-editor__header",
|
||||
".theme-editor__live",
|
||||
".provider-access-more__menu",
|
||||
".topicbar__more-menu",
|
||||
".topicbar__more-export-menu",
|
||||
".heartbeat-filter-menu",
|
||||
".heartbeat-project-menu",
|
||||
".heartbeat-task-menu",
|
||||
];
|
||||
const surfaceSelectors = [
|
||||
".msg-pasted-block",
|
||||
".composer__pasted-block",
|
||||
".mem-tab-select",
|
||||
".mermaid-diagram__error",
|
||||
];
|
||||
const fixtures = [...new Set([...overlaySelectors, ...surfaceSelectors])]
|
||||
.map((selector) => `<div class="${selector.slice(1)}">surface</div>`)
|
||||
.join("");
|
||||
|
||||
const amberExpected = {
|
||||
dark: {
|
||||
"--bg": "#090a0c",
|
||||
"--bg-soft": "#111319",
|
||||
"--panel": "#191b22",
|
||||
"--sidebar-bg": "#0c0e12",
|
||||
"--fg": "#f4f5f7",
|
||||
"--fg-dim": "#c0c4cc",
|
||||
"--accent": "#d4632f",
|
||||
"--border": "#343945",
|
||||
},
|
||||
light: {
|
||||
"--bg": "#f7f8fb",
|
||||
"--bg-soft": "#eef2f7",
|
||||
"--panel": "#ffffff",
|
||||
"--sidebar-bg": "#f3f6fa",
|
||||
"--fg": "#111827",
|
||||
"--fg-dim": "#4b5563",
|
||||
"--accent": "#dd5b28",
|
||||
"--border": "#d8dee8",
|
||||
},
|
||||
};
|
||||
|
||||
function assert(condition, message) {
|
||||
if (!condition) throw new Error(message);
|
||||
process.stdout.write(` PASS ${message}\n`);
|
||||
}
|
||||
|
||||
let browser;
|
||||
try {
|
||||
browser = await chromium.launch({ headless: true });
|
||||
const page = await browser.newPage({ viewport: { width: 1280, height: 800 } });
|
||||
await page.setContent(`<!doctype html><html><head><style>${styles}</style></head><body>${fixtures}<div class="transcript__loading">muted</div></body></html>`);
|
||||
|
||||
for (const style of themeStyles) {
|
||||
for (const mode of modes) {
|
||||
await page.emulateMedia({ colorScheme: mode.media });
|
||||
await page.evaluate(({ style, attribute }) => {
|
||||
const root = document.documentElement;
|
||||
root.setAttribute("data-theme-style", style);
|
||||
if (attribute) root.setAttribute("data-theme", attribute);
|
||||
else root.removeAttribute("data-theme");
|
||||
root.removeAttribute("data-theme-pack");
|
||||
root.removeAttribute("data-theme-has-bg");
|
||||
root.removeAttribute("data-theme-scene");
|
||||
root.style.removeProperty("--theme-pane-overlay-pct");
|
||||
root.style.removeProperty("--theme-pane-task-overlay-pct");
|
||||
}, { style, attribute: mode.attribute });
|
||||
|
||||
const result = await page.evaluate(({ overlaySelectors, surfaceSelectors }) => {
|
||||
function colorAlpha(value) {
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = 1;
|
||||
canvas.height = 1;
|
||||
const context = canvas.getContext("2d");
|
||||
context.clearRect(0, 0, 1, 1);
|
||||
context.fillStyle = value;
|
||||
context.fillRect(0, 0, 1, 1);
|
||||
return context.getImageData(0, 0, 1, 1).data[3] / 255;
|
||||
}
|
||||
const alpha = (selector) => colorAlpha(getComputedStyle(document.querySelector(selector)).backgroundColor);
|
||||
const muted = document.querySelector(".transcript__loading");
|
||||
const probe = document.createElement("span");
|
||||
probe.style.color = "var(--fg-faint)";
|
||||
document.body.append(probe);
|
||||
const mutedColor = getComputedStyle(muted).color;
|
||||
const expectedMutedColor = getComputedStyle(probe).color;
|
||||
probe.remove();
|
||||
return {
|
||||
overlays: Object.fromEntries(overlaySelectors.map((selector) => [selector, alpha(selector)])),
|
||||
surfaces: Object.fromEntries(surfaceSelectors.map((selector) => [selector, alpha(selector)])),
|
||||
mutedColor,
|
||||
expectedMutedColor,
|
||||
};
|
||||
}, { overlaySelectors, surfaceSelectors });
|
||||
|
||||
for (const [selector, alpha] of Object.entries(result.overlays)) {
|
||||
assert(alpha >= 0.999, `${style} ${mode.name} keeps ${selector} opaque (${alpha})`);
|
||||
}
|
||||
for (const [selector, alpha] of Object.entries(result.surfaces)) {
|
||||
assert(alpha > 0, `${style} ${mode.name} resolves ${selector} background (${alpha})`);
|
||||
}
|
||||
assert(result.mutedColor === result.expectedMutedColor, `${style} ${mode.name} resolves muted text to --fg-faint`);
|
||||
|
||||
if (style === "amber") {
|
||||
const expected = amberExpected[mode.media === "light" || mode.attribute === "light" ? "light" : "dark"];
|
||||
const colors = await page.evaluate((entries) => {
|
||||
const result = {};
|
||||
for (const [token] of entries) {
|
||||
const probe = document.createElement("span");
|
||||
probe.style.backgroundColor = `var(${token})`;
|
||||
document.body.append(probe);
|
||||
result[token] = getComputedStyle(probe).backgroundColor;
|
||||
probe.remove();
|
||||
}
|
||||
return result;
|
||||
}, Object.entries(expected));
|
||||
const expectedColors = await page.evaluate((entries) => {
|
||||
const result = {};
|
||||
for (const [token, value] of entries) {
|
||||
const probe = document.createElement("span");
|
||||
probe.style.backgroundColor = value;
|
||||
document.body.append(probe);
|
||||
result[token] = getComputedStyle(probe).backgroundColor;
|
||||
probe.remove();
|
||||
}
|
||||
return result;
|
||||
}, Object.entries(expected));
|
||||
for (const token of Object.keys(expected)) {
|
||||
assert(colors[token] === expectedColors[token], `Amber ${mode.name} ${token} matches preview (${colors[token]})`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await page.emulateMedia({ colorScheme: "dark" });
|
||||
for (const [paneOpacity, expectedAlpha] of [[0, 0.4], [0.5, 0.9], [1, 1]]) {
|
||||
for (const scene of ["home", "task"]) {
|
||||
const alphas = await page.evaluate(({ overlaySelectors, paneOpacity, scene }) => {
|
||||
const root = document.documentElement;
|
||||
const percentage = `${Math.min((paneOpacity + 0.4) * 100, 100)}%`;
|
||||
root.setAttribute("data-theme-style", "graphite");
|
||||
root.setAttribute("data-theme", "dark");
|
||||
root.setAttribute("data-theme-pack", "surface-contract");
|
||||
root.setAttribute("data-theme-has-bg", "true");
|
||||
root.setAttribute("data-theme-scene", scene);
|
||||
root.style.setProperty("--theme-pane-overlay-pct", percentage);
|
||||
root.style.setProperty("--theme-pane-task-overlay-pct", percentage);
|
||||
return Object.fromEntries(overlaySelectors.map((selector) => {
|
||||
const value = getComputedStyle(document.querySelector(selector)).backgroundColor;
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = 1;
|
||||
canvas.height = 1;
|
||||
const context = canvas.getContext("2d");
|
||||
context.clearRect(0, 0, 1, 1);
|
||||
context.fillStyle = value;
|
||||
context.fillRect(0, 0, 1, 1);
|
||||
return [selector, context.getImageData(0, 0, 1, 1).data[3] / 255];
|
||||
}));
|
||||
}, { overlaySelectors, paneOpacity, scene });
|
||||
for (const [selector, alpha] of Object.entries(alphas)) {
|
||||
assert(
|
||||
Math.abs(alpha - expectedAlpha) <= 1 / 255,
|
||||
`${scene} ${selector} follows paneOpacity ${paneOpacity} (${alpha})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
process.stdout.write("\nTheme surface browser contract passed.\n");
|
||||
} finally {
|
||||
await browser?.close();
|
||||
}
|
||||
@@ -16,13 +16,15 @@ const maxFrameGapMs = Number(process.env.REASONIX_TRANSCRIPT_MAX_FRAME_GAP_MS ??
|
||||
const p95FrameGapMs = Number(process.env.REASONIX_TRANSCRIPT_P95_FRAME_GAP_MS ?? 80);
|
||||
const maxLongTaskMs = Number(process.env.REASONIX_TRANSCRIPT_MAX_LONG_TASK_MS ?? 250);
|
||||
const totalLongTaskMs = Number(process.env.REASONIX_TRANSCRIPT_TOTAL_LONG_TASK_MS ?? 750);
|
||||
const nativeThumbReplay = process.platform === "linux"
|
||||
&& process.env.REASONIX_TRANSCRIPT_NATIVE_THUMB === "1";
|
||||
|
||||
function assert(condition, message) {
|
||||
if (!condition) throw new Error(message);
|
||||
process.stdout.write(` PASS ${message}\n`);
|
||||
}
|
||||
|
||||
async function moveToOuterReaderGutter(page, transcript) {
|
||||
async function moveToOuterReaderGutter(page, transcript, announce = true) {
|
||||
const deadline = Date.now() + 5_000;
|
||||
let point = null;
|
||||
while (!point && Date.now() < deadline) {
|
||||
@@ -46,7 +48,8 @@ async function moveToOuterReaderGutter(page, transcript) {
|
||||
});
|
||||
if (!point) await page.waitForTimeout(16);
|
||||
}
|
||||
assert(point != null, "outer reader wheel target resolves to visible row padding");
|
||||
if (announce) assert(point != null, "outer reader wheel target resolves to visible row padding");
|
||||
else if (point == null) throw new Error("outer reader wheel target is unavailable during traversal");
|
||||
await page.mouse.move(point.x, point.y);
|
||||
}
|
||||
|
||||
@@ -58,6 +61,7 @@ async function waitForStableTranscriptGeometry(
|
||||
const startedAt = performance.now();
|
||||
let previous = null;
|
||||
let stableFrames = 0;
|
||||
let lastSample = null;
|
||||
const sample = () => {
|
||||
const element = document.querySelector(".transcript");
|
||||
if (element instanceof HTMLElement) {
|
||||
@@ -67,6 +71,7 @@ async function waitForStableTranscriptGeometry(
|
||||
top: element.scrollTop,
|
||||
clientHeight: element.clientHeight,
|
||||
};
|
||||
lastSample = { ...current, distance: current.height - current.top - current.clientHeight, stableFrames };
|
||||
const unchanged = previous != null
|
||||
&& current.mode === previous.mode
|
||||
&& Math.abs(current.height - previous.height) <= 0.5
|
||||
@@ -85,7 +90,7 @@ async function waitForStableTranscriptGeometry(
|
||||
stableFrames = 0;
|
||||
}
|
||||
if (performance.now() - startedAt >= timeout) {
|
||||
reject(new Error(`transcript geometry did not stay stable for ${frames} frames`));
|
||||
reject(new Error(`transcript geometry did not stay stable for ${frames} frames: ${JSON.stringify(lastSample)}`));
|
||||
return;
|
||||
}
|
||||
requestAnimationFrame(sample);
|
||||
@@ -94,6 +99,170 @@ async function waitForStableTranscriptGeometry(
|
||||
}), { timeout, frames, requireTail });
|
||||
}
|
||||
|
||||
async function openGeometryContractFixture(page) {
|
||||
await page.click('.project-tree__topic-main:has-text("bench:geometry-229")');
|
||||
await page.waitForFunction(
|
||||
() => document.querySelector(".transcript")?.textContent?.includes("Geometry contract fixture complete."),
|
||||
undefined,
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
await waitForStableTranscriptGeometry(page, { timeout: 30_000, requireTail: true });
|
||||
return page.locator(".transcript");
|
||||
}
|
||||
|
||||
async function runGeometryContractTraversal(page, label) {
|
||||
const transcript = page.locator(".transcript");
|
||||
const fixtureShape = await transcript.evaluate((element) => ({
|
||||
totalRows: Number.parseInt(element.dataset.transcriptRowCount ?? "0", 10),
|
||||
clientHeight: element.clientHeight,
|
||||
}));
|
||||
assert(
|
||||
fixtureShape.totalRows >= 220 && fixtureShape.totalRows <= 240,
|
||||
`${label}: sanitized fixture stays near 229 rows (${fixtureShape.totalRows})`,
|
||||
);
|
||||
await page.evaluate(() => {
|
||||
window.__REASONIX_TRANSCRIPT_SCROLL_WRITE__ = (write) => {
|
||||
window.__geometryContractProbe?.writes.push(write);
|
||||
};
|
||||
});
|
||||
await transcript.evaluate(() => {
|
||||
const compact = {};
|
||||
window.__geometryContractProbe = { active: true, frames: [], compact, allRows: {}, writes: [] };
|
||||
window.__REASONIX_TRANSCRIPT_SCROLL_WRITE__ = (write) => window.__geometryContractProbe?.writes.push(write);
|
||||
const sample = () => {
|
||||
const probe = window.__geometryContractProbe;
|
||||
const element = document.querySelector(".transcript");
|
||||
if (!probe?.active || !(element instanceof HTMLElement)) return;
|
||||
const viewport = element.getBoundingClientRect();
|
||||
const mounted = [...element.querySelectorAll(".transcript__row")];
|
||||
const visible = mounted
|
||||
.filter((row) => {
|
||||
const rect = row.getBoundingClientRect();
|
||||
return rect.bottom > viewport.top && rect.top < viewport.bottom;
|
||||
})
|
||||
.sort((left, right) => left.getBoundingClientRect().top - right.getBoundingClientRect().top);
|
||||
const first = visible[0];
|
||||
const firstVisibleIndex = first instanceof HTMLElement
|
||||
? Number.parseInt(first.dataset.logicalIndex ?? first.dataset.index ?? "", 10)
|
||||
: Number.NaN;
|
||||
probe.frames.push({
|
||||
top: element.scrollTop,
|
||||
height: element.scrollHeight,
|
||||
estimatedTotal: element.dataset.transcriptEstimatedTotal,
|
||||
firstVisibleIndex: Number.isFinite(firstVisibleIndex) ? firstVisibleIndex : null,
|
||||
firstVisibleTop: first instanceof HTMLElement ? first.getBoundingClientRect().top - viewport.top : null,
|
||||
occupied: visible.length > 0,
|
||||
visibleRows: visible.slice(0, 8).map((row) => ({
|
||||
index: row.dataset.logicalIndex ?? row.dataset.index,
|
||||
key: row.dataset.rowKey,
|
||||
kind: row.dataset.rowKind,
|
||||
variant: row.dataset.transcriptLayoutVariant,
|
||||
height: row.getBoundingClientRect().height,
|
||||
top: row.getBoundingClientRect().top - viewport.top,
|
||||
})),
|
||||
});
|
||||
for (const row of mounted) {
|
||||
if (!(row instanceof HTMLElement)) continue;
|
||||
const variant = row.dataset.transcriptLayoutVariant ?? "";
|
||||
const index = Number.parseInt(row.dataset.logicalIndex ?? row.dataset.index ?? "", 10);
|
||||
const estimated = Number.parseFloat(row.dataset.estimatedSize ?? row.dataset.staticEstimate ?? "");
|
||||
const measured = Number.parseFloat(row.dataset.knownSize ?? "") || row.getBoundingClientRect().height;
|
||||
if (!Number.isFinite(index) || !Number.isFinite(estimated) || !Number.isFinite(measured)) continue;
|
||||
const key = `${index}:${variant}`;
|
||||
const error = Math.abs(measured - estimated);
|
||||
const delta = measured - estimated;
|
||||
const rowSample = {
|
||||
index,
|
||||
key: row.dataset.rowKey,
|
||||
preview: row.textContent?.slice(0, 48),
|
||||
kind: row.dataset.rowKind,
|
||||
variant,
|
||||
estimated,
|
||||
measured,
|
||||
error,
|
||||
delta,
|
||||
};
|
||||
const previousAll = probe.allRows[key];
|
||||
if (!previousAll || error > previousAll.error) probe.allRows[key] = rowSample;
|
||||
if (variant === "static" || variant === "text-flow" || variant.endsWith("-expanded")) continue;
|
||||
const previous = compact[key];
|
||||
if (!previous || error > previous.error) compact[key] = rowSample;
|
||||
}
|
||||
requestAnimationFrame(sample);
|
||||
};
|
||||
requestAnimationFrame(sample);
|
||||
});
|
||||
await moveToOuterReaderGutter(page, transcript);
|
||||
let reachedTop = false;
|
||||
for (let attempt = 0; attempt < 240 && !reachedTop; attempt += 1) {
|
||||
// Virtuoso recycles the row under a fixed pointer coordinate. Re-resolve
|
||||
// the outer reader target so every synthetic wheel is delivered through
|
||||
// the same capture path as a real continuous gesture.
|
||||
await moveToOuterReaderGutter(page, transcript, false);
|
||||
await page.mouse.wheel(0, -80);
|
||||
await page.waitForTimeout(60);
|
||||
reachedTop = await transcript.evaluate((element) => element.scrollTop <= 1);
|
||||
}
|
||||
assert(reachedTop, `${label}: first upward traversal reaches the physical top`);
|
||||
await page.evaluate(() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))));
|
||||
const probe = await transcript.evaluate(() => {
|
||||
const current = window.__geometryContractProbe ?? { frames: [], compact: {}, writes: [] };
|
||||
current.active = false;
|
||||
window.__REASONIX_TRANSCRIPT_SCROLL_WRITE__ = undefined;
|
||||
window.__geometryContractProbe = undefined;
|
||||
return current;
|
||||
});
|
||||
const occupied = probe.frames.every((frame) => frame.occupied);
|
||||
let maxReversePx = 0;
|
||||
let maxReverseRows = 0;
|
||||
let worstReversePair = null;
|
||||
let reverseRun = 0;
|
||||
let maxReverseRun = 0;
|
||||
let maxHeightDrop = 0;
|
||||
for (let index = 1; index < probe.frames.length; index += 1) {
|
||||
const previous = probe.frames[index - 1];
|
||||
const current = probe.frames[index];
|
||||
maxReversePx = Math.max(maxReversePx, current.top - previous.top);
|
||||
maxHeightDrop = Math.max(maxHeightDrop, previous.height - current.height);
|
||||
if (previous.firstVisibleIndex != null && current.firstVisibleIndex != null) {
|
||||
const reverseRows = current.firstVisibleIndex - previous.firstVisibleIndex;
|
||||
if (reverseRows > maxReverseRows) {
|
||||
maxReverseRows = reverseRows;
|
||||
worstReversePair = { previous, current };
|
||||
}
|
||||
const visualReverse = current.firstVisibleIndex > previous.firstVisibleIndex
|
||||
|| (current.firstVisibleIndex === previous.firstVisibleIndex
|
||||
&& previous.firstVisibleTop != null
|
||||
&& current.firstVisibleTop != null
|
||||
&& current.firstVisibleTop < previous.firstVisibleTop - 2);
|
||||
reverseRun = visualReverse ? reverseRun + 1 : 0;
|
||||
maxReverseRun = Math.max(maxReverseRun, reverseRun);
|
||||
}
|
||||
}
|
||||
const compactRows = Object.values(probe.compact);
|
||||
const reasoningRows = compactRows.filter((row) => row.variant === "reasoning-summary");
|
||||
const reasoningError = Math.max(0, ...reasoningRows.map((row) => row.error));
|
||||
const otherCompactError = Math.max(0, ...compactRows.filter((row) => row.variant !== "reasoning-summary").map((row) => row.error));
|
||||
const forbiddenWrites = probe.writes.filter((write) => write.owner === "recovery" || write.owner === "anchor-compensation");
|
||||
const largestOverestimates = Object.values(probe.allRows)
|
||||
.filter((row) => row.delta < 0)
|
||||
.sort((left, right) => left.delta - right.delta)
|
||||
.slice(0, 8);
|
||||
const largestUnderestimates = Object.values(probe.allRows)
|
||||
.filter((row) => row.delta > 0)
|
||||
.sort((left, right) => right.delta - left.delta)
|
||||
.slice(0, 8);
|
||||
assert(occupied, `${label}: every sampled frame keeps a mounted row in the viewport`);
|
||||
assert(maxReverseRows <= 1, `${label}: first visible row never flashes downward by more than one row (${maxReverseRows}; ${JSON.stringify(worstReversePair)}; reasoning error ${reasoningError.toFixed(2)}; compact error ${otherCompactError.toFixed(2)}; height drop ${maxHeightDrop.toFixed(2)}; over ${JSON.stringify(largestOverestimates)}; under ${JSON.stringify(largestUnderestimates)}; writes ${JSON.stringify(probe.writes.slice(-8))})`);
|
||||
assert(maxReverseRun <= 1, `${label}: no visible reverse displacement persists beyond one frame (${maxReverseRun}; physical anchor adjustment ${maxReversePx.toFixed(2)}px)`);
|
||||
assert(maxHeightDrop < fixtureShape.clientHeight / 2, `${label}: list height never contracts by half a viewport (${maxHeightDrop.toFixed(2)}px)`);
|
||||
assert(reasoningRows.length === 31, `${label}: traversal measures all 31 folded reasoning rows (${reasoningRows.length})`);
|
||||
assert(reasoningError <= 2, `${label}: folded reasoning estimate error stays within 2px (${reasoningError.toFixed(2)}px)`);
|
||||
assert(otherCompactError <= 8, `${label}: other compact estimate errors stay within 8px (${otherCompactError.toFixed(2)}px)`);
|
||||
assert(forbiddenWrites.length === 0, `${label}: normal traversal emits zero recovery/anchor-compensation writes (${forbiddenWrites.length})`);
|
||||
return { reasoningError, otherCompactError, totalRows: fixtureShape.totalRows };
|
||||
}
|
||||
|
||||
async function waitForServer() {
|
||||
const deadline = Date.now() + 30_000;
|
||||
while (Date.now() < deadline) {
|
||||
@@ -110,16 +279,22 @@ async function waitForServer() {
|
||||
throw new Error("transcript scroll preview did not become ready");
|
||||
}
|
||||
|
||||
const preview = spawn("pnpm", ["exec", "vite", "preview", "--port", String(port), "--strictPort", "--host", "127.0.0.1"], {
|
||||
const packageManager = process.platform === "win32" ? "pnpm.cmd" : "pnpm";
|
||||
const preview = spawn(packageManager, ["exec", "vite", "preview", "--port", String(port), "--strictPort", "--host", "127.0.0.1"], {
|
||||
cwd: frontendDir,
|
||||
stdio: "ignore",
|
||||
shell: process.platform === "win32",
|
||||
});
|
||||
|
||||
let browser;
|
||||
try {
|
||||
await waitForServer();
|
||||
browser = await chromium.launch({
|
||||
headless: true,
|
||||
// Chromium's headless compositor reserves the native scrollbar gutter on
|
||||
// Linux but does not expose its thumb to pointer input. CI runs this replay
|
||||
// in Xvfb so the real browser-owned thumb remains draggable; other hosts
|
||||
// keep the deterministic headless path and their platform-specific gates.
|
||||
headless: !nativeThumbReplay,
|
||||
...(process.env.PLAYWRIGHT_EXECUTABLE_PATH ? { executablePath: process.env.PLAYWRIGHT_EXECUTABLE_PATH } : {}),
|
||||
});
|
||||
const page = await browser.newPage({ viewport: { width: 1280, height: 800 } });
|
||||
@@ -145,7 +320,7 @@ try {
|
||||
max: element.scrollHeight - element.clientHeight,
|
||||
}));
|
||||
assert(hydrationStart.max > 0, `async hydration fixture is scrollable (${hydrationStart.max}px)`);
|
||||
await page.mouse.move(hydrationBox.x + hydrationBox.width / 2, hydrationBox.y + hydrationBox.height / 2);
|
||||
await moveToOuterReaderGutter(page, hydrationTranscript);
|
||||
await page.mouse.wheel(0, hydrationStart.top < hydrationStart.max / 2 ? 360 : -360);
|
||||
await page.waitForFunction(
|
||||
(startTop) => {
|
||||
@@ -209,12 +384,24 @@ try {
|
||||
requestAnimationFrame(settle);
|
||||
}));
|
||||
await moveToOuterReaderGutter(page, hydrationTranscript);
|
||||
for (let attempt = 0; attempt < 32; attempt += 1) {
|
||||
// Full Markdown hydration can legitimately expand this synthetic document
|
||||
// to ~30k CSS px. Keep driving the same outer-reader gesture until the
|
||||
// physical tail is reached instead of assuming the old underestimated tree.
|
||||
let previousDownwardTop = -1;
|
||||
for (let attempt = 0; attempt < 128; attempt += 1) {
|
||||
await moveToOuterReaderGutter(page, hydrationTranscript, false);
|
||||
await page.mouse.wheel(0, 10_000);
|
||||
const atBottom = await hydrationTranscript.evaluate((element) =>
|
||||
element.scrollHeight - element.scrollTop - element.clientHeight <= 1);
|
||||
if (atBottom) break;
|
||||
await page.waitForTimeout(16);
|
||||
const position = await hydrationTranscript.evaluate((element) => ({
|
||||
top: element.scrollTop,
|
||||
atBottom: element.scrollHeight - element.scrollTop - element.clientHeight <= 1,
|
||||
}));
|
||||
if (position.atBottom) break;
|
||||
// A hydration resize intentionally guards the reader's previous extent
|
||||
// for one intent burst. Start a fresh burst after the 180ms idle lease
|
||||
// when that boundary is reached, matching a real user's next wheel turn.
|
||||
const stalled = Math.abs(position.top - previousDownwardTop) <= 1;
|
||||
previousDownwardTop = position.top;
|
||||
await page.waitForTimeout(stalled ? 220 : 16);
|
||||
}
|
||||
await page.waitForFunction(() => {
|
||||
const element = document.querySelector(".transcript");
|
||||
@@ -242,16 +429,26 @@ try {
|
||||
undefined,
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
await page.waitForFunction(() => {
|
||||
const element = document.querySelector(".transcript");
|
||||
return element instanceof HTMLElement
|
||||
&& element.dataset.scrollMode === "tail-follow"
|
||||
&& element.scrollHeight - element.scrollTop - element.clientHeight <= 1;
|
||||
});
|
||||
// Tail ownership uses the shared 4px native-bottom threshold because Linux
|
||||
// and WebView2 can quantize a fractional layout to different integer scroll
|
||||
// extents. Require eight stable frames inside that product threshold instead
|
||||
// of accepting one transient exact-bottom sample.
|
||||
await waitForStableTranscriptGeometry(page, { timeout: 30_000, requireTail: true });
|
||||
assert(true, "rapid A→B→A switching leaves the reported long-turn session at its physical bottom");
|
||||
await openGeometryContractFixture(page);
|
||||
await runGeometryContractTraversal(page, "DPR 1 first visit");
|
||||
await page.click('.project-tree__topic-main:has-text("bench:small-6t")');
|
||||
await page.waitForFunction(
|
||||
() => document.querySelector(".project-tree__topic--active .project-tree__topic-label")?.textContent?.includes("bench:small-6t"),
|
||||
undefined,
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
await openGeometryContractFixture(page);
|
||||
await runGeometryContractTraversal(page, "DPR 1 A→B→A revisit");
|
||||
await page.click('.project-tree__topic-main:has-text("bench:tools-38t")');
|
||||
await page.waitForFunction(() => document.querySelector(".project-tree__topic--active .project-tree__topic-label")?.textContent?.includes("bench:tools-38t"));
|
||||
await page.waitForFunction(() => document.querySelector(".transcript")?.textContent?.includes("pkg-41/mod.go"), undefined, { timeout: 30_000 });
|
||||
await page.waitForFunction(() => !document.querySelector(".transcript-navigation-overlay"), undefined, { timeout: 30_000 });
|
||||
const markdownVisibility = await page.evaluate(() => {
|
||||
const row = document.querySelector(".transcript__row");
|
||||
if (!(row instanceof HTMLElement)) return { inside: null, outside: null };
|
||||
@@ -294,7 +491,10 @@ try {
|
||||
const rail = document.querySelector(".jump-scroll");
|
||||
if (!(rail instanceof HTMLElement)) return null;
|
||||
const railRect = rail.getBoundingClientRect();
|
||||
const marker = [...rail.querySelectorAll(".jump-item")].find((item) => {
|
||||
// This assertion covers clearing a stale selection before an immediate,
|
||||
// already-mounted indexed jump. Unloaded markers intentionally use the
|
||||
// masked history transaction and are covered by the 434→847→994 replay.
|
||||
const marker = [...rail.querySelectorAll('.jump-item[data-loaded="true"]')].find((item) => {
|
||||
const rect = item.getBoundingClientRect();
|
||||
const middle = rect.top + rect.height / 2;
|
||||
return middle >= railRect.top && middle <= railRect.bottom;
|
||||
@@ -303,7 +503,7 @@ try {
|
||||
const rect = marker.getBoundingClientRect();
|
||||
return { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 };
|
||||
});
|
||||
assert(questionTargetPoint != null, "question navigator exposes an earlier visible marker");
|
||||
assert(questionTargetPoint != null, "question navigator exposes an earlier loaded marker");
|
||||
const staleSelectionMode = await page.evaluate(() => {
|
||||
const target = document.querySelector("[data-transcript-selectable]");
|
||||
const transcript = document.querySelector(".transcript");
|
||||
@@ -711,7 +911,31 @@ try {
|
||||
element.dispatchEvent(new Event("scroll"));
|
||||
});
|
||||
await page.waitForFunction(() => document.querySelector(".transcript__row"));
|
||||
const nativeThumbProbe = await transcript.evaluate((element) => {
|
||||
let nativeThumbProbe = await transcript.evaluate(async (element) => {
|
||||
// The scroll-to-top event can leave a recycled Virtuoso node mounted or a
|
||||
// delayed layout owner can move the viewport again for a few frames.
|
||||
// Starting the drag in either state can hit the scrollbar track instead of
|
||||
// the top-positioned thumb, recycling the marked node before the geometry
|
||||
// freeze begins. Require both the physical top and the logical row identity
|
||||
// to settle before marking the probe.
|
||||
let stableFrames = 0;
|
||||
let previousIdentity = "";
|
||||
for (let frame = 0; frame < 120 && stableFrames < 4; frame += 1) {
|
||||
await new Promise((resolve) => requestAnimationFrame(resolve));
|
||||
if (element.scrollTop > 1) {
|
||||
element.scrollTop = 0;
|
||||
element.dispatchEvent(new Event("scroll"));
|
||||
stableFrames = 0;
|
||||
previousIdentity = "";
|
||||
continue;
|
||||
}
|
||||
const candidate = element.querySelector(".transcript__row");
|
||||
const identity = candidate instanceof HTMLElement
|
||||
? `${candidate.dataset.rowKey ?? ""}:${candidate.dataset.knownSize ?? ""}`
|
||||
: "";
|
||||
stableFrames = identity && identity === previousIdentity ? stableFrames + 1 : 0;
|
||||
previousIdentity = identity;
|
||||
}
|
||||
const rect = element.getBoundingClientRect();
|
||||
const scaleX = rect.width / element.offsetWidth;
|
||||
const contentRight = rect.left + (element.clientLeft + element.clientWidth) * scaleX;
|
||||
@@ -721,27 +945,179 @@ try {
|
||||
return {
|
||||
x: Math.min(rect.right - 1, contentRight + Math.max(1, (rect.right - contentRight) / 2)),
|
||||
y: rect.top + 5,
|
||||
// Browser themes clamp the held thumb only after the pointer crosses
|
||||
// the track end, so the target deliberately overshoots the visible
|
||||
// gutter where a native thumb is available.
|
||||
bottomY: Math.min(window.innerHeight - 1, rect.bottom + Math.max(24, rect.height * 0.1)),
|
||||
scrollTop: element.scrollTop,
|
||||
rowKey: row.dataset.rowKey ?? "",
|
||||
knownSize: Number.parseFloat(row.dataset.knownSize || "0"),
|
||||
gutter: rect.right - contentRight,
|
||||
scrollHeight: element.scrollHeight,
|
||||
};
|
||||
});
|
||||
if (process.platform === "darwin" && (!nativeThumbProbe || nativeThumbProbe.gutter <= 1)) {
|
||||
// macOS Chromium inherits system overlay scrollbars, so there is no
|
||||
// pointer-addressable native gutter. Linux/Windows CI still exercises the
|
||||
// complete thumb ownership and measurement-freeze path below.
|
||||
process.stdout.write(" SKIP native thumb drag (host uses overlay scrollbars)\n");
|
||||
const nativeThumbDragSupported = process.platform !== "win32" || process.env.REASONIX_TRANSCRIPT_NATIVE_THUMB === "1";
|
||||
if (!nativeThumbDragSupported) {
|
||||
// Playwright's headless Chromium on Windows exposes the reserved gutter
|
||||
// width but does not expose a pointer-draggable native thumb. The actual
|
||||
// WebView2 path is covered by the Windows native smoke job; the geometry
|
||||
// lock itself is covered by transcript-native-scrollbar.test.ts.
|
||||
process.stdout.write(" SKIP native thumb drag (headless Windows Chromium has no draggable native track)\n");
|
||||
} else if (process.platform === "darwin") {
|
||||
// Headless macOS Chromium does not expose a deterministic pointer-draggable
|
||||
// thumb: hosts with overlay scrollbars have no gutter, while hosts with a
|
||||
// reserved gutter accept pointer-down but do not reliably move the thumb.
|
||||
// Linux CI exercises the full drag/freeze path; Windows WebView2 coverage
|
||||
// is provided by the native smoke job.
|
||||
process.stdout.write(" SKIP native thumb drag (headless macOS Chromium has no deterministic native track)\n");
|
||||
} else {
|
||||
assert(nativeThumbProbe && nativeThumbProbe.gutter > 1, `workbench exposes a native scrollbar gutter (${nativeThumbProbe?.gutter ?? 0}px)`);
|
||||
const trackTop = nativeThumbProbe.y - 5;
|
||||
const coarseThumbCandidateOffsets = [4, 8, 12, 16, 20, 24, 28, 32];
|
||||
// Native scrollbar themes may expose a very narrow draggable hitbox
|
||||
// between a top button and the track. Keep the fast coarse probes first,
|
||||
// then inspect every physical pixel near the track start so a theme
|
||||
// boundary cannot be misclassified as host input being unavailable.
|
||||
const thumbCandidateOffsets = [
|
||||
...coarseThumbCandidateOffsets,
|
||||
...Array.from({ length: 64 }, (_, index) => index + 1)
|
||||
.filter((offset) => !coarseThumbCandidateOffsets.includes(offset)),
|
||||
];
|
||||
const thumbCandidateMotions = [];
|
||||
let nativeThumbY = null;
|
||||
let nativeThumbDragY = null;
|
||||
for (const offset of thumbCandidateOffsets) {
|
||||
await transcript.evaluate((element) => {
|
||||
element.scrollTop = 0;
|
||||
element.dispatchEvent(new Event("scroll"));
|
||||
});
|
||||
await page.waitForFunction(() => (document.querySelector(".transcript")?.scrollTop ?? Number.POSITIVE_INFINITY) <= 1);
|
||||
const candidateY = trackTop + offset;
|
||||
await page.mouse.move(nativeThumbProbe.x, candidateY);
|
||||
await page.mouse.down();
|
||||
await page.waitForFunction(() => document.querySelector(".transcript")?.dataset.nativeScrollbarDrag === "true");
|
||||
await page.mouse.move(nativeThumbProbe.x, candidateY + 24);
|
||||
await page.waitForTimeout(24);
|
||||
const midpoint = await transcript.evaluate((element) => ({
|
||||
scrollTop: element.scrollTop,
|
||||
clientHeight: element.clientHeight,
|
||||
}));
|
||||
await page.mouse.move(nativeThumbProbe.x, candidateY + 48);
|
||||
await page.waitForTimeout(24);
|
||||
const motion = await transcript.evaluate((element) => ({
|
||||
scrollTop: element.scrollTop,
|
||||
clientHeight: element.clientHeight,
|
||||
}));
|
||||
await page.mouse.move(nativeThumbProbe.x, candidateY + 96);
|
||||
await page.waitForTimeout(24);
|
||||
const confirmation = await transcript.evaluate((element) => ({
|
||||
scrollTop: element.scrollTop,
|
||||
clientHeight: element.clientHeight,
|
||||
}));
|
||||
thumbCandidateMotions.push({
|
||||
offset,
|
||||
midpoint: midpoint.scrollTop,
|
||||
scrollTop: motion.scrollTop,
|
||||
confirmation: confirmation.scrollTop,
|
||||
});
|
||||
// A real thumb follows every pointer increment. A held track/button can
|
||||
// auto-repeat across several pages and can satisfy the first two distance
|
||||
// checks before stopping. Require a third, farther move to prove that the
|
||||
// browser-owned control keeps following the pointer transaction.
|
||||
if (motion.scrollTop > motion.clientHeight * 2.5
|
||||
&& motion.scrollTop - midpoint.scrollTop > motion.clientHeight
|
||||
&& confirmation.scrollTop - motion.scrollTop > confirmation.clientHeight * 2) {
|
||||
nativeThumbY = candidateY;
|
||||
nativeThumbDragY = candidateY + 96;
|
||||
break;
|
||||
}
|
||||
await page.mouse.up();
|
||||
await page.waitForFunction(() => document.querySelector(".transcript")?.dataset.nativeScrollbarDrag !== "true");
|
||||
}
|
||||
if (nativeThumbY === null) {
|
||||
process.stdout.write(` SKIP native thumb drag (headed Linux Chromium host-input-unavailable; ${JSON.stringify(thumbCandidateMotions)})\n`);
|
||||
} else {
|
||||
// Continue the same pointer transaction that proved native input was
|
||||
// delivered. Releasing, resetting, and reusing the old Y coordinate does
|
||||
// not prove the browser theme exposed the thumb at that coordinate again.
|
||||
// Finish the browser-owned drag before asynchronous DOM probes: Chromium
|
||||
// can stop delivering native-track movement after a held transaction is
|
||||
// interrupted by fixture mutations even though pointer ownership remains.
|
||||
const nativeTailSamples = [];
|
||||
const nativeDragStepCount = 24;
|
||||
for (let step = 1; step <= nativeDragStepCount; step += 1) {
|
||||
const y = nativeThumbDragY + ((nativeThumbProbe.bottomY - nativeThumbDragY) * step) / nativeDragStepCount;
|
||||
await page.mouse.move(nativeThumbProbe.x, y);
|
||||
// Give Chromium one frame to apply each browser-owned thumb movement;
|
||||
// coalescing one large synthetic move can skip the native track's
|
||||
// intermediate geometry while Virtuoso swaps its mounted range.
|
||||
await page.waitForTimeout(24);
|
||||
const sample = await transcript.evaluate((element) => ({
|
||||
scrollTop: element.scrollTop,
|
||||
scrollHeight: element.scrollHeight,
|
||||
clientHeight: element.clientHeight,
|
||||
bottomDistance: element.scrollHeight - element.scrollTop - element.clientHeight,
|
||||
mode: element.dataset.scrollMode,
|
||||
}));
|
||||
nativeTailSamples.push({ y: Math.round(y), ...sample });
|
||||
if (sample.bottomDistance <= 4) break;
|
||||
}
|
||||
const nativeTailTerminal = nativeTailSamples.at(-1);
|
||||
assert(nativeTailSamples.every((sample) => sample.mode === "native-thumb"), `native thumb retains ownership through the delivered drag (${JSON.stringify(nativeTailSamples)})`);
|
||||
assert(nativeTailTerminal?.bottomDistance <= 4, `native thumb drag reaches the physical bottom (${JSON.stringify(nativeTailSamples)})`);
|
||||
nativeThumbProbe = await transcript.evaluate((element, input) => {
|
||||
element.querySelectorAll('[data-native-scrollbar-probe="true"]').forEach((node) => {
|
||||
if (node instanceof HTMLElement) delete node.dataset.nativeScrollbarProbe;
|
||||
});
|
||||
const viewport = element.getBoundingClientRect();
|
||||
const row = [...element.querySelectorAll(".transcript__row")]
|
||||
.find((candidate) => {
|
||||
const rect = candidate.getBoundingClientRect();
|
||||
return rect.bottom > viewport.top && rect.top < viewport.bottom;
|
||||
});
|
||||
if (!(row instanceof HTMLElement)) return null;
|
||||
row.dataset.nativeScrollbarProbe = "true";
|
||||
return {
|
||||
...input.probe,
|
||||
y: input.y,
|
||||
scrollTop: element.scrollTop,
|
||||
clientHeight: element.clientHeight,
|
||||
rowKey: row.dataset.rowKey ?? "",
|
||||
knownSize: Number.parseFloat(row.dataset.knownSize || "0"),
|
||||
scrollHeight: element.scrollHeight,
|
||||
};
|
||||
}, { probe: nativeThumbProbe, y: nativeThumbY });
|
||||
assert(nativeThumbProbe, "native scrollbar probe remains available after draggable-thumb discovery");
|
||||
assert(true, `native scrollbar exposes a pointer-draggable thumb (${Math.round(nativeThumbY - trackTop)}px from track start)`);
|
||||
assert(nativeThumbProbe.scrollTop > nativeThumbProbe.clientHeight * 2.5, `native scrollbar discovery delivers forward physical progress (${nativeThumbProbe.scrollTop}px)`);
|
||||
assert(nativeThumbProbe.knownSize > 0, `native scrollbar probe starts from a measured row (${nativeThumbProbe.knownSize}px)`);
|
||||
await page.mouse.move(nativeThumbProbe.x, nativeThumbProbe.y);
|
||||
await page.mouse.down();
|
||||
await page.waitForFunction(() => document.querySelector(".transcript")?.dataset.nativeScrollbarDrag === "true");
|
||||
await page.waitForFunction(
|
||||
(knownSize) => document.querySelector('[data-native-scrollbar-probe="true"]')?.style.height === `${knownSize}px`,
|
||||
nativeThumbProbe.knownSize,
|
||||
);
|
||||
const nativeDragStart = await transcript.evaluate((element) => {
|
||||
const row = element.querySelector('[data-native-scrollbar-probe="true"]');
|
||||
return {
|
||||
scrollTop: element.scrollTop,
|
||||
rowKey: row instanceof HTMLElement ? row.dataset.rowKey ?? "" : "",
|
||||
};
|
||||
});
|
||||
assert(Math.abs(nativeDragStart.scrollTop - nativeThumbProbe.scrollTop) <= 1, `native thumb transaction keeps its delivered position (${nativeThumbProbe.scrollTop} → ${nativeDragStart.scrollTop}px)`);
|
||||
assert(nativeDragStart.rowKey === nativeThumbProbe.rowKey, `native thumb press keeps the probed logical row (${nativeDragStart.rowKey || "missing"})`);
|
||||
await page.waitForFunction(({ knownSize, rowKey }) => {
|
||||
const row = document.querySelector('[data-native-scrollbar-probe="true"]');
|
||||
return row instanceof HTMLElement
|
||||
&& row.dataset.rowKey === rowKey
|
||||
&& row.style.height === `${knownSize}px`;
|
||||
}, { knownSize: nativeThumbProbe.knownSize, rowKey: nativeThumbProbe.rowKey });
|
||||
const nativeDragBaseline = await transcript.evaluate((element) => {
|
||||
const row = element.querySelector('[data-native-scrollbar-probe="true"]');
|
||||
return {
|
||||
knownSize: row instanceof HTMLElement ? Number.parseFloat(row.dataset.knownSize || "0") : 0,
|
||||
fixedHeight: row instanceof HTMLElement ? row.style.height : "",
|
||||
rowHeight: row instanceof HTMLElement ? row.getBoundingClientRect().height : 0,
|
||||
listHeight: element.querySelector('[data-testid="virtuoso-item-list"]')?.getBoundingClientRect().height ?? 0,
|
||||
scrollHeight: element.scrollHeight,
|
||||
};
|
||||
});
|
||||
assert(nativeDragBaseline?.knownSize > 0, `native thumb drag starts from a measured logical row (${nativeDragBaseline?.knownSize ?? 0}px)`);
|
||||
assert(nativeDragBaseline.fixedHeight === `${nativeDragBaseline.knownSize}px`, `native thumb drag fixes mounted row layout (${nativeDragBaseline.fixedHeight})`);
|
||||
await transcript.evaluate((element) => {
|
||||
const row = element.querySelector('[data-native-scrollbar-probe="true"]');
|
||||
const content = row?.firstElementChild;
|
||||
@@ -758,14 +1134,9 @@ try {
|
||||
scrollHeight: element.scrollHeight,
|
||||
};
|
||||
});
|
||||
assert(duringNativeThumbDrag.knownSize === nativeThumbProbe.knownSize, `native thumb drag freezes new row measurements (${duringNativeThumbDrag.knownSize}px)`);
|
||||
assert(duringNativeThumbDrag.fixedHeight === `${nativeThumbProbe.knownSize}px`, `native thumb drag fixes mounted row layout (${duringNativeThumbDrag.fixedHeight})`);
|
||||
assert(Math.abs(duringNativeThumbDrag.scrollHeight - nativeThumbProbe.scrollHeight) <= 8, `native thumb drag keeps the physical scroll range stable (${nativeThumbProbe.scrollHeight} → ${duringNativeThumbDrag.scrollHeight}; row ${duringNativeThumbDrag.rowHeight}; list ${duringNativeThumbDrag.listHeight})`);
|
||||
await page.mouse.move(nativeThumbProbe.x, nativeThumbProbe.bottomY, { steps: 8 });
|
||||
await page.waitForFunction(() => {
|
||||
const element = document.querySelector(".transcript");
|
||||
return element && element.scrollHeight - element.scrollTop - element.clientHeight <= 1;
|
||||
});
|
||||
assert(duringNativeThumbDrag.knownSize === nativeDragBaseline.knownSize, `native thumb drag freezes new row measurements (${duringNativeThumbDrag.knownSize}px)`);
|
||||
assert(duringNativeThumbDrag.fixedHeight === nativeDragBaseline.fixedHeight, `native thumb drag fixes mounted row layout (${duringNativeThumbDrag.fixedHeight})`);
|
||||
assert(Math.abs(duringNativeThumbDrag.scrollHeight - nativeDragBaseline.scrollHeight) <= 8, `native thumb drag keeps the physical scroll range stable (${nativeDragBaseline.scrollHeight} → ${duringNativeThumbDrag.scrollHeight}; row ${duringNativeThumbDrag.rowHeight}; list ${duringNativeThumbDrag.listHeight})`);
|
||||
await page.mouse.up();
|
||||
await page.waitForFunction(() => document.querySelector(".transcript")?.dataset.nativeScrollbarDrag !== "true");
|
||||
await transcript.evaluate((element) => {
|
||||
@@ -777,7 +1148,7 @@ try {
|
||||
const element = document.querySelector(".transcript");
|
||||
return element
|
||||
&& element.dataset.scrollMode === "tail-follow"
|
||||
&& element.scrollHeight - element.scrollTop - element.clientHeight <= 1;
|
||||
&& element.scrollHeight - element.scrollTop - element.clientHeight <= 4;
|
||||
});
|
||||
assert(true, "native thumb release keeps the remeasured transcript at the physical bottom");
|
||||
const idleTail = await transcript.evaluate((element) => new Promise((resolve) => {
|
||||
@@ -815,6 +1186,7 @@ try {
|
||||
assert(idleTail.geometryStable, "an idle expanded tail reaches stable geometry within the window");
|
||||
assert(idleTail.stableWrites.length === 0, `a stable idle tail emits no corrective scroll writes (${idleTail.stableWrites.length} of ${idleTail.writes.length}) ${JSON.stringify(idleTail.stableWrites)}`);
|
||||
assert(idleTailRange <= 1, `an idle expanded tail keeps stable native geometry (${idleTailRange}px)`);
|
||||
}
|
||||
}
|
||||
|
||||
// Explicit bottom owns the tail. Subsequent async growth must use Virtuoso's
|
||||
@@ -936,7 +1308,15 @@ try {
|
||||
element.dataset.scrollMode === "tail-follow"
|
||||
&& element.scrollHeight - element.scrollTop - element.clientHeight <= 1);
|
||||
}
|
||||
assert(reachedBottom, "repeated downward wheels reach the physical bottom through measurement churn (#8657)");
|
||||
const reachState = reachedBottom ? null : await transcript.evaluate((element) => ({
|
||||
mode: element.dataset.scrollMode,
|
||||
distance: element.scrollHeight - element.scrollTop - element.clientHeight,
|
||||
top: element.scrollTop,
|
||||
height: element.scrollHeight,
|
||||
clientHeight: element.clientHeight,
|
||||
writes: window.__reachBottomProbe.writes.slice(-8),
|
||||
}));
|
||||
assert(reachedBottom, `repeated downward wheels reach the physical bottom through measurement churn (#8657)${reachState ? `: ${JSON.stringify(reachState)}` : ""}`);
|
||||
await page.waitForFunction(() => document.querySelector(".transcript")?.dataset.scrollMode === "tail-follow", undefined, { timeout: 5_000 });
|
||||
const reachProbe = await transcript.evaluate(() => {
|
||||
window.__reachBottomProbe.done = true;
|
||||
|
||||
@@ -136,9 +136,136 @@ async function waitForServer() {
|
||||
throw new Error("transcript browser preview did not become ready");
|
||||
}
|
||||
|
||||
const preview = spawn("pnpm", ["exec", "vite", "preview", "--port", String(port), "--strictPort", "--host", "127.0.0.1"], {
|
||||
async function runSelectionTableRepaintGeometry(page) {
|
||||
await page.click('.project-tree__topic-main:has-text("bench:selection-table")');
|
||||
await page.waitForFunction(() => (
|
||||
document.querySelector(".project-tree__topic--active .project-tree__topic-label")?.textContent?.includes("bench:selection-table")
|
||||
&& [...document.querySelectorAll("strong")].some((element) => element.textContent?.includes("SELECTION REPAINT TARGET"))
|
||||
), undefined, { timeout: 30_000 });
|
||||
await page.waitForFunction(() => !document.querySelector(".transcript-navigation-overlay"), undefined, { timeout: 30_000 });
|
||||
|
||||
const target = page.locator("strong", { hasText: "SELECTION REPAINT TARGET" });
|
||||
await target.scrollIntoViewIfNeeded();
|
||||
await page.waitForTimeout(300);
|
||||
const targetBox = await target.boundingBox();
|
||||
assert(targetBox != null, "selection-table exposes the strong-text multi-click target");
|
||||
|
||||
const initial = await page.evaluate(() => {
|
||||
const targetElement = [...document.querySelectorAll("strong")].find((element) => element.textContent?.includes("SELECTION REPAINT TARGET"));
|
||||
const transcript = document.querySelector(".transcript");
|
||||
const table = targetElement?.closest("table");
|
||||
const row = targetElement?.closest("tr");
|
||||
const host = document.querySelector('.transcript-selection-action[data-surface="transcript"]');
|
||||
if (!targetElement || !transcript || !table || !row || !host) return null;
|
||||
const abort = new AbortController();
|
||||
const rect = (element) => {
|
||||
const value = element.getBoundingClientRect();
|
||||
return { top: value.top, left: value.left, right: value.right, bottom: value.bottom };
|
||||
};
|
||||
const capture = (event) => {
|
||||
const currentHost = document.querySelector('.transcript-selection-action[data-surface="transcript"]');
|
||||
const selection = document.getSelection();
|
||||
window.__selectionTableProbe.samples.push({
|
||||
event,
|
||||
scrollTop: transcript.scrollTop,
|
||||
scrollHeight: transcript.scrollHeight,
|
||||
clientHeight: transcript.clientHeight,
|
||||
table: rect(table),
|
||||
row: rect(row),
|
||||
target: rect(targetElement),
|
||||
hostCount: document.querySelectorAll('.transcript-selection-action[data-surface="transcript"]').length,
|
||||
hostStable: currentHost === host,
|
||||
hostState: currentHost?.getAttribute("data-state") ?? null,
|
||||
selectionRects: selection?.rangeCount
|
||||
? [...selection.getRangeAt(selection.rangeCount - 1).getClientRects()].map((value) => ({
|
||||
top: value.top,
|
||||
left: value.left,
|
||||
right: value.right,
|
||||
bottom: value.bottom,
|
||||
}))
|
||||
: [],
|
||||
});
|
||||
};
|
||||
window.__selectionTableProbe = { host, samples: [], capture, abort };
|
||||
document.addEventListener("pointerdown", () => capture("pointerdown"), { capture: true, signal: abort.signal });
|
||||
document.addEventListener("pointerup", () => capture("pointerup"), { capture: true, signal: abort.signal });
|
||||
document.addEventListener("selectionchange", () => capture("selectionchange"), { signal: abort.signal });
|
||||
capture("initial");
|
||||
return window.__selectionTableProbe.samples[0];
|
||||
});
|
||||
assert(initial != null, "selection-table geometry probe attaches to the settled transcript");
|
||||
assert(initial.scrollTop > 0, `selection-table target exercises a non-top transcript viewport (${initial.scrollTop})`);
|
||||
assert(initial.hostCount === 1 && initial.hostStable, "selection-table starts with one stable transcript action host");
|
||||
|
||||
const clickIntervals = [400, 320, 180, 0];
|
||||
const clickPoint = { x: targetBox.x + targetBox.width / 2, y: targetBox.y + targetBox.height / 2 };
|
||||
await page.mouse.move(clickPoint.x, clickPoint.y);
|
||||
for (let index = 0; index < clickIntervals.length; index += 1) {
|
||||
await page.mouse.down({ button: "left", clickCount: index + 1 });
|
||||
await page.waitForTimeout(30);
|
||||
await page.mouse.up({ button: "left", clickCount: index + 1 });
|
||||
await page.evaluate(async (click) => {
|
||||
await new Promise((resolve) => requestAnimationFrame(resolve));
|
||||
window.__selectionTableProbe.capture(`click-${click}-raf-1`);
|
||||
await new Promise((resolve) => requestAnimationFrame(resolve));
|
||||
window.__selectionTableProbe.capture(`click-${click}-raf-2`);
|
||||
}, index + 1);
|
||||
if (clickIntervals[index] > 0) await page.waitForTimeout(clickIntervals[index]);
|
||||
}
|
||||
|
||||
await page.keyboard.press("Escape");
|
||||
await page.evaluate(async () => {
|
||||
document.getSelection()?.removeAllRanges();
|
||||
await new Promise((resolve) => requestAnimationFrame(resolve));
|
||||
await new Promise((resolve) => requestAnimationFrame(resolve));
|
||||
window.__selectionTableProbe.capture("settled");
|
||||
});
|
||||
const result = await page.evaluate(() => {
|
||||
const probe = window.__selectionTableProbe;
|
||||
probe.abort.abort();
|
||||
const samples = probe.samples;
|
||||
const baseline = samples[0];
|
||||
const maxDelta = (field) => Math.max(...samples.map((sample) => Math.max(
|
||||
Math.abs(sample[field].top - baseline[field].top),
|
||||
Math.abs(sample[field].left - baseline[field].left),
|
||||
)));
|
||||
const settled = samples.at(-1);
|
||||
delete window.__selectionTableProbe;
|
||||
return {
|
||||
samples: samples.length,
|
||||
maxTableDelta: maxDelta("table"),
|
||||
maxRowDelta: maxDelta("row"),
|
||||
maxTargetDelta: maxDelta("target"),
|
||||
scrollStable: samples.every((sample) => (
|
||||
sample.scrollTop === baseline.scrollTop
|
||||
&& sample.scrollHeight === baseline.scrollHeight
|
||||
&& sample.clientHeight === baseline.clientHeight
|
||||
)),
|
||||
hostStable: samples.every((sample) => sample.hostCount === 1 && sample.hostStable),
|
||||
observedOpen: samples.some((sample) => sample.hostState === "open"),
|
||||
settledState: settled.hostState,
|
||||
settledButtonDisabled: document.querySelector('.transcript-selection-action[data-surface="transcript"] button')?.disabled,
|
||||
settledButtonTabIndex: document.querySelector('.transcript-selection-action[data-surface="transcript"] button')?.tabIndex,
|
||||
};
|
||||
});
|
||||
assert(result.samples >= 13, `selection-table records pointer, selection, and frame geometry (${result.samples} samples)`);
|
||||
assert(result.hostStable, "four native multi-clicks retain one transcript action host identity");
|
||||
assert(result.observedOpen, "native multi-click selection opens the transcript action host");
|
||||
assert(result.scrollStable, "native multi-click selection preserves scrollTop/scrollHeight/clientHeight");
|
||||
assert(result.maxTableDelta <= 0.5, `table top/left stay within 0.5px (${result.maxTableDelta})`);
|
||||
assert(result.maxRowDelta <= 0.5, `table row top/left stay within 0.5px (${result.maxRowDelta})`);
|
||||
assert(result.maxTargetDelta <= 0.5, `strong target top/left stay within 0.5px (${result.maxTargetDelta})`);
|
||||
assert(
|
||||
result.settledState === "closed" && result.settledButtonDisabled && result.settledButtonTabIndex === -1,
|
||||
"settled action host is closed, disabled, and outside the tab order",
|
||||
);
|
||||
}
|
||||
|
||||
const packageManager = process.platform === "win32" ? "pnpm.cmd" : "pnpm";
|
||||
const preview = spawn(packageManager, ["exec", "vite", "preview", "--port", String(port), "--strictPort", "--host", "127.0.0.1"], {
|
||||
cwd: frontendDir,
|
||||
stdio: "ignore",
|
||||
shell: process.platform === "win32",
|
||||
});
|
||||
|
||||
let browser;
|
||||
@@ -160,20 +287,33 @@ try {
|
||||
await page.goto(url, { waitUntil: "domcontentloaded" });
|
||||
await page.waitForFunction(() => document.querySelectorAll(".transcript__row").length > 4, undefined, { timeout: 30_000 });
|
||||
await page.waitForFunction(() => !document.querySelector(".startup-splash"), undefined, { timeout: 30_000 });
|
||||
await runSelectionTableRepaintGeometry(page);
|
||||
await page.click('.project-tree__topic-main:has-text("bench:tools-38t")');
|
||||
await page.waitForFunction(() => (
|
||||
document.querySelector(".project-tree__topic--active .project-tree__topic-label")?.textContent?.includes("bench:tools-38t")
|
||||
&& document.querySelector(".transcript")?.textContent?.includes("pkg-41/mod.go")
|
||||
), undefined, { timeout: 30_000 });
|
||||
await page.waitForFunction(
|
||||
() => !document.querySelector(".transcript-navigation-overlay"),
|
||||
undefined,
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
await page.waitForTimeout(300);
|
||||
for (let pageIndex = 0; pageIndex < 8; pageIndex += 1) {
|
||||
await page.evaluate(() => {
|
||||
// Preload the selection fixture with real upward wheel intent. Directly
|
||||
// assigning scrollTop used to make Virtuoso's startReached callback page in
|
||||
// the background, but viewport paging now requires one permit per page.
|
||||
for (let pageIndex = 0; pageIndex < 32; pageIndex += 1) {
|
||||
const before = Number(await page.locator(".transcript").getAttribute("data-transcript-row-count") ?? 0);
|
||||
const box = await page.locator(".transcript").boundingBox();
|
||||
if (!box) break;
|
||||
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
|
||||
await page.mouse.wheel(0, -100_000);
|
||||
const loaded = await page.waitForFunction((previous) => {
|
||||
const transcript = document.querySelector(".transcript");
|
||||
if (transcript) transcript.scrollTop = 0;
|
||||
});
|
||||
await page.waitForTimeout(100);
|
||||
if (!(await clickIfPresent(page, ".transcript__older"))) break;
|
||||
await page.waitForTimeout(350);
|
||||
const current = Number(transcript?.getAttribute("data-transcript-row-count") ?? 0);
|
||||
return current > previous;
|
||||
}, before, { timeout: 2_000 }).then(() => true, () => false);
|
||||
if (!loaded) break;
|
||||
}
|
||||
await clickIfPresent(page, ".transcript__jump-bottom");
|
||||
try {
|
||||
|
||||
@@ -23,5 +23,10 @@
|
||||
</div>
|
||||
</div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
<!-- Wails v2.13 dev spinner anchor: the dev runtime mounts its loading
|
||||
overlay here; without the element the overlay anchors on null and its
|
||||
Svelte internals crash ("Cannot read properties of null (reading
|
||||
'nodes')"), which the app's error screen then escalates. -->
|
||||
<div id="wails-spinner" aria-hidden="true"></div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -3,13 +3,14 @@
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"packageManager": "[email protected]",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "pnpm lint:hooks && pnpm check:waapi && pnpm check:scroll-writer && node scripts/check-css-syntax.mjs src/styles.css && node scripts/check-z-index-tokens.mjs src/styles.css && tsc --noEmit && vite build && node scripts/check-bundle-budget.mjs",
|
||||
"build": "pnpm lint:hooks && pnpm check:waapi && pnpm check:scroll-writer && node scripts/check-css-syntax.mjs src/styles.css src/components/RemoteConnectWizard.css src/components/TranscriptSelectionMenu.css && node scripts/check-z-index-tokens.mjs src/styles.css src/components/RemoteConnectWizard.css && node scripts/check-theme-token-contract.mjs && tsc --noEmit && vite build && node scripts/check-bundle-budget.mjs",
|
||||
"check:bundle": "node scripts/check-bundle-budget.mjs",
|
||||
"check:scroll-writer": "node scripts/check-single-scroll-writer.mjs",
|
||||
"preview": "vite preview",
|
||||
"check:css": "node scripts/check-css-syntax.mjs src/styles.css && node scripts/check-z-index-tokens.mjs src/styles.css",
|
||||
"check:css": "node scripts/check-css-syntax.mjs src/styles.css src/components/RemoteConnectWizard.css && node scripts/check-z-index-tokens.mjs src/styles.css src/components/RemoteConnectWizard.css && node scripts/check-theme-token-contract.mjs",
|
||||
"check:waapi": "node scripts/check-waapi-contract.mjs",
|
||||
"lint:hooks": "eslint \"src/**/*.{ts,tsx}\"",
|
||||
"test:todo-visibility": "node scripts/test-todo-visibility.mjs",
|
||||
@@ -22,13 +23,15 @@
|
||||
"test:stream": "tsx src/__tests__/raf-batch.test.ts && tsx src/__tests__/stream-delta-batch.test.ts && tsx src/__tests__/use-controller-stream-progress.test.ts",
|
||||
"test:motion": "node scripts/check-motion-ci-contract.mjs && node scripts/check-waapi-contract.mjs --self-test && tsx src/__tests__/native-motion.test.tsx && tsx src/__tests__/approval-animation.test.tsx",
|
||||
"test:motion-browser": "PLAYWRIGHT_BROWSERS_PATH=.pw-browsers node bench/approval-animation.mjs",
|
||||
"test:transcript": "tsx src/__tests__/transcript-virtuoso-index.test.ts && tsx src/__tests__/transcript-measured-sizes.test.ts && tsx src/__tests__/transcript-state-snapshot.test.ts && tsx src/__tests__/transcript-layout-recovery.test.ts && tsx src/__tests__/transcript-reader-extent-stability.test.ts && tsx src/__tests__/transcript-reader-extent-race.test.tsx && tsx src/__tests__/transcript-recovery-race.test.tsx && tsx src/__tests__/transcript-anchor-compensation-race.test.tsx && tsx src/__tests__/transcript-scroll-release.test.ts && tsx src/__tests__/transcript-scroll-diagnostics.test.ts && tsx src/__tests__/transcript-native-scrollbar.test.ts && tsx src/__tests__/nested-scroll-handoff.test.ts && tsx src/__tests__/creation-transcript-scrollbar.test.ts && tsx src/__tests__/question-jump-bar.test.tsx && tsx src/__tests__/transcript-question-nav.test.ts && tsx src/__tests__/transcript-question-nav-integration.test.ts && tsx src/__tests__/markdown-table-virtual.test.tsx && tsx src/__tests__/typography-overflow-contract.test.ts && tsx src/__tests__/transcript-selection-retention.test.tsx && tsx src/__tests__/transcript-logical-selection.test.ts && tsx src/__tests__/transcript-selection-overlay.test.tsx && tsx src/__tests__/markdown-pipeline.test.tsx && tsx src/__tests__/message-selection-copy.test.ts && tsx src/__tests__/transcript-selection-menu.test.tsx && tsx src/__tests__/transcript-store.test.ts && tsx src/__tests__/transcript-virtualization.test.tsx && tsx src/__tests__/transcript-question-jump.test.tsx",
|
||||
"test:transcript-browser": "PLAYWRIGHT_BROWSERS_PATH=.pw-browsers node bench/transcript-selection.mjs && PLAYWRIGHT_BROWSERS_PATH=.pw-browsers node bench/transcript-scroll-stability.mjs",
|
||||
"pretest": "pnpm test:terminal && pnpm test:task-monitor && pnpm test:composer && tsx src/__tests__/context-center-contract.test.ts && tsx src/__tests__/provider-model-cache.test.ts && tsx src/__tests__/format-tokens.test.ts && pnpm test:usage-stats && pnpm test:settings-responsive && pnpm test:composer-menu-viewport && pnpm test:transcript",
|
||||
"test:theme-browser": "PLAYWRIGHT_BROWSERS_PATH=.pw-browsers node bench/theme-surface-contract.mjs",
|
||||
"test:diagnostics": "tsx src/__tests__/diagnostics-settings.test.tsx",
|
||||
"test:transcript": "tsx src/__tests__/transcript-virtuoso-index.test.ts && tsx src/__tests__/transcript-row-geometry.test.ts && tsx src/__tests__/transcript-geometry-environment.test.ts && tsx src/__tests__/transcript-measured-sizes.test.ts && tsx src/__tests__/transcript-state-snapshot.test.ts && tsx src/__tests__/transcript-layout-recovery.test.ts && tsx src/__tests__/transcript-reader-extent-stability.test.ts && tsx src/__tests__/transcript-reader-extent-race.test.tsx && tsx src/__tests__/transcript-recovery-race.test.tsx && tsx src/__tests__/transcript-anchor-compensation-race.test.tsx && tsx src/__tests__/transcript-scroll-release.test.ts && tsx src/__tests__/transcript-scroll-writer.test.ts && tsx src/__tests__/transcript-scroll-diagnostics.test.ts && tsx src/__tests__/frontend-diagnostics.test.ts && tsx src/__tests__/project-tree-diagnostics.test.ts && tsx src/__tests__/transcript-native-scrollbar.test.ts && tsx src/__tests__/nested-scroll-handoff.test.ts && tsx src/__tests__/creation-transcript-scrollbar.test.ts && tsx src/__tests__/question-jump-bar.test.tsx && tsx src/__tests__/transcript-question-nav.test.ts && tsx src/__tests__/transcript-question-nav-integration.test.ts && tsx src/__tests__/markdown-table-virtual.test.tsx && tsx src/__tests__/typography-overflow-contract.test.ts && tsx src/__tests__/transcript-selection-retention.test.tsx && tsx src/__tests__/transcript-logical-selection.test.ts && tsx src/__tests__/transcript-selection-overlay.test.tsx && tsx src/__tests__/markdown-pipeline.test.tsx && tsx src/__tests__/message-selection-copy.test.ts && tsx src/__tests__/transcript-selection-menu.test.tsx && tsx src/__tests__/transcript-selection-rendering.test.ts && tsx src/__tests__/transcript-store.test.ts && tsx src/__tests__/transcript-virtualization.test.tsx && tsx src/__tests__/transcript-question-jump.test.tsx",
|
||||
"test:transcript-browser": "node bench/transcript-selection.mjs && node bench/transcript-scroll-stability.mjs",
|
||||
"pretest": "pnpm test:terminal && pnpm test:task-monitor && pnpm test:composer && tsx src/__tests__/context-center-contract.test.ts && tsx src/__tests__/provider-model-cache.test.ts && tsx src/__tests__/format-tokens.test.ts && pnpm test:usage-stats && pnpm test:settings-responsive && pnpm test:composer-menu-viewport && pnpm test:diagnostics && pnpm test:transcript",
|
||||
"test:composer-menu-viewport": "tsx src/__tests__/composer-menu-viewport.test.ts && tsx src/__tests__/virtual-menu-identity.test.tsx",
|
||||
"test:composer": "tsx src/__tests__/rich-composer-selection.test.tsx && tsx src/__tests__/composer-resized-input-css.test.ts && tsx src/__tests__/composer-ime.test.tsx && tsx src/__tests__/composer-autosize-scrollbar-css.test.ts",
|
||||
"test": "node scripts/test-todo-visibility.mjs && node scripts/run-tests.mjs",
|
||||
"test:remote": "tsx src/__tests__/remote-workspace-launch.test.ts && tsx src/__tests__/remote-store.test.ts && tsx src/__tests__/remote-error-ux.test.tsx && tsx src/__tests__/remote-hosts-page.test.tsx && tsx src/__tests__/remote-secret-dialog.test.tsx && node --import ./scripts/svg-stub-register.mjs --import tsx src/__tests__/remote-server-panel.test.tsx && tsx src/__tests__/statusbar-workspace.test.tsx",
|
||||
"test:remote": "tsx src/__tests__/remote-workspace-launch.test.ts && tsx src/__tests__/remote-store.test.ts && tsx src/__tests__/remote-error-ux.test.tsx && tsx src/__tests__/remote-hosts-page.test.tsx && node --import ./scripts/css-stub-register.mjs --import tsx src/__tests__/remote-connect-wizard.test.tsx && tsx src/__tests__/add-project-entries.test.ts && tsx src/__tests__/remote-secret-dialog.test.tsx && node --import ./scripts/svg-stub-register.mjs --import tsx src/__tests__/remote-server-panel.test.tsx && node --import ./scripts/svg-stub-register.mjs --import tsx src/__tests__/remote-session-surface.test.tsx && tsx src/__tests__/remote-running-reconcile.test.ts && tsx src/__tests__/remote-project-tree.test.tsx && tsx src/__tests__/remote-session-cache.test.ts && tsx src/__tests__/remote-tab-opened.test.tsx && tsx src/__tests__/statusbar-workspace.test.tsx",
|
||||
"test:performance": "tsx src/__tests__/history-performance-benchmark.tsx",
|
||||
"test:bench": "PLAYWRIGHT_BROWSERS_PATH=.pw-browsers node bench/run.mjs",
|
||||
"test:typecheck": "tsc --noEmit -p tsconfig.test.json",
|
||||
|
||||
@@ -81,9 +81,64 @@ console.log("\nbundle budgets");
|
||||
// path while keeping its 1.68 KiB question rail lazy-loaded. Test diagnostics
|
||||
// plus the navigation owner add 0.7 KiB gzip (0.164%) over the merged test gate.
|
||||
// DingTalk channel status and locale wiring move the current-base production
|
||||
// build from 427.2 to 427.7 KiB and test from 428.6 to 429.1 KiB. Keep about
|
||||
// 0.1 KiB of build-SHA headroom with a 0.5 KiB (about 0.117%) ratchet per gate.
|
||||
const initialJSBudgetKiB = process.env.REASONIX_CHANNEL === "test" ? 429.2 : 427.8;
|
||||
// build from 427.2 to 427.7 KiB and test from 428.6 to 429.1 KiB. The unified
|
||||
// state-aware geometry contract, session diagnostics counters, and guarded
|
||||
// native-scroll probes add 2.4 KiB gzip to the initial path. The current
|
||||
// main-v2 merge adds another 0.3 KiB of deterministic shared startup code.
|
||||
// Keep the increase explicit and bounded instead of hiding it in a broad
|
||||
// percentage ratchet.
|
||||
// The retained-transcript surface adds a small, bounded navigation owner to
|
||||
// the startup path (overlay state + stale-completion guard). Keep the increase
|
||||
// explicit and narrow; the measured build is 431.1 KiB gzip.
|
||||
// The web-search tool card now resolves the same display projection lazily so
|
||||
// its filtered count matches the assistant Sources panel. The measured build
|
||||
// is 431.509 KiB gzip; keep 0.1 KiB of explicit headroom for hash/toolchain
|
||||
// drift instead of relying on a rounded equality.
|
||||
// Remote onboarding [0.5/3] adds project-group and credential-chain wiring on
|
||||
// top of the lazy wizard. Exact-turn routing, the extracted event-gap
|
||||
// projector, checkpoint resets, and the navigation surface transaction bring
|
||||
// the current main-v2 path to 437.36 KiB gzip.
|
||||
// The full remote-session surface adds the lazy transcript bridge and tab
|
||||
// lifecycle on top of [0.5/3]. Keep the measured stack's narrow ratchet.
|
||||
// Remote approval hardening adds authoritative composer-profile hydration,
|
||||
// scoped rewind dispatch, and attachment/inbox fences to the always-mounted
|
||||
// remote hook. The measured production path is 438.38 KiB gzip after keeping
|
||||
// the integration modules below repolint's ownership ceilings; retain 0.12 KiB
|
||||
// toolchain headroom with a bounded 1.4 KiB ratchet.
|
||||
// Remote status isolation keeps the always-mounted status bar on the active
|
||||
// remote transcript and routes job cancellation to that host. Parsers and
|
||||
// retry policy remain lazy; the measured selector adds under 0.1 KiB gzip.
|
||||
// Remote runtime parity adds scoped approvals, status-only reconciliation,
|
||||
// session quality-floor routing, dropped-frame reconciliation, and remote
|
||||
// runtime-command dispatch. The measured initial path is 439.60 KiB;
|
||||
// retain 0.10 KiB of bounded toolchain headroom.
|
||||
// Closing the remaining review gaps adds generation-fenced hydration plus
|
||||
// remote-only tool payload, Todo, and terminal isolation. The measured path is
|
||||
// 439.74 KiB; retain 0.06 KiB of headroom with a 0.1 KiB ratchet.
|
||||
// The final remote-runtime parity pass adds remote run-strip telemetry,
|
||||
// explicit session verbs, and specialized plan decisions. The measured path
|
||||
// is 440.02 KiB. The current main-v2 turn-event, finish-protocol, and session
|
||||
// repair runtime then moves the combined path to 445.097 KiB; retain 0.103 KiB
|
||||
// of bounded build/toolchain headroom.
|
||||
// Atomic remote profile changes, exact approval draining, and generation-safe
|
||||
// history handoff bring the measured path to 445.228 KiB. Retain 0.072 KiB of
|
||||
// headroom with the smallest existing decimal ratchet.
|
||||
// Direct pending-prompt recovery and authoritative remote Goal state bring the
|
||||
// measured path to 445.473 KiB. Retain 0.027 KiB of bounded headroom.
|
||||
// Restored remote shells now activate their backend session immediately and
|
||||
// keep disconnected state out of the mounted surface. The merged production
|
||||
// path measures 445.614 KiB; retain 0.086 KiB of bounded build/toolchain
|
||||
// headroom with the smallest existing decimal ratchet.
|
||||
// Runtime-aware Todo presentation plus exact-tab continuation adds 0.3 KiB gzip
|
||||
// to the always-mounted footer path. Keep the state/routing guard with a narrow
|
||||
// ratchet rather than showing idle restored work as actively running. The
|
||||
// combined path measures 445.9 KiB; retain 0.1 KiB of toolchain headroom.
|
||||
// Transcript surface ownership and the token-fenced unloaded-question commit
|
||||
// move the exact main-v2 baseline from 445.865 to 447.587 KiB gzip (+0.39%).
|
||||
// The final 0.266 KiB retains jump ownership through paint-ready instead of
|
||||
// allowing a native scrollend to release it. Keep only 0.213 KiB headroom;
|
||||
// native validation hosts and test fixtures stay outside the production graph.
|
||||
const initialJSBudgetKiB = process.env.REASONIX_CHANNEL === "test" ? 447.8 : 447.8;
|
||||
assertBudget("initial JavaScript gzip", initialJSGzip, initialJSBudgetKiB * 1024);
|
||||
assertBudget("largest initial JavaScript chunk gzip", largestInitialJS, 280 * 1024);
|
||||
// Render-blocking CSS is intentionally absent: styles.css loads deferred via
|
||||
@@ -97,7 +152,12 @@ if (initialCSS.length > 0) {
|
||||
// Extension surfaces, Task Monitor, and compact decision receipts share the
|
||||
// application stylesheet loaded before React mounts. Keep their combined
|
||||
// allowance bounded even though the file is no longer render-blocking.
|
||||
assertBudget("deferred app-shell CSS gzip", appShellCSSGzip, 114 * 1024);
|
||||
// Navigation overlay styles add a bounded 0.1 KiB to the deferred shell.
|
||||
// The cleaned source panel adds 0.1 KiB gzip to the deferred shell on top of
|
||||
// the retained-transcript navigation allowance; keep the ratchet explicit.
|
||||
// The navigation mask's stable composer footprint and remote tab/surface
|
||||
// states bring the merged shell to roughly 115.7 KiB gzip.
|
||||
assertBudget("deferred app-shell CSS gzip", appShellCSSGzip, 116.0 * 1024);
|
||||
if (localeChunks.length !== 2) {
|
||||
throw new Error(`expected 2 on-demand Chinese locale chunks, found ${localeChunks.length}`);
|
||||
}
|
||||
@@ -120,8 +180,12 @@ for (const path of localeChunks) {
|
||||
// Recovery-copy and catalog-only sidebar labels can move the simplified
|
||||
// Chinese chunk across the rounded 55.9 KiB boundary on CI's Node/zlib;
|
||||
// retain a narrow 0.1 KiB headroom rather than making gzip output a
|
||||
// platform-dependent gate.
|
||||
const budget = name.startsWith("zh-TW-") ? 56.6 * 1024 : 56.0 * 1024;
|
||||
// platform-dependent gate. The OpenCode one-key setup adds product-level
|
||||
// connection, fallback, and legacy-state copy while removing protocol
|
||||
// choices from the primary UI; keep that complete guidance with a bounded
|
||||
// 0.4–0.5 KiB locale-only ratchet.
|
||||
// Remote-session actions and disconnected-shell guidance add bounded copy.
|
||||
const budget = name.startsWith("zh-TW-") ? 58.5 * 1024 : 57.8 * 1024;
|
||||
assertBudget(`${name} gzip`, gzipBytes(path), budget);
|
||||
}
|
||||
|
||||
@@ -134,9 +198,24 @@ const rawInitialBytes = [...initialJS, ...initialCSS, ...appShellCSS]
|
||||
// KiB and test from 2346.2 to 2348.8 KiB; the pinned heading adds 0.5 KiB raw
|
||||
// (0.021%). The workspace panel rework (change-row hover/revert, status badges,
|
||||
// More menu, completion summary) makes the latest-base merge 2353.1 KiB in
|
||||
// production and 2358.3 KiB in test: about 9.0 KiB (0.38%) over main-v2's
|
||||
// channel gates. Retain that attributable UI capacity with 0.1 KiB of build-SHA
|
||||
// headroom without widening the gzip or largest-chunk exceptions.
|
||||
const rawInitialBudgetKiB = process.env.REASONIX_CHANNEL === "test" ? 2_358.4 : 2_353.2;
|
||||
// production and test channels both measure 2357.92 KiB after project-group
|
||||
// wiring. Exact-turn routing, checkpoint resets, and failure-atomic navigation
|
||||
// bring the current main-v2 path to 2379.22 KiB. The remote approval
|
||||
// fences, extracted ownership modules, and remote status-bar isolation bring
|
||||
// the measured initial payload to 2380.9 KiB; retain 0.1 KiB of bounded
|
||||
// raw/toolchain headroom. Scoped remote approvals, status reconciliation, and
|
||||
// runtime command dispatch bring the measured payload to 2382.9 KiB. The
|
||||
// remaining review fences measure 2383.2 KiB; retain 0.1 KiB of headroom.
|
||||
// Final remote-runtime parity measures 2384.4 KiB raw. The current main-v2
|
||||
// runtime additions bring the combined path to 2404.364 KiB. The final merged
|
||||
// restored-shell activation and disconnected-state revival path measures
|
||||
// 2404.898 KiB; retain 0.102 KiB of bounded headroom alongside the gzip
|
||||
// ratchet above.
|
||||
// Runtime-aware Todo status and exact-tab continuation then add to the same
|
||||
// initial path. The combined payload measures 2406.2 KiB; retain 0.1 KiB of
|
||||
// raw/toolchain headroom for both owners.
|
||||
// The same transcript transaction measures 2413.012 KiB raw (+0.28%) against
|
||||
// the 2406.204 KiB baseline. Retain 0.188 KiB of bounded headroom.
|
||||
const rawInitialBudgetKiB = process.env.REASONIX_CHANNEL === "test" ? 2_413.2 : 2_413.2;
|
||||
assertBudget("initial raw JavaScript and CSS", rawInitialBytes, rawInitialBudgetKiB * 1024);
|
||||
assertBudget("largest initial JavaScript chunk raw", largestInitialJSRaw, 1_000 * 1024);
|
||||
@@ -11,6 +11,8 @@ const appSource = readFileSync(resolve(repoRoot, "desktop/frontend/src/App.tsx")
|
||||
const bridgeSource = readFileSync(resolve(repoRoot, "desktop/frontend/src/lib/bridge.ts"), "utf8");
|
||||
const desktopMainSource = readFileSync(resolve(repoRoot, "desktop/main.go"), "utf8");
|
||||
const transcriptScrollBenchSource = readFileSync(resolve(repoRoot, "desktop/frontend/bench/transcript-scroll-stability.mjs"), "utf8");
|
||||
const transcriptSelectionBenchSource = readFileSync(resolve(repoRoot, "desktop/frontend/bench/transcript-selection.mjs"), "utf8");
|
||||
const transcriptSelectionComponentSource = readFileSync(resolve(repoRoot, "desktop/frontend/src/components/TranscriptSelectionMenu.tsx"), "utf8");
|
||||
|
||||
function jobBody(name, nextName) {
|
||||
const match = workflow.match(new RegExp(`\\n ${name}:\\n([\\s\\S]*?)\\n ${nextName}:`));
|
||||
@@ -35,12 +37,35 @@ for (const required of [
|
||||
"../scripts/test-webview2-native-smoke.ps1 -SelfTest",
|
||||
"Smoke-test Wails/WebView2 native startup",
|
||||
"../scripts/test-webview2-native-smoke.ps1",
|
||||
"Test WebView2 transcript selection compositor",
|
||||
"../scripts/test-transcript-selection-webview2.ps1 -Iterations 3",
|
||||
"Upload WebView2 transcript selection evidence",
|
||||
]) {
|
||||
if (!windowsJob.includes(required)) {
|
||||
throw new Error(`motion-ci-contract: desktop-windows must include ${required}`);
|
||||
}
|
||||
}
|
||||
|
||||
for (const required of [
|
||||
"bench:selection-table",
|
||||
"SELECTION REPAINT TARGET",
|
||||
"clickIntervals = [400, 320, 180, 0]",
|
||||
"maxTargetDelta <= 0.5",
|
||||
]) {
|
||||
if (!transcriptSelectionBenchSource.includes(required)) {
|
||||
throw new Error(`motion-ci-contract: transcript selection browser gate must retain ${required}`);
|
||||
}
|
||||
}
|
||||
for (const required of [
|
||||
'data-surface="transcript"',
|
||||
"data-state={actionOverlay.phase}",
|
||||
"actionOverlayStateRef.current.action",
|
||||
]) {
|
||||
if (!transcriptSelectionComponentSource.includes(required)) {
|
||||
throw new Error(`motion-ci-contract: stable transcript selection host must retain ${required}`);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [path, source] of [
|
||||
["desktop/main.go", desktopMainSource],
|
||||
["desktop/frontend/src/App.tsx", appSource],
|
||||
@@ -50,12 +75,23 @@ for (const [path, source] of [
|
||||
"REASONIX_WEBVIEW2_APPROVAL_SMOKE",
|
||||
"__REASONIX_WEBVIEW2_APPROVAL_SMOKE__",
|
||||
"WebView2ApprovalSmokeBridge",
|
||||
"__reasonixSelectionSmoke",
|
||||
"reasonix_transcript_smoke",
|
||||
]) {
|
||||
if (source.includes(forbidden)) {
|
||||
throw new Error(`motion-ci-contract: ${path} must not embed test-only WebView2 instrumentation (${forbidden})`);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const requiredPath of [
|
||||
"desktop/cmd/transcript-selection-smoke/main.go",
|
||||
"desktop/transcript_selection_smoke_contract.js",
|
||||
"scripts/test-transcript-selection-webview2.ps1",
|
||||
]) {
|
||||
if (!existsSync(resolve(repoRoot, requiredPath))) {
|
||||
throw new Error(`motion-ci-contract: missing independent WebView2 selection smoke file ${requiredPath}`);
|
||||
}
|
||||
}
|
||||
for (const retiredPath of [
|
||||
"desktop/webview2_approval_smoke.go",
|
||||
"desktop/frontend/src/lib/useWebView2ApprovalSmoke.ts",
|
||||
@@ -105,6 +141,7 @@ for (const required of [
|
||||
"markdown-pipeline.test.tsx",
|
||||
"message-selection-copy.test.ts",
|
||||
"transcript-selection-menu.test.tsx",
|
||||
"transcript-selection-rendering.test.ts",
|
||||
"transcript-store.test.ts",
|
||||
"transcript-virtualization.test.tsx",
|
||||
]) {
|
||||
@@ -121,19 +158,23 @@ for (const required of ["transcript-selection.mjs", "transcript-scroll-stability
|
||||
}
|
||||
|
||||
const transcriptCommand = "pnpm --dir frontend test:transcript";
|
||||
const transcriptRuns = workflow.match(/pnpm --dir frontend test:transcript(?:\s|$)/g)?.length ?? 0;
|
||||
if (!jobBody("desktop", "desktop-macos").includes(transcriptCommand) || transcriptRuns !== 1) {
|
||||
const desktopLinuxJob = jobBody("desktop", "desktop-macos");
|
||||
const transcriptRuns = desktopLinuxJob.match(/pnpm --dir frontend test:transcript(?:\s|$)/g)?.length ?? 0;
|
||||
if (!desktopLinuxJob.includes(transcriptCommand) || transcriptRuns !== 1) {
|
||||
throw new Error("motion-ci-contract: the Linux desktop job must run test:transcript exactly once");
|
||||
}
|
||||
|
||||
const transcriptBrowserCommand = "pnpm --dir frontend test:transcript-browser";
|
||||
const transcriptBrowserRuns = workflow.match(/pnpm --dir frontend test:transcript-browser(?:\s|$)/g)?.length ?? 0;
|
||||
if (!jobBody("desktop", "desktop-macos").includes(transcriptBrowserCommand) || transcriptBrowserRuns !== 1) {
|
||||
const transcriptBrowserRuns = desktopLinuxJob.match(/pnpm --dir frontend test:transcript-browser(?:\s|$)/g)?.length ?? 0;
|
||||
if (!desktopLinuxJob.includes(transcriptBrowserCommand) || transcriptBrowserRuns !== 1) {
|
||||
throw new Error("motion-ci-contract: the Linux desktop job must run test:transcript-browser exactly once");
|
||||
}
|
||||
if (!jobBody("desktop", "desktop-macos").includes("PLAYWRIGHT_BROWSERS_PATH=.pw-browsers pnpm --dir frontend exec playwright install")) {
|
||||
if (!desktopLinuxJob.includes("PLAYWRIGHT_BROWSERS_PATH=.pw-browsers pnpm --dir frontend exec playwright install")) {
|
||||
throw new Error("motion-ci-contract: Chromium must install into the path used by frontend browser tests");
|
||||
}
|
||||
if (!windowsJob.includes(transcriptBrowserCommand)) {
|
||||
throw new Error("motion-ci-contract: desktop-windows must run the transcript browser replay");
|
||||
}
|
||||
for (const required of ["transcript-selection.mjs", "transcript-scroll-stability.mjs"]) {
|
||||
if (!packageJSON.scripts?.["test:transcript-browser"]?.includes(required)) {
|
||||
throw new Error(`motion-ci-contract: test:transcript-browser must include ${required}`);
|
||||
|
||||
@@ -5,8 +5,7 @@
|
||||
*
|
||||
* Only the files in ALLOWED_WRITERS may issue imperative scroll calls
|
||||
* (scrollTo / scrollBy / scrollToIndex) against the transcript Virtuoso
|
||||
* handle: the arbiter hook plus its extracted controllers (split out for the
|
||||
* file-size budget — still one logical writer, driven only by the arbiter).
|
||||
* handle: the generation-aware writer gateway owned by the arbiter.
|
||||
* Every other module must route through the scroll coordinator's
|
||||
* dispatch/writeOffset API. This guards the "one writer owns scrollTop"
|
||||
* invariant that keeps user scrolls, tail-follow, and anchor recovery from
|
||||
@@ -23,12 +22,9 @@ import { fileURLToPath } from "node:url";
|
||||
|
||||
const SOURCE_ROOT = fileURLToPath(new URL("../src", import.meta.url));
|
||||
|
||||
// Only the scroll-arbiter hook and its extracted controllers may write to the
|
||||
// transcript Virtuoso handle; every other module routes through the
|
||||
// dispatch/writeOffset/recovery API.
|
||||
// Every arbiter/controller command routes through this one gateway.
|
||||
const ALLOWED_WRITERS = new Set([
|
||||
"lib/useTranscriptScrollArbiter.ts",
|
||||
"lib/transcriptTailSettle.ts",
|
||||
"lib/transcriptScrollWriter.ts",
|
||||
]);
|
||||
|
||||
// Raw `.scrollTop` writes bypass the Virtuoso handle entirely. The allowed
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const frontendRoot = path.resolve(scriptDir, "..");
|
||||
const sourceRoot = path.join(frontendRoot, "src");
|
||||
|
||||
const retiredTokens = new Set([
|
||||
"--fg-muted",
|
||||
"--bg-elev-1",
|
||||
"--hover",
|
||||
"--border-strong",
|
||||
"--shadow",
|
||||
]);
|
||||
|
||||
// These properties are deliberately written by React/runtime geometry code.
|
||||
// Preview tokens are isolated on ThemePreviewSurface and never form part of
|
||||
// the application theme contract.
|
||||
const runtimeTokens = new Set([
|
||||
"--composer-height",
|
||||
"--invocation-color",
|
||||
"--sidebar-expanded-width",
|
||||
"--transcript-row-estimate",
|
||||
]);
|
||||
const runtimePrefixes = ["--tp-"];
|
||||
|
||||
const requiredRootTokens = new Map([
|
||||
["--stage", "var(--bg)"],
|
||||
["--surface", "var(--bg-elev)"],
|
||||
["--surface-2", "var(--bg-elev-2)"],
|
||||
["--surface-3", "var(--bg-soft)"],
|
||||
["--panel", "var(--bg-elev)"],
|
||||
["--border-2", "color-mix(in srgb, var(--fg) 20%, transparent)"],
|
||||
["--text", "var(--fg)"],
|
||||
["--text-2", "var(--fg-dim)"],
|
||||
["--text-3", "var(--fg-faint)"],
|
||||
["--shadow-color", "#000"],
|
||||
["--overlay-surface-bg", "var(--bg-elev)"],
|
||||
]);
|
||||
|
||||
const themeStyles = ["graphite", "aurora", "slate", "carbon", "nocturne", "amber"];
|
||||
const amberExpected = {
|
||||
dark: {
|
||||
"--bg": "#090a0c",
|
||||
"--bg-soft": "#111319",
|
||||
"--panel": "#191b22",
|
||||
"--sidebar-bg": "#0c0e12",
|
||||
"--fg": "#f4f5f7",
|
||||
"--fg-dim": "#c0c4cc",
|
||||
"--accent": "#d4632f",
|
||||
"--border": "#343945",
|
||||
},
|
||||
light: {
|
||||
"--bg": "#f7f8fb",
|
||||
"--bg-soft": "#eef2f7",
|
||||
"--panel": "#ffffff",
|
||||
"--sidebar-bg": "#f3f6fa",
|
||||
"--fg": "#111827",
|
||||
"--fg-dim": "#4b5563",
|
||||
"--accent": "#dd5b28",
|
||||
"--border": "#d8dee8",
|
||||
},
|
||||
};
|
||||
|
||||
function listCSSFiles(directory) {
|
||||
const files = [];
|
||||
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
|
||||
const target = path.join(directory, entry.name);
|
||||
if (entry.isDirectory()) files.push(...listCSSFiles(target));
|
||||
else if (entry.isFile() && target.endsWith(".css")) files.push(target);
|
||||
}
|
||||
return files.sort();
|
||||
}
|
||||
|
||||
function stripComments(source) {
|
||||
return source.replace(/\/\*[\s\S]*?\*\//g, (comment) => comment.replace(/[^\n]/g, " "));
|
||||
}
|
||||
|
||||
function lineNumber(source, index) {
|
||||
return source.slice(0, index).split("\n").length;
|
||||
}
|
||||
|
||||
function findBlock(source, selector) {
|
||||
const start = source.indexOf(selector);
|
||||
if (start < 0) return null;
|
||||
const open = source.indexOf("{", start + selector.length - 1);
|
||||
if (open < 0) return null;
|
||||
let depth = 0;
|
||||
for (let index = open; index < source.length; index += 1) {
|
||||
if (source[index] === "{") depth += 1;
|
||||
else if (source[index] === "}") {
|
||||
depth -= 1;
|
||||
if (depth === 0) return source.slice(open + 1, index);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function declarations(block) {
|
||||
const result = new Map();
|
||||
if (!block) return result;
|
||||
for (const match of block.matchAll(/(--[a-zA-Z0-9_-]+)\s*:\s*([^;]+);/g)) {
|
||||
result.set(match[1], match[2].trim());
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function normalize(value) {
|
||||
return value.replace(/\s+/g, " ").trim().toLowerCase();
|
||||
}
|
||||
|
||||
function resolveToken(name, tokens, seen = new Set()) {
|
||||
if (seen.has(name)) return null;
|
||||
const value = tokens.get(name);
|
||||
if (!value) return null;
|
||||
const alias = value.match(/^var\(\s*(--[a-zA-Z0-9_-]+)\s*\)$/);
|
||||
if (!alias) return normalize(value);
|
||||
const nextSeen = new Set(seen);
|
||||
nextSeen.add(name);
|
||||
return resolveToken(alias[1], tokens, nextSeen);
|
||||
}
|
||||
|
||||
const cssFiles = listCSSFiles(sourceRoot);
|
||||
const sources = new Map(cssFiles.map((file) => [file, stripComments(fs.readFileSync(file, "utf8"))]));
|
||||
const definitions = new Set();
|
||||
const errors = [];
|
||||
|
||||
for (const source of sources.values()) {
|
||||
for (const match of source.matchAll(/^\s*(--[a-zA-Z0-9_-]+)\s*:/gm)) definitions.add(match[1]);
|
||||
for (const match of source.matchAll(/(?<=[{;])\s*(--[a-zA-Z0-9_-]+)\s*:/g)) definitions.add(match[1]);
|
||||
}
|
||||
|
||||
for (const [file, source] of sources) {
|
||||
for (const match of source.matchAll(/var\(\s*(--[a-zA-Z0-9_-]+)\s*(?=,|\))/g)) {
|
||||
const token = match[1];
|
||||
const next = source[match.index + match[0].length];
|
||||
const hasFallback = next === ",";
|
||||
const runtimeOwned = runtimeTokens.has(token) || runtimePrefixes.some((prefix) => token.startsWith(prefix));
|
||||
if (retiredTokens.has(token)) {
|
||||
errors.push(`${path.relative(frontendRoot, file)}:${lineNumber(source, match.index)} references retired ${token}`);
|
||||
} else if (!definitions.has(token) && !hasFallback && !runtimeOwned) {
|
||||
errors.push(`${path.relative(frontendRoot, file)}:${lineNumber(source, match.index)} references undefined ${token}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const mainStyles = sources.get(path.join(sourceRoot, "styles.css"));
|
||||
if (!mainStyles) {
|
||||
errors.push("src/styles.css is missing");
|
||||
} else {
|
||||
const rootTokens = declarations(findBlock(mainStyles, ":root {"));
|
||||
for (const [token, expected] of requiredRootTokens) {
|
||||
const actual = rootTokens.get(token);
|
||||
if (!actual) errors.push(`src/styles.css base :root is missing ${token}`);
|
||||
else if (normalize(actual) !== normalize(expected)) {
|
||||
errors.push(`src/styles.css base :root must define ${token}: ${expected}; (found ${actual})`);
|
||||
}
|
||||
}
|
||||
for (const style of themeStyles) {
|
||||
const darkSelector = `:root[data-theme-style="${style}"]`;
|
||||
const lightSelector = `:root[data-theme="light"][data-theme-style="${style}"]`;
|
||||
const autoLightSelector = `:root[data-theme-style="${style}"]:not([data-theme])`;
|
||||
if (!mainStyles.includes(`${darkSelector} {`)) errors.push(`src/styles.css is missing ${style} dark theme selector`);
|
||||
if (!mainStyles.includes(`${lightSelector} {`)) errors.push(`src/styles.css is missing ${style} forced-light selector`);
|
||||
if (!mainStyles.includes(`${autoLightSelector} {`)) errors.push(`src/styles.css is missing ${style} auto-light selector`);
|
||||
}
|
||||
|
||||
const workbenchRefresh = mainStyles.slice(mainStyles.indexOf("* Native Workbench refresh"));
|
||||
if (/^:root\s*\{/m.test(workbenchRefresh)) {
|
||||
errors.push("Native Workbench refresh must not override named dark theme palettes");
|
||||
}
|
||||
if (/^:root\[data-theme="light"\]\s*\{/m.test(workbenchRefresh)) {
|
||||
errors.push("Native Workbench refresh must not override named forced-light theme palettes");
|
||||
}
|
||||
if (/^\s*:root:not\(\[data-theme\]\)\s*\{/m.test(workbenchRefresh)) {
|
||||
errors.push("Native Workbench refresh must not override named auto-light theme palettes");
|
||||
}
|
||||
|
||||
const darkAmber = new Map([
|
||||
...rootTokens,
|
||||
...declarations(findBlock(mainStyles, ':root[data-theme-style="amber"] {')),
|
||||
]);
|
||||
const lightAmber = new Map([
|
||||
...rootTokens,
|
||||
...declarations(findBlock(mainStyles, ':root[data-theme="light"] {')),
|
||||
...declarations(findBlock(mainStyles, ':root[data-theme="light"][data-theme-style="amber"] {')),
|
||||
]);
|
||||
for (const [mode, tokens] of [["dark", darkAmber], ["light", lightAmber]]) {
|
||||
for (const [token, expected] of Object.entries(amberExpected[mode])) {
|
||||
const actual = resolveToken(token, tokens);
|
||||
if (actual !== normalize(expected)) {
|
||||
errors.push(`Amber ${mode} ${token} must resolve to ${expected} (found ${actual ?? "undefined"})`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
console.error("Theme token contract check failed:");
|
||||
for (const error of errors) console.error(`- ${error}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`Theme token contract OK (${cssFiles.length} CSS files, ${definitions.size} defined tokens).`);
|
||||
@@ -54,8 +54,14 @@ const OWNED_ELSEWHERE = new Map(Object.entries({
|
||||
"remote-store.test.ts": "test:remote",
|
||||
"remote-error-ux.test.tsx": "test:remote",
|
||||
"remote-hosts-page.test.tsx": "test:remote",
|
||||
"remote-connect-wizard.test.tsx": "test:remote (needs the css stub register)",
|
||||
"add-project-entries.test.ts": "test:remote",
|
||||
"remote-secret-dialog.test.tsx": "test:remote",
|
||||
"remote-server-panel.test.tsx": "test:remote (needs the svg stub register)",
|
||||
"remote-session-surface.test.tsx": "test:remote (needs the svg stub register)",
|
||||
"remote-running-reconcile.test.ts": "test:remote",
|
||||
"remote-project-tree.test.tsx": "test:remote",
|
||||
"statusbar-workspace.test.tsx": "test:remote",
|
||||
"updater-shared-state.test.tsx": "test:updater",
|
||||
"window-state-ordering.test.ts": "test:window-state",
|
||||
}));
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
// Loader hook for tsx-based tests: static asset imports (SVG logos, etc.)
|
||||
// Loader hook for tsx-based tests: static asset imports (SVG logos, styles)
|
||||
// resolve to an empty string default export so component tests can import
|
||||
// components whose transitive store/component chain pulls in an asset.
|
||||
export async function load(url, context, nextLoad) {
|
||||
if (url.endsWith(".svg") || url.endsWith(".png") || url.endsWith(".webp")) {
|
||||
if (url.endsWith(".svg") || url.endsWith(".png") || url.endsWith(".webp") || url.endsWith(".css")) {
|
||||
return { format: "module", source: "export default \"\";", shortCircuit: true };
|
||||
}
|
||||
return nextLoad(url, context);
|
||||
|
||||
@@ -26,8 +26,10 @@ const {
|
||||
shouldOpenTodoPanelByDefault,
|
||||
shouldShowTodoPanel,
|
||||
todoBatchKey,
|
||||
todoContinueTarget,
|
||||
todoDismissalKey,
|
||||
todoPanelScope,
|
||||
todoPresentationStatus,
|
||||
} = await import(moduleUrl);
|
||||
|
||||
const completedTodos = [
|
||||
@@ -39,6 +41,15 @@ const activeTodos = [
|
||||
{ content: "Ship the fix", status: "pending" },
|
||||
];
|
||||
|
||||
assert.equal(todoPresentationStatus("in_progress", { running: true, pendingPrompt: false }), "in_progress", "an active turn renders its current todo as in progress");
|
||||
assert.equal(todoPresentationStatus("in_progress", { running: false, pendingPrompt: false }), "paused", "an idle or restored turn renders a stale current todo as ready to continue");
|
||||
assert.equal(todoPresentationStatus("in_progress", { running: true, pendingPrompt: true }), "waiting", "a prompt-blocked turn renders its current todo as waiting for the user");
|
||||
assert.equal(todoPresentationStatus("completed", { running: false, pendingPrompt: false }), "completed", "completed todos remain completed");
|
||||
assert.equal(todoContinueTarget("tab-a", "tab-a", { ready: true, running: false, pendingPrompt: false }), "tab-a", "continue targets the exact idle visible tab");
|
||||
assert.equal(todoContinueTarget("tab-a", "tab-b", { ready: true, running: false, pendingPrompt: false }), null, "a rapid tab switch prevents stale todo routing");
|
||||
assert.equal(todoContinueTarget("tab-a", "tab-a", { ready: true, running: true, pendingPrompt: false }), null, "running turns cannot receive a duplicate continue submission");
|
||||
assert.equal(todoContinueTarget("tab-a", "tab-a", { ready: true, running: false, pendingPrompt: true }), null, "pending prompts must be answered instead of bypassed by continue");
|
||||
|
||||
assert.deepEqual(
|
||||
resolveTodoPanelTodos([], undefined),
|
||||
[],
|
||||
|
||||
+379
-266
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,76 @@
|
||||
// Run: tsx src/__tests__/add-project-entries.test.ts
|
||||
// Source-contract test: the add-project entry points must offer the remote
|
||||
// connection action next to the local one, and the removed sidebar quick
|
||||
// button must stay gone.
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
function ok(value: boolean, label: string) {
|
||||
if (value) {
|
||||
process.stdout.write(` PASS ${label}\n`);
|
||||
passed += 1;
|
||||
} else {
|
||||
process.stdout.write(` FAIL ${label}\n`);
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
|
||||
console.log("\nAdd-project entry matrix");
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const treeSource = readFileSync(resolve(here, "../components/ProjectTree.tsx"), "utf8");
|
||||
const addControlsSource = readFileSync(resolve(here, "../components/ProjectTreeAddControls.tsx"), "utf8");
|
||||
const hookSource = readFileSync(resolve(here, "../components/useProjectCreation.tsx"), "utf8");
|
||||
const appSource = readFileSync(resolve(here, "../App.tsx"), "utf8");
|
||||
const locales = ["en", "zh", "zh-TW"].map((name) =>
|
||||
readFileSync(resolve(here, `../locales/${name}.ts`), "utf8"),
|
||||
);
|
||||
|
||||
// Workbench "+" menu: three items, remote opens the wizard flow.
|
||||
ok(
|
||||
/onRemote: \(\) => \{ closeMenu\(\); openRemoteConnectFlow\(\); \}/.test(treeSource) &&
|
||||
/key: "remote-connection"[\s\S]*?onSelect: onRemote/.test(addControlsSource),
|
||||
"workbench + menu offers the remote connection item wired to the wizard flow",
|
||||
);
|
||||
ok(
|
||||
/key: "blank-project"[\s\S]*?key: "open-local-folder"[\s\S]*?key: "remote-connection"/.test(addControlsSource),
|
||||
"workbench + menu keeps blank project and existing folder before remote connection",
|
||||
);
|
||||
|
||||
// Classic/creation "+": no longer a direct picker; renders a menu that
|
||||
// contains the remote connection item.
|
||||
ok(
|
||||
/project-tree__add-project[\s\S]*?aria-haspopup="menu"/.test(addControlsSource),
|
||||
"classic/creation + button opens a menu instead of the direct picker",
|
||||
);
|
||||
ok(
|
||||
/ProjectTreeHeaderAddControl[\s\S]*?items=\{classicHeaderAddItems\}/.test(treeSource),
|
||||
"classic/creation + menu is the two-item local/remote list",
|
||||
);
|
||||
|
||||
// Empty state: primary local + secondary remote.
|
||||
ok(
|
||||
/project-tree__empty-primary[\s\S]*?ProjectTreeRemoteAction[\s\S]*?openRemoteConnectFlow/.test(treeSource) &&
|
||||
/project-tree__empty-secondary/.test(addControlsSource),
|
||||
"empty state pairs the local action with a remote connection action",
|
||||
);
|
||||
|
||||
// The wizard mounts through the creation hook, outside the startup bundle.
|
||||
ok(
|
||||
/lazy\(\(\) => import\("\.\/RemoteConnectWizardEntry"\)/.test(hookSource),
|
||||
"remote wizard loads lazily through useProjectCreation",
|
||||
);
|
||||
|
||||
// Removed sidebar quick button: gone from App.tsx and no locale key remains.
|
||||
ok(!/sidebar__new--remote/.test(appSource), "removed sidebar quick button leaves no JSX behind");
|
||||
ok(!/creation\.sidebar\.remote/.test(appSource), "no creation.sidebar.remote usage in App.tsx");
|
||||
for (const [index, source] of locales.entries()) {
|
||||
ok(!/creation\.sidebar\.remote/.test(source), `creation.sidebar.remote removed from locale #${index + 1}`);
|
||||
ok(/"projectTree\.remoteConnection"/.test(source), `projectTree.remoteConnection present in locale #${index + 1}`);
|
||||
}
|
||||
|
||||
process.stdout.write(`\n${passed} passed, ${failed} failed\n`);
|
||||
if (failed > 0) process.exit(1);
|
||||
@@ -447,7 +447,7 @@ ok(
|
||||
/const controllerReady =\s*state\.meta\?\.ready === true &&\s*\(!state\.meta\.runtime \|\| state\.meta\.runtime\.phase === "ready"\) &&\s*!state\.meta\.startupErr &&\s*!state\.backendActivationPending &&\s*!runtimeTransitioning;/.test(appSource) &&
|
||||
/if \(!activeTabId \|\| !controllerReady\) return;\s*void commitThenSend\(activeTabId, text\)\.catch/.test(appSource) &&
|
||||
/onPrompt=\{handleTranscriptPrompt\}/.test(appSource) &&
|
||||
/submitDisabled=\{!controllerReady\}/.test(appSource),
|
||||
/submitDisabled=\{remoteSurfaceActive \? !remoteComposerReady \|\| !remoteComposerProfileReady : !controllerReady\}/.test(appSource),
|
||||
"welcome prompts and composer submit share the controller readiness gate",
|
||||
);
|
||||
|
||||
@@ -500,10 +500,10 @@ ok(
|
||||
"rewind previews warn on incomplete coverage and only authorize file overwrite after a conflict confirmation",
|
||||
);
|
||||
|
||||
ok(
|
||||
/const transcriptHydrating = state\.hydrating && !state\.hydrateHistoryLoaded;/.test(appSource) &&
|
||||
/hydrating=\{runtimeTransitioning \|\| transcriptHydrating\}/.test(appSource),
|
||||
"Welcome is suppressed only until transcript history has loaded",
|
||||
ok(/const transcriptHydrating = state\.hydrating && !state\.hydrateHistoryLoaded;/.test(appSource) &&
|
||||
/hydrating=\{transcriptHydrating \|\| \(runtimeTransitioning && !navigationTargetDataReady\)\}/.test(appSource) &&
|
||||
/surfaceCommitToken=\{surfaceCommitToken\}/.test(appSource) && /onSurfacePaintReady=\{handleSurfacePaintReady\}/.test(appSource),
|
||||
"Welcome stays suppressed through target data commit and navigation settles only after paint readiness",
|
||||
);
|
||||
|
||||
ok(
|
||||
|
||||
@@ -91,9 +91,14 @@ ok(cancelled === 1, "Escape cancels the dialog");
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const treeSource = readFileSync(resolve(here, "../components/ProjectTree.tsx"), "utf8");
|
||||
const addControlsSource = readFileSync(resolve(here, "../components/ProjectTreeAddControls.tsx"), "utf8");
|
||||
const flowSource = readFileSync(resolve(here, "../components/BlankProjectFlow.tsx"), "utf8");
|
||||
const creationSource = readFileSync(resolve(here, "../components/useProjectCreation.tsx"), "utf8");
|
||||
ok(/key: "blank-project"[\s\S]*openBlankProjectFlow\(\)/.test(treeSource), "workbench menu starts the blank-project flow");
|
||||
ok(
|
||||
/onBlank: \(\) => \{ closeMenu\(\); openBlankProjectFlow\(\); \}/.test(treeSource) &&
|
||||
/key: "blank-project"[\s\S]*onSelect: onBlank/.test(addControlsSource),
|
||||
"workbench menu starts the blank-project flow",
|
||||
);
|
||||
ok(/CreateBlankProject\(draft\.parentDirectory, projectName\)/.test(flowSource), "the dialog delegates atomic directory creation to Go");
|
||||
ok(/await onOpenProject\(createdPath\)/.test(flowSource), "the new directory reuses workspace navigation and registration");
|
||||
ok(/lazy\(\(\) => import\("\.\/BlankProjectFlow"\)/.test(creationSource), "the creation flow stays outside the startup bundle");
|
||||
|
||||
@@ -22,6 +22,9 @@ const appSource = readFileSync(resolve(here, "../App.tsx"), "utf8");
|
||||
const projectTreeSource = readFileSync(resolve(here, "../components/ProjectTree.tsx"), "utf8");
|
||||
const settingsEntrySource = readFileSync(resolve(here, "../components/SettingsPanelEntry.tsx"), "utf8");
|
||||
const settingsSource = readFileSync(resolve(here, "../components/SettingsPanel.tsx"), "utf8");
|
||||
const remoteWizardEntrySource = readFileSync(resolve(here, "../components/RemoteConnectWizardEntry.tsx"), "utf8");
|
||||
const remoteWizardSource = readFileSync(resolve(here, "../components/RemoteConnectWizard.tsx"), "utf8");
|
||||
const projectCreationSource = readFileSync(resolve(here, "../components/useProjectCreation.tsx"), "utf8");
|
||||
const markdownSource = readFileSync(resolve(here, "../components/Markdown.tsx"), "utf8");
|
||||
const localPathLinksSource = readFileSync(resolve(here, "../lib/localPathLinks.ts"), "utf8");
|
||||
const i18nSource = readFileSync(resolve(here, "../lib/i18n.tsx"), "utf8");
|
||||
@@ -62,6 +65,13 @@ ok(
|
||||
!settingsSource.includes('import "./CompactRatioSettings.css"'),
|
||||
"Settings CSS stays in the lazy production entry without breaking direct module tests",
|
||||
);
|
||||
ok(
|
||||
remoteWizardEntrySource.includes('import "./RemoteConnectWizard.css"') &&
|
||||
remoteWizardEntrySource.includes('from "./RemoteConnectWizard"') &&
|
||||
projectCreationSource.includes('import("./RemoteConnectWizardEntry")') &&
|
||||
!remoteWizardSource.includes('import "./RemoteConnectWizard.css"'),
|
||||
"Remote wizard CSS stays in its lazy production entry without breaking direct module tests",
|
||||
);
|
||||
ok(
|
||||
!appSource.includes("openAllHistory") &&
|
||||
!appSource.includes("cmd-history") &&
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { activeWorkBusyNoticeText } from "../lib/capabilityMutations";
|
||||
import { t } from "../lib/i18n";
|
||||
import { effortSwitchNoticeText, modelSwitchNoticeText } from "../lib/useController";
|
||||
import { effortSwitchNoticeText, modelSwitchNoticeText } from "../lib/controllerSwitchNotices";
|
||||
|
||||
function ok(value: unknown, message: string) {
|
||||
if (!value) throw new Error(message);
|
||||
|
||||
@@ -82,6 +82,28 @@ console.log("\nturn checkpoint submission binding");
|
||||
eq(checkpoint(state, "u11"), 1, "the next normal turn maps independently after a no-TurnDone command");
|
||||
}
|
||||
|
||||
{
|
||||
const current = reducer(initialState, {
|
||||
type: "history_replace",
|
||||
items: [{ kind: "user", id: "rev-12", text: "new" }],
|
||||
startTurn: 0,
|
||||
totalTurns: 1,
|
||||
hasOlder: false,
|
||||
revision: 12,
|
||||
digest: "digest-12",
|
||||
});
|
||||
const stale = reducer(current, {
|
||||
type: "history_replace",
|
||||
items: [{ kind: "user", id: "rev-11", text: "stale" }],
|
||||
startTurn: 0,
|
||||
totalTurns: 1,
|
||||
hasOlder: false,
|
||||
revision: 11,
|
||||
digest: "digest-11",
|
||||
});
|
||||
eq(stale, current, "an older history revision cannot replace the current transcript projection");
|
||||
}
|
||||
|
||||
{
|
||||
let state = submit(initialState, "activity A", 15);
|
||||
state = reducer(state, { type: "unsend" });
|
||||
|
||||
@@ -271,7 +271,7 @@ console.log("\ncomposer image capability");
|
||||
});
|
||||
|
||||
eq(sent.length, 1, "switching to a text-only model still sends the image ref for tool use");
|
||||
ok(toastText().includes("will not receive images directly"), "text-only send warns about direct image input without blocking");
|
||||
ok(toastText().includes("image-understanding model") || toastText().includes("图片理解模型"), "text-only send points to the image-understanding setting");
|
||||
eq(document.querySelector(".composer__prompt") === null, true, "image-input warning does not render inside the composer layout");
|
||||
ok(sent[0]?.submit?.includes("@.reasonix/attachments/mock.png") === true, "submitted text retains the local image attachment ref");
|
||||
|
||||
@@ -281,6 +281,39 @@ console.log("\ncomposer image capability");
|
||||
dom.window.close();
|
||||
}
|
||||
|
||||
{
|
||||
const dom = installDom();
|
||||
const sent: string[] = [];
|
||||
installBridgeApp({
|
||||
SavePastedImage: async () => ".reasonix/attachments/mock.png",
|
||||
AttachmentDataURL: async () => "data:image/png;base64,iVBORw0KGgo=",
|
||||
});
|
||||
const { root } = await renderComposer({
|
||||
imageInputEnabled: false,
|
||||
imageUnderstandingEnabled: true,
|
||||
onSend: (display) => sent.push(display),
|
||||
});
|
||||
const file = new File(["img"], "photo.png", { type: "image/png", lastModified: 1 });
|
||||
|
||||
await act(async () => {
|
||||
textarea().dispatchEvent(imagePasteEvent(file));
|
||||
await flushTimers();
|
||||
await flushTimers();
|
||||
});
|
||||
await waitFor(() => contextItemCount() === 1);
|
||||
await act(async () => {
|
||||
sendButton().click();
|
||||
await flushTimers();
|
||||
});
|
||||
eq(sent.length, 1, "configured image-understanding fallback still sends the image turn");
|
||||
eq(toastText(), "", "configured image-understanding fallback suppresses the obsolete warning");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
dom.window.close();
|
||||
}
|
||||
|
||||
{
|
||||
const dom = installDom();
|
||||
installBridgeApp({
|
||||
|
||||
@@ -160,6 +160,9 @@ console.log("diagnostics settings page");
|
||||
ok(calls[0] === false, "initial load must request static report (includeSessionRuntime=false)");
|
||||
ok((rootEl.textContent || "").includes("skill.missing_description"), "warnings must render");
|
||||
ok(rootEl.querySelector(".diag-summary"), "health summary must render");
|
||||
const frontendToggle = rootEl.querySelector('[data-testid="frontend-diagnostics-settings"] [role="switch"]');
|
||||
ok(frontendToggle, "frontend diagnostics switch must be visible in the production diagnostics settings page");
|
||||
ok(frontendToggle?.getAttribute("aria-checked") === "false", "frontend diagnostics switch starts off");
|
||||
|
||||
const runtimeToggle = rootEl.querySelector('input[type="checkbox"]') as HTMLInputElement | null;
|
||||
ok(runtimeToggle, "runtime toggle must exist");
|
||||
|
||||
@@ -3,18 +3,18 @@ import { en } from "../locales/en";
|
||||
import { zh } from "../locales/zh";
|
||||
import { zhTW } from "../locales/zh-TW";
|
||||
|
||||
assert.equal(en["notice.deliveryIncompleteTitle"], "Task is not complete");
|
||||
assert.match(en["notice.deliveryIncompleteBody"], /task work or checks that are incomplete/);
|
||||
assert.equal(en["notice.deliveryIncompleteTitle"], "Delivery checks are not complete");
|
||||
assert.equal(en["notice.deliveryIncompleteBody"], "The response was generated, but verification and review still need to be completed.");
|
||||
assert.equal(en["notice.deliveryIncompleteContinue"], "Continue checks");
|
||||
assert.equal(en["notice.deliveryRequirementTask"], "remaining implementation work");
|
||||
|
||||
assert.equal(zh["notice.deliveryIncompleteTitle"], "任务尚未完成");
|
||||
assert.match(zh["notice.deliveryIncompleteBody"], /任务或检查未完成/);
|
||||
assert.equal(zh["notice.deliveryIncompleteTitle"], "交付检查尚未完成");
|
||||
assert.equal(zh["notice.deliveryIncompleteBody"], "内容已经生成,但验证和复审步骤尚未完成。");
|
||||
assert.equal(zh["notice.deliveryIncompleteContinue"], "继续检查");
|
||||
assert.equal(zh["notice.deliveryRequirementTask"], "剩余实施工作");
|
||||
|
||||
assert.equal(zhTW["notice.deliveryIncompleteTitle"], "任務尚未完成");
|
||||
assert.match(zhTW["notice.deliveryIncompleteBody"], /任務或檢查未完成/);
|
||||
assert.equal(zhTW["notice.deliveryIncompleteTitle"], "交付檢查尚未完成");
|
||||
assert.equal(zhTW["notice.deliveryIncompleteBody"], "內容已經產生,但驗證和複審步驟尚未完成。");
|
||||
assert.equal(zhTW["notice.deliveryIncompleteContinue"], "繼續檢查");
|
||||
assert.equal(zhTW["notice.deliveryRequirementTask"], "剩餘實作工作");
|
||||
|
||||
|
||||
@@ -77,7 +77,7 @@ eq(finalDeclaration(".slashmenu", "max-height"), "min(360px, var(--composer-menu
|
||||
// AC4 — App.tsx applies footer--decision exactly when a decision surface owns
|
||||
// the footer, and the pre-existing footer--compact wiring stays intact.
|
||||
eq(/visibleDecisionSurface \? "footer--decision" : ""/.test(appSource), true, "App.tsx toggles footer--decision on the visible decision surface");
|
||||
eq(/terminalPanelOpen && !sidebarCreation \? "footer--compact" : ""/.test(appSource), true, "footer--compact wiring stays intact");
|
||||
eq(/terminalSurfaceOpen && !sidebarCreation \? "footer--compact" : ""/.test(appSource), true, "footer--compact wiring stays intact");
|
||||
eq(/ref=\{composerWrapRef\}/.test(composerSource), true, "Composer measures the real menu anchor");
|
||||
eq(/observeComposerMenuViewport\(anchor\)/.test(composerSource), true, "Composer observes geometry while a menu is open");
|
||||
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
analyzeFrontendDiagnosticAnomalies,
|
||||
createFrontendDiagnostics,
|
||||
isFrontendDiagnosticsBuild,
|
||||
} from "../lib/frontendDiagnostics";
|
||||
|
||||
let now = 10_000;
|
||||
const diagnostics = createFrontendDiagnostics({
|
||||
maxEvents: 32,
|
||||
now: () => now,
|
||||
randomID: () => "0123456789abcdef0123456789abcdef",
|
||||
environment: () => ({
|
||||
buildCommit: "test-commit",
|
||||
buildChannel: "canary",
|
||||
platform: "windows",
|
||||
userAgent: "Reasonix test",
|
||||
devicePixelRatio: 1.25,
|
||||
viewportWidth: 1440,
|
||||
viewportHeight: 900,
|
||||
language: "zh-CN",
|
||||
reducedMotion: false,
|
||||
}),
|
||||
});
|
||||
|
||||
diagnostics.record("app", "app.surface", { tabId: "private-tab", transcriptText: "PRIVATE_TEXT" });
|
||||
assert.equal(diagnostics.getSnapshot().eventCount, 0, "idle recorder ignores events");
|
||||
|
||||
diagnostics.start();
|
||||
now += 10;
|
||||
diagnostics.record("browser", "click", {
|
||||
target: "button",
|
||||
path: "C:\\Users\\private-user\\secret",
|
||||
token: "PRIVATE_TOKEN",
|
||||
text: "PRIVATE_TEXT",
|
||||
});
|
||||
now += 10;
|
||||
diagnostics.record("transcript", "transcript.row-measure", {
|
||||
rowIndex: 44,
|
||||
rowKind: "answer",
|
||||
layoutVersion: "1:42",
|
||||
layoutVariant: "text-flow",
|
||||
estimateSource: "static",
|
||||
estimatedSize: 1_800,
|
||||
measuredSize: 420,
|
||||
sizeDelta: -1_380,
|
||||
contentRevision: 3,
|
||||
});
|
||||
diagnostics.record("history", "history.items-patch", {
|
||||
patchCount: 2,
|
||||
contentRevision: 4,
|
||||
});
|
||||
diagnostics.record("transcript", "transcript.scroll-write", {
|
||||
source: "recovery-end",
|
||||
owner: "recovery",
|
||||
writeKind: "scrollToIndex",
|
||||
targetIndex: "LAST",
|
||||
targetTop: 720,
|
||||
previousMode: "manual",
|
||||
canClaimTail: false,
|
||||
tailCommand: true,
|
||||
});
|
||||
diagnostics.mark();
|
||||
for (let index = 0; index < 40; index += 1) {
|
||||
now += 1;
|
||||
diagnostics.record("browser", "scroll", { scrollTop: index * 10, scrollHeight: 2_400, clientHeight: 800 });
|
||||
}
|
||||
diagnostics.record("transcript", "transcript.scroll-write", {
|
||||
source: "recovery-end",
|
||||
owner: "recovery",
|
||||
writeKind: "scrollToIndex",
|
||||
targetIndex: "LAST",
|
||||
targetTop: 720,
|
||||
});
|
||||
diagnostics.record("transcript", "transcript.row-measure", {
|
||||
layoutVersion: "1:42",
|
||||
layoutVariant: "text-flow",
|
||||
estimateSource: "static",
|
||||
measuredSize: 420,
|
||||
});
|
||||
diagnostics.record("history", "history.items-patch", { patchCount: 2, contentRevision: 4 });
|
||||
diagnostics.record("workspace", "workspace.session-directory", {
|
||||
workspaceSessions: 7,
|
||||
visibleSessions: 5,
|
||||
hiddenSessions: 2,
|
||||
hiddenByCollapsed: 1,
|
||||
hiddenByTruncation: 1,
|
||||
runtimeOnlySessions: 1,
|
||||
recoveryCopies: 2,
|
||||
changeReason: "recovery-copy",
|
||||
workspaceRoot: "C:\\Users\\private-user\\workspace",
|
||||
topicId: "private-topic-id",
|
||||
label: "PRIVATE_TITLE",
|
||||
});
|
||||
|
||||
const active = diagnostics.getSnapshot();
|
||||
assert.equal(active.status, "recording");
|
||||
assert.equal(active.eventCount, 32, "ring buffer remains bounded");
|
||||
assert.ok(active.droppedEventCount > 0, "overflow is visible in the summary");
|
||||
assert.equal(active.markerCount, 0, "evicted markers are not counted as retained");
|
||||
|
||||
const payload = diagnostics.stop();
|
||||
assert.equal(payload.schemaVersion, 1);
|
||||
assert.equal(payload.manifest.platform, "windows");
|
||||
assert.equal(payload.events[payload.events.length - 1]?.type, "stop");
|
||||
const serialized = JSON.stringify(payload);
|
||||
assert.equal(serialized.includes("scrollToIndex"), true, "scroll writer details remain available for analysis");
|
||||
assert.equal(serialized.includes("targetIndex"), true, "scroll target remains available for analysis");
|
||||
assert.equal(serialized.includes("recovery-end"), true, "scroll transition source remains available for analysis");
|
||||
assert.equal(serialized.includes("history.items-patch"), true, "history patch event remains available for analysis");
|
||||
assert.equal(serialized.includes("layoutVersion"), true, "row layout version remains available for analysis");
|
||||
assert.equal(serialized.includes("layoutVariant"), true, "row layout variant remains available for analysis");
|
||||
assert.equal(serialized.includes("estimateSource"), true, "height estimate source remains available for analysis");
|
||||
assert.equal(serialized.includes("workspace.session-directory"), true, "sidebar directory event remains available for analysis");
|
||||
assert.equal(serialized.includes("workspaceSessions"), true, "sidebar session counts remain available for analysis");
|
||||
assert.ok(Array.isArray(payload.summary.anomalies), "payload includes automatic anomaly analysis");
|
||||
for (const forbidden of ["PRIVATE_TEXT", "PRIVATE_TOKEN", "private-user", "private-tab", "path", "token", "tabId"]) {
|
||||
assert.equal(serialized.includes(forbidden), false, `payload excludes ${forbidden}`);
|
||||
}
|
||||
|
||||
const frozenCount = diagnostics.getSnapshot().eventCount;
|
||||
diagnostics.record("browser", "scroll", { scrollTop: 999 });
|
||||
assert.equal(diagnostics.getSnapshot().eventCount, frozenCount, "stopped recorder remains frozen");
|
||||
|
||||
assert.equal(isFrontendDiagnosticsBuild("test", false), true);
|
||||
assert.equal(isFrontendDiagnosticsBuild("canary", false), true);
|
||||
assert.equal(isFrontendDiagnosticsBuild("preview", false), true);
|
||||
assert.equal(isFrontendDiagnosticsBuild("stable", true), true);
|
||||
assert.equal(isFrontendDiagnosticsBuild("stable", false), false);
|
||||
|
||||
assert.deepEqual(analyzeFrontendDiagnosticAnomalies([
|
||||
{ t: 0, type: "navigation.begin", intent: 7 },
|
||||
{ t: 1, type: "workspace.session-directory", workspaceSessions: 8 },
|
||||
{ t: 2, type: "workspace.session-directory", workspaceSessions: 7 },
|
||||
{ t: 3, type: "navigation.settle", intent: 7 },
|
||||
{ t: 4, type: "history.older-request", trigger: "viewport-user" },
|
||||
{ t: 5, type: "transcript.scroll-write", owner: "unknown-writer" },
|
||||
]), [
|
||||
{ code: "settle-before-paint-ready", intent: 7 },
|
||||
{ code: "navigation-session-count-changed", intent: 7, count: 2 },
|
||||
{ code: "viewport-older-without-user-input", count: 1 },
|
||||
{ code: "unknown-scroll-writer", count: 1 },
|
||||
], "automatic analysis reports ordering, paging, catalog, and writer anomalies");
|
||||
|
||||
assert.deepEqual(analyzeFrontendDiagnosticAnomalies([
|
||||
{ t: 0, type: "history.viewport-permit" },
|
||||
{ t: 1, type: "history.older-request", trigger: "viewport-user" },
|
||||
]), [], "a viewport page request consumes one explicit user permit without a false anomaly");
|
||||
assert.deepEqual(analyzeFrontendDiagnosticAnomalies([
|
||||
{ t: 0, type: "navigation.begin", intent: 9 },
|
||||
{ t: 1, type: "navigation.settle", intent: 9, outcome: "failed" },
|
||||
]), [], "a failed data terminal may release its mask without a paint-ready false positive");
|
||||
|
||||
console.log("frontend diagnostics tests passed");
|
||||
@@ -10,7 +10,8 @@ import { createRoot } from "react-dom/client";
|
||||
import type { AppBindings } from "../lib/bridge";
|
||||
import { activateGoalAndSubmitOnTab } from "../lib/goalSubmit";
|
||||
import { useController } from "../lib/useController";
|
||||
import type { BalanceInfo, CheckpointMeta, ContextInfo, EffortInfo, HistoryMessage, JobView, Meta, TabMeta } from "../lib/types";
|
||||
import { historySliceFromMessages } from "./mockHistorySlice";
|
||||
import type { BalanceInfo, CheckpointMeta, ContextInfo, EffortInfo, HistoryMessage, HistorySliceRequest, JobView, Meta, TabMeta } from "../lib/types";
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
@@ -133,6 +134,8 @@ window.go = {
|
||||
JobsForTab: async () => jobs,
|
||||
CheckpointsForTab: async () => checkpoints,
|
||||
HistoryForTab: async (): Promise<HistoryMessage[]> => [],
|
||||
HistorySliceForTab: async (tabId: string, request: HistorySliceRequest) =>
|
||||
historySliceFromMessages(tabId, [], request),
|
||||
HistoryPageForTab: async () => ({ messages: [], startTurn: 0, endTurn: 0, totalTurns: 0, hasOlder: false }),
|
||||
HistoryCheckpointTurnsForTab: async () => [],
|
||||
ReplayPendingPrompts: async () => {},
|
||||
@@ -201,23 +204,27 @@ await waitForActive("tab-a");
|
||||
eq(controller?.activeTabId, "tab-a", "harness starts on tab A");
|
||||
|
||||
const sourceTabId = "tab-a";
|
||||
const pending = activateGoalAndSubmitOnTab({
|
||||
tabId: sourceTabId,
|
||||
displayText: "Cross-tab safe goal",
|
||||
submitText: "/ui-ux-pro-max Cross-tab safe goal",
|
||||
structured: {
|
||||
display: "/ui-ux-pro-max Cross-tab safe goal",
|
||||
input: "Cross-tab safe goal",
|
||||
invocations: [{ name: "ui-ux-pro-max", kind: "skill", offset: 0 }],
|
||||
},
|
||||
sendToTab: (tabId, goal, display, submit, structured) => {
|
||||
if (!controller) throw new Error("controller missing");
|
||||
return controller.sendToTab(tabId, display, submit, undefined, structured, {
|
||||
goal,
|
||||
collaborationMode: "normal",
|
||||
toolApprovalMode: "ask",
|
||||
});
|
||||
},
|
||||
let pending!: Promise<void>;
|
||||
await act(async () => {
|
||||
pending = activateGoalAndSubmitOnTab({
|
||||
tabId: sourceTabId,
|
||||
displayText: "Cross-tab safe goal",
|
||||
submitText: "/ui-ux-pro-max Cross-tab safe goal",
|
||||
structured: {
|
||||
display: "/ui-ux-pro-max Cross-tab safe goal",
|
||||
input: "Cross-tab safe goal",
|
||||
invocations: [{ name: "ui-ux-pro-max", kind: "skill", offset: 0 }],
|
||||
},
|
||||
sendToTab: (tabId, goal, display, submit, structured) => {
|
||||
if (!controller) throw new Error("controller missing");
|
||||
return controller.sendToTab(tabId, display, submit, undefined, structured, {
|
||||
goal,
|
||||
collaborationMode: "normal",
|
||||
toolApprovalMode: "ask",
|
||||
});
|
||||
},
|
||||
});
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
// While the atomic bridge call for A is suspended, the UI switches to tab B.
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { act } from "react";
|
||||
|
||||
export function installTranscriptRaceClock(targetWindow: Window) {
|
||||
let clockNow = 10_000;
|
||||
let nextTimer = 1;
|
||||
const timers = new Map<number, { dueAt: number; run: () => void }>();
|
||||
const originalDateNow = Date.now;
|
||||
const originalSetTimeout = targetWindow.setTimeout;
|
||||
const originalClearTimeout = targetWindow.clearTimeout;
|
||||
Date.now = () => clockNow;
|
||||
targetWindow.setTimeout = ((handler: TimerHandler, timeout = 0, ...args: unknown[]) => {
|
||||
const id = nextTimer++;
|
||||
const run = typeof handler === "function"
|
||||
? () => handler(...args)
|
||||
: () => { throw new Error("string timer handlers are unsupported in this test"); };
|
||||
timers.set(id, { dueAt: clockNow + Math.max(0, timeout), run });
|
||||
return id;
|
||||
}) as typeof targetWindow.setTimeout;
|
||||
targetWindow.clearTimeout = ((id: number | undefined) => {
|
||||
if (id !== undefined) timers.delete(id);
|
||||
}) as typeof targetWindow.clearTimeout;
|
||||
|
||||
const advanceClock = async (milliseconds: number) => {
|
||||
await act(async () => {
|
||||
const target = clockNow + milliseconds;
|
||||
while (true) {
|
||||
const next = [...timers.entries()]
|
||||
.filter(([, timer]) => timer.dueAt <= target)
|
||||
.sort(([leftID, left], [rightID, right]) => left.dueAt - right.dueAt || leftID - rightID)[0];
|
||||
if (!next) break;
|
||||
const [id, timer] = next;
|
||||
timers.delete(id);
|
||||
clockNow = timer.dueAt;
|
||||
timer.run();
|
||||
}
|
||||
clockNow = target;
|
||||
});
|
||||
};
|
||||
const restore = () => {
|
||||
Date.now = originalDateNow;
|
||||
targetWindow.setTimeout = originalSetTimeout;
|
||||
targetWindow.clearTimeout = originalClearTimeout;
|
||||
};
|
||||
return { advanceClock, restore };
|
||||
}
|
||||
@@ -18,7 +18,7 @@ assert.match(controller, /throw new Error\(t\("history\.failedLoadHistory"\)\)/,
|
||||
assert.match(controller, /retrySessionHistory/, "retry path is exported");
|
||||
assert.match(
|
||||
transcript,
|
||||
/if \(!virtuosoReadyRef\.current \|\| !stick\.current\) return;\n followGrowingTail\(\);\n \}, \[footerHeight, followGrowingTail, stick\]\);/,
|
||||
/if \(hydrating \|\| !virtuosoReadyRef\.current \|\| !stick\.current\) return;\n followGrowingTail\(\);\n \}, \[footerHeight, followGrowingTail, hydrating, stick\]\);/,
|
||||
"footer resize is routed through the coalesced tail-follow path without depending on item appends",
|
||||
);
|
||||
assert.match(controller, /shouldPreferResidentHistory\(resetSurface, options\.preserveCachedHistory\)/, "retry hydrates fetch instead of serving the resident snapshot");
|
||||
|
||||
@@ -158,5 +158,17 @@ eq(archivedError[0]?.kind === "tool" && archivedError[0].status, "error", "archi
|
||||
eq(archivedError[0]?.kind === "tool" && archivedError[0].dataArchived, true, "archived failed result is still loadable on demand");
|
||||
eq(archivedError[0]?.kind === "tool" && archivedError[0].error, "error: permission denied", "archived failed result keeps bounded error preview");
|
||||
|
||||
const localOnlyDupes = toolItems([
|
||||
{ role: "user", content: "first" },
|
||||
{ role: "tool", toolCallId: "__reasonix_local_only__", toolName: "__reasonix_local_only__", content: "partial one" },
|
||||
{ role: "user", content: "second" },
|
||||
{ role: "tool", toolCallId: "__reasonix_local_only__", toolName: "__reasonix_local_only__", content: "partial two" },
|
||||
] as HistoryMessage[]);
|
||||
eq(localOnlyDupes.length, 2, "each interrupted-turn local-only record hydrates as its own tool item");
|
||||
eq(
|
||||
localOnlyDupes[0]?.id !== localOnlyDupes[1]?.id,
|
||||
true,
|
||||
"repeated __reasonix_local_only__ toolCallIds uniquify so transcript row keys cannot collide after restart",
|
||||
);
|
||||
console.log(`\n${passed} passed, ${failed} failed`);
|
||||
if (failed > 0) process.exit(1);
|
||||
@@ -0,0 +1,17 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
const styles = readFileSync(new URL("../styles.css", import.meta.url), "utf8");
|
||||
const packageJSON = JSON.parse(readFileSync(new URL("../../package.json", import.meta.url), "utf8"));
|
||||
|
||||
const boxedRule = styles.match(/\.md\s+\.katex\s+\.fbox\s*\{([^}]*)\}/s)?.[1] ?? "";
|
||||
if (!/width:\s*100%\s*;/.test(boxedRule)) {
|
||||
throw new Error("KaTeX boxed frame must keep a standards-compatible width fallback");
|
||||
}
|
||||
if (!/width:\s*-webkit-fill-available\s*;/.test(boxedRule)) {
|
||||
throw new Error("KaTeX boxed frame must fill the containing row in WebKit");
|
||||
}
|
||||
if (packageJSON.packageManager !== "[email protected]") {
|
||||
throw new Error(`frontend package manager pin drifted: ${packageJSON.packageManager ?? "missing"}`);
|
||||
}
|
||||
|
||||
console.log("katex boxed CSS/package contract: PASS");
|
||||
@@ -202,6 +202,53 @@ console.log("\nmarkdown history rendering");
|
||||
await act(async () => root4.unmount());
|
||||
}
|
||||
|
||||
// ── a worker result cannot replace the reader's mid-document fallback ────────
|
||||
{
|
||||
rootEl.className = "transcript";
|
||||
rootEl.scrollTop = 400;
|
||||
Object.defineProperty(rootEl, "scrollHeight", { configurable: true, value: 1_000 });
|
||||
Object.defineProperty(rootEl, "clientHeight", { configurable: true, value: 300 });
|
||||
(globalThis as { Worker?: unknown }).Worker = class {};
|
||||
let resolveParse: ((result: ReturnType<typeof parseMarkdown>) => void) | null = null;
|
||||
const deferred = new MarkdownWorkerClient({
|
||||
createWorker: () => Promise.resolve({
|
||||
onmessage: null,
|
||||
onerror: null,
|
||||
postMessage(request) {
|
||||
resolveParse = (result) => {
|
||||
const message = { data: { id: request.id, result } };
|
||||
(this.onmessage as ((event: unknown) => void) | null)?.(message);
|
||||
};
|
||||
},
|
||||
terminate() {},
|
||||
}),
|
||||
});
|
||||
setMarkdownWorkerClientForTest(deferred);
|
||||
const root5 = createRoot(rootEl);
|
||||
const text = Array.from({ length: 60 }, (_, index) => `# Anchor ${index + 1}\n\nBody ${index + 1}.`).join("\n\n");
|
||||
await act(async () => {
|
||||
root5.render(<MarkdownHistory text={text} entryId="md-history-mid-read" fallback={<div className="md">{text}</div>} />);
|
||||
});
|
||||
await act(async () => {
|
||||
resolveParse?.(parseMarkdown(text));
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
ok(!rootEl.querySelector(".md[data-markdown-blocks]"), "worker completion keeps the full fallback while the reader is away from bottom");
|
||||
ok(rootEl.textContent?.includes("Anchor 1"), "the reader's prefix remains mounted during the deferred handoff");
|
||||
|
||||
rootEl.scrollTop = 700;
|
||||
await act(async () => {
|
||||
rootEl.dispatchEvent(new dom.window.Event("scroll"));
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
ok(rootEl.querySelector('.md[data-markdown-blocks="120"]'), "returning to bottom commits the cached parsed blocks");
|
||||
ok(rootEl.textContent?.includes("Anchor 60"), "the parsed tail is visible after the safe handoff");
|
||||
await act(async () => root5.unmount());
|
||||
rootEl.className = "";
|
||||
delete (globalThis as { Worker?: unknown }).Worker;
|
||||
setMarkdownWorkerClientForTest(newSpyClient());
|
||||
}
|
||||
|
||||
// ── progressive mounting for huge documents ──────────────────────────────────
|
||||
{
|
||||
rootEl.className = "transcript";
|
||||
@@ -219,13 +266,13 @@ console.log("\nmarkdown history rendering");
|
||||
return { top, bottom: top + height, left: 0, right: 0, width: 0, height, x: 0, y: top, toJSON: () => ({}) };
|
||||
};
|
||||
const text = Array.from({ length: 420 }, (_, i) => `Paragraph ${i} with some *content*.`).join("\n\n");
|
||||
const root5 = createRoot(rootEl);
|
||||
const root6 = createRoot(rootEl);
|
||||
const writeTranscriptOffset = (_owner: "block-window-prepend", top: number) => {
|
||||
rootEl.scrollTop = top;
|
||||
return true;
|
||||
};
|
||||
await act(async () => {
|
||||
root5.render(
|
||||
root6.render(
|
||||
<TranscriptScrollWriteProvider value={writeTranscriptOffset}>
|
||||
<MarkdownHistory text={text} entryId="md-history-huge" fallback={null} />
|
||||
</TranscriptScrollWriteProvider>,
|
||||
@@ -276,7 +323,7 @@ console.log("\nmarkdown history rendering");
|
||||
|
||||
const replacement = Array.from({ length: 420 }, (_, i) => `Replacement ${i} with some *content*.`).join("\n\n");
|
||||
await act(async () => {
|
||||
root5.render(
|
||||
root6.render(
|
||||
<TranscriptScrollWriteProvider value={writeTranscriptOffset}>
|
||||
<MarkdownHistory text={replacement} entryId="md-history-huge-replacement" fallback={null} />
|
||||
</TranscriptScrollWriteProvider>,
|
||||
@@ -288,7 +335,7 @@ console.log("\nmarkdown history rendering");
|
||||
ok(rootEl.textContent?.includes("Replacement 419"), "the replacement's newest block mounts immediately");
|
||||
await intersectSentinel("[data-markdown-older-sentinel]", true);
|
||||
eq(rootEl.querySelector(".md[data-markdown-blocks]")?.getAttribute("data-markdown-visible-blocks"), "120", "a replacement document re-arms viewport paging");
|
||||
await act(async () => root5.unmount());
|
||||
await act(async () => root6.unmount());
|
||||
dom.window.HTMLElement.prototype.getBoundingClientRect = originalRect;
|
||||
rootEl.className = "";
|
||||
}
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
// Run: tsx src/__tests__/navigation-surface-transition.test.ts
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
import { guardBackendNavigationResult, settleNavigationSurfaceIntent } from "../lib/navigationSurfaceTransition";
|
||||
import {
|
||||
advanceSurfacePaintCommit,
|
||||
beginNavigationSurfaceState,
|
||||
guardBackendNavigationResult,
|
||||
markNavigationTargetMasked,
|
||||
settleNavigationSurfaceIntent,
|
||||
settleNavigationSurfaceState,
|
||||
} from "../lib/navigationSurfaceTransition";
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
@@ -24,6 +31,42 @@ ok(active === 3, "coalesced B completion cannot release C's mask");
|
||||
active = settleNavigationSurfaceIntent(active, 3);
|
||||
ok(active === null, "the latest completion releases its own mask");
|
||||
|
||||
let surface = beginNavigationSurfaceState(9);
|
||||
surface = markNavigationTargetMasked(surface, 8);
|
||||
ok(surface?.phase === "source-retained", "a stale request cannot replace the retained source");
|
||||
surface = markNavigationTargetMasked(surface, 9, "tab-target");
|
||||
ok(surface?.phase === "target-masked", "the latest request mounts its target under the mask");
|
||||
surface = settleNavigationSurfaceState(surface, 8);
|
||||
ok(surface?.intent === 9, "a stale paint terminal cannot release the latest mask");
|
||||
surface = settleNavigationSurfaceState(surface, 9);
|
||||
ok(surface === null, "the matching paint terminal releases the mask");
|
||||
|
||||
let paint = advanceSurfacePaintCommit({ attempts: 0, stableFrames: 0 }, {
|
||||
rendered: true, placementReady: true, geometryReady: true, geometryKey: "755:1200:445",
|
||||
});
|
||||
ok(paint.outcome === undefined, "one paint frame cannot expose the target");
|
||||
paint = advanceSurfacePaintCommit(paint.progress, {
|
||||
rendered: true, placementReady: true, geometryReady: true, geometryKey: "859:1200:341",
|
||||
});
|
||||
ok(paint.outcome === undefined, "a changed viewport geometry restarts the stability gate");
|
||||
paint = advanceSurfacePaintCommit(paint.progress, {
|
||||
rendered: true, placementReady: true, geometryReady: true, geometryKey: "859:1200:341",
|
||||
});
|
||||
ok(paint.outcome === "ready", "two stable geometry frames commit the target");
|
||||
let stalled = { attempts: 0, stableFrames: 0 };
|
||||
let recoveryRequests = 0;
|
||||
let degraded: string | undefined;
|
||||
for (let frame = 0; frame < 180; frame += 1) {
|
||||
const decision = advanceSurfacePaintCommit(stalled, {
|
||||
rendered: true, placementReady: false, geometryReady: false, geometryKey: "755:0:0",
|
||||
});
|
||||
stalled = decision.progress;
|
||||
if (decision.requestRecovery) recoveryRequests += 1;
|
||||
degraded = decision.outcome;
|
||||
}
|
||||
ok(recoveryRequests === 2, "stalled placement receives two bounded recovery opportunities");
|
||||
ok(degraded === "degraded", "the second failed placement terminates without permanent loading");
|
||||
|
||||
let reasserted = "";
|
||||
const currentAccepted = await guardBackendNavigationResult({
|
||||
intent: 4,
|
||||
@@ -56,13 +99,21 @@ ok(await staleAcceptedPromise === false, "a stale backend-activating result is r
|
||||
ok(reasserted === "tab.reveal-background:tab-stale", "stale reassertion receives the mutating target identity");
|
||||
|
||||
const appSource = readFileSync(new URL("../App.tsx", import.meta.url), "utf8");
|
||||
ok(appSource.includes("flushSync(() => setNavigationSurfaceIntent(intent))"), "navigation masking commits synchronously before the Wails await");
|
||||
ok(appSource.includes("items={runtimeTransitioning ? [] : displayItems}"), "App removes source transcript rows during navigation");
|
||||
const surfaceHookSource = readFileSync(new URL("../lib/useNavigationSurface.ts", import.meta.url), "utf8");
|
||||
const stylesSource = readFileSync(new URL("../styles.css", import.meta.url), "utf8");
|
||||
ok(surfaceHookSource.includes("flushSync(() => {"), "navigation masking commits synchronously before the Wails await");
|
||||
ok(surfaceHookSource.includes("setPreserved(rendered?.items.length ? rendered : null)"), "the last stable transcript is retained during navigation");
|
||||
ok(appSource.includes("items={visibleTranscriptItems}"), "the visible transcript is decoupled from the hydrating target");
|
||||
ok(appSource.includes("transcript-navigation-overlay"), "navigation renders a blocking transcript overlay");
|
||||
ok(/\.transcript-navigation-overlay\s*\{[\s\S]*?background:\s*var\(--chat-bg, var\(--bg\)\)/.test(stylesSource), "the navigation overlay is opaque while target rows settle");
|
||||
ok(appSource.includes("live={runtimeTransitioning ? undefined : state.live}"), "App removes source live output during navigation");
|
||||
ok(appSource.includes("hidden={composerSurfaceHidden || undefined}"), "App keeps the composer mounted but hidden during navigation");
|
||||
ok(appSource.includes("composer-decision-host--footprint-hidden"), "App preserves the composer footprint during navigation");
|
||||
ok(!appSource.includes("hidden={composerSurfaceHidden || undefined}"), "navigation no longer collapses the composer footprint");
|
||||
ok(appSource.includes("inert={composerSurfaceHidden ? true : undefined}"), "the hidden composer is inert during navigation");
|
||||
ok(appSource.includes("!runtimeTransitioning && showTodos"), "source-session Todo content is isolated");
|
||||
ok(appSource.includes("!runtimeTransitioning && rewindState"), "source-session rewind content is isolated");
|
||||
ok(appSource.includes("{showTodos && ("), "target Todo footprint is laid out below the mask");
|
||||
ok(appSource.includes("{rewindState && ("), "target rewind footprint is laid out below the mask");
|
||||
ok(/\.footer--navigation-hidden\s*\{[\s\S]*?visibility:\s*hidden;[\s\S]*?pointer-events:\s*none;/.test(stylesSource), "masked target footer cannot paint or receive input");
|
||||
ok(appSource.includes('style={navigationSurface?.phase === "source-retained"') && appSource.includes("const visibleDecisionSurface = decisionSurface"), "target-masked paint uses the target footer geometry");
|
||||
ok((appSource.match(/guardBackendNavigationResult\(\{/g) ?? []).length === 2, "both Reveal paths guard stale backend activation results");
|
||||
const switchFolderSource = appSource.slice(
|
||||
appSource.indexOf("const switchFolder = useCallback"),
|
||||
@@ -72,7 +123,8 @@ ok(switchFolderSource.includes("const navigationIntentSeq = noteNavigationIntent
|
||||
ok(switchFolderSource.includes("beginNavigationSurface(navigationIntentSeq)"), "workspace navigation masks the source surface before Wails");
|
||||
ok(switchFolderSource.includes("pickWorkspace(navigationIntentSeq)"), "folder-pick navigation carries the shared intent into the controller");
|
||||
ok(switchFolderSource.includes("switchWorkspace(path, navigationIntentSeq)"), "direct workspace navigation carries the shared intent into the controller");
|
||||
ok(switchFolderSource.includes("settleNavigationSurface(navigationIntentSeq)"), "workspace navigation compare-clears its surface mask");
|
||||
ok(switchFolderSource.includes("settleNavigationSurface(navigationIntentSeq)"), "workspace request completion advances the target under its surface mask");
|
||||
ok(surfaceHookSource.includes("navigation.paint-ready"), "surface settlement is diagnosed only from target paint readiness");
|
||||
|
||||
console.log(`\n${passed} passed, ${failed} failed`);
|
||||
if (failed > 0) process.exit(1);
|
||||
@@ -4,6 +4,7 @@ import { JSDOM } from "jsdom";
|
||||
import React, { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { initialState, reducer, useController, type Item } from "../lib/useController";
|
||||
import type { NavigationResult } from "../lib/navigationSurfaceTransition";
|
||||
import { historySliceFromMessages } from "./mockHistorySlice";
|
||||
import type { AppBindings } from "../lib/bridge";
|
||||
import type { BalanceInfo, CheckpointMeta, ContextInfo, EffortInfo, HistoryMessage, HistorySliceRequest, JobView, Meta, TabMeta, WireEvent } from "../lib/types";
|
||||
@@ -335,17 +336,21 @@ await act(async () => {
|
||||
|
||||
eq(controller?.state.items.length, 0, "stale history load cannot repopulate a new blank session");
|
||||
|
||||
let resumePromise: Promise<void> | undefined;
|
||||
let resumeNavigation: NavigationResult<void> | undefined;
|
||||
let resumeSurfaceSettled = false;
|
||||
await act(async () => {
|
||||
resumePromise = controller?.resumeSession("/sessions/restored.jsonl", "tab-a");
|
||||
resumeNavigation = controller?.resumeSession("/sessions/restored.jsonl", "tab-a");
|
||||
void resumeNavigation?.surfaceReady.then(() => { resumeSurfaceSettled = true; });
|
||||
await flushPromises();
|
||||
});
|
||||
eq(resumeSurfaceSettled, false, "Resume releases navigation acquisition before target history settles");
|
||||
eq(controller?.state.ask?.id, "pre-response-resume", "Resume accepts the new-epoch ask and rejects the interleaved old-epoch ask before returning");
|
||||
await act(async () => {
|
||||
resumeRPCGate.resolve();
|
||||
await resumePromise;
|
||||
await resumeNavigation?.surfaceReady;
|
||||
await flushPromises();
|
||||
});
|
||||
eq(resumeSurfaceSettled, true, "Resume surfaceReady resolves after authoritative history and reconciliation");
|
||||
eq(controller?.state.meta?.canonicalTodos?.[0]?.status, "completed", "resuming a session refreshes its authoritative canonical todo state");
|
||||
eq(controller?.state.ask?.id, "replayed-runtime-resumed", "Resume RPC ask emitted before return is restored after reset/history hydration");
|
||||
ok(promptReplayCalls > 0, "Resume completion performs a tab-scoped pending-prompt replay");
|
||||
@@ -356,15 +361,15 @@ await act(async () => {
|
||||
await flushPromises();
|
||||
});
|
||||
const replayCallsBeforeChannelOpen = promptReplayCalls;
|
||||
let channelPromise: Promise<void> | undefined;
|
||||
let channelNavigation: NavigationResult<void> | undefined;
|
||||
await act(async () => {
|
||||
channelPromise = controller?.openChannelSession("/sessions/channel.jsonl", "tab-a");
|
||||
channelNavigation = controller?.openChannelSession("/sessions/channel.jsonl", "tab-a");
|
||||
await flushPromises();
|
||||
});
|
||||
eq(controller?.state.ask?.id, "pre-response-channel", "channel-open accepts the new-epoch ask and rejects the interleaved old-epoch ask before returning");
|
||||
await act(async () => {
|
||||
channelRPCGate.resolve();
|
||||
await channelPromise;
|
||||
await channelNavigation?.surfaceReady;
|
||||
await flushPromises();
|
||||
});
|
||||
eq(controller?.state.ask?.id, "replayed-runtime-channel", "channel-open ask emitted before return is restored after reset/history hydration");
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
enqueueProjectTreeArchive,
|
||||
projectTreeSessionArchiveTargetKey,
|
||||
projectTreeTopicArchiveTargetKey,
|
||||
projectTreeTrashingTopics,
|
||||
runProjectTreeArchiveJob,
|
||||
} from "../lib/projectTreeArchive";
|
||||
import {
|
||||
invalidateProjectTreeTopicLoads,
|
||||
projectTreeFolderKeyForSession,
|
||||
projectTreeWithoutTopics,
|
||||
} from "../lib/projectTreeTopic";
|
||||
import type { ProjectNode } from "../lib/types";
|
||||
@@ -105,6 +108,7 @@ async function testPendingEndsOnlyAfterCanonicalReload() {
|
||||
let pending = true;
|
||||
const job = runProjectTreeArchiveJob({
|
||||
archive: async () => {},
|
||||
commit: () => {},
|
||||
reload: () => reloadGate.promise,
|
||||
finishPending: () => { pending = false; },
|
||||
recover: async () => {},
|
||||
@@ -118,19 +122,65 @@ async function testPendingEndsOnlyAfterCanonicalReload() {
|
||||
}
|
||||
|
||||
async function testFailedArchiveRestoresVisibilityBeforeRecoveryReload() {
|
||||
const backendGate = deferred<void>();
|
||||
let pending = true;
|
||||
let tombstones = new Set<string>();
|
||||
let recoveryObservedPending: boolean | null = null;
|
||||
const job = runProjectTreeArchiveJob({
|
||||
archive: async () => { throw new Error("busy"); },
|
||||
archive: () => backendGate.promise,
|
||||
commit: () => { tombstones = projectTreeTrashingTopics(tombstones, "topic-a", true); },
|
||||
reload: async () => {},
|
||||
finishPending: () => { pending = false; },
|
||||
recover: async () => { recoveryObservedPending = pending; },
|
||||
});
|
||||
|
||||
await Promise.resolve();
|
||||
assert.equal(pending, true);
|
||||
assert.equal(tombstones.has("topic-a"), false, "pending backend work must not hide the topic");
|
||||
assert.equal(projectTreeWithoutTopics(
|
||||
[{ key: "topic-a", kind: "topic", label: "A", topicId: "topic-a" }],
|
||||
tombstones,
|
||||
).length, 1);
|
||||
backendGate.reject(new Error("busy"));
|
||||
assert.equal(await job, false);
|
||||
assert.equal(tombstones.has("topic-a"), false, "rejected archives never commit a tombstone");
|
||||
assert.equal(recoveryObservedPending, false);
|
||||
}
|
||||
|
||||
function testArchiveTargetsDoNotCollideAcrossRows() {
|
||||
assert.notEqual(
|
||||
projectTreeTopicArchiveTargetKey("project", "/a", "shared"),
|
||||
projectTreeTopicArchiveTargetKey("project", "/b", "shared"),
|
||||
"same-id topics in different projects keep independent confirmations",
|
||||
);
|
||||
assert.notEqual(
|
||||
projectTreeSessionArchiveTargetKey("/a/one.jsonl"),
|
||||
projectTreeSessionArchiveTargetKey("/a/two.jsonl"),
|
||||
"sessions in one topic keep independent confirmations",
|
||||
);
|
||||
}
|
||||
|
||||
function testSessionArchiveReloadsItsOwningFolder() {
|
||||
const tree: ProjectNode[] = [{
|
||||
key: "project-a",
|
||||
kind: "project",
|
||||
label: "Project A",
|
||||
children: [{
|
||||
key: "topic-a",
|
||||
kind: "topic",
|
||||
label: "Topic A",
|
||||
children: [{
|
||||
key: "session-a",
|
||||
kind: "session",
|
||||
label: "Session A",
|
||||
sessionPath: " /sessions/a.jsonl ",
|
||||
}],
|
||||
}],
|
||||
}];
|
||||
assert.equal(projectTreeFolderKeyForSession(tree, "/sessions/a.jsonl"), "project-a");
|
||||
assert.equal(projectTreeFolderKeyForSession(tree, "/sessions/missing.jsonl"), "");
|
||||
}
|
||||
|
||||
function testProjectTreeWiresEveryRaceGuard() {
|
||||
const source = readFileSync(
|
||||
join(dirname(fileURLToPath(import.meta.url)), "../components/ProjectTree.tsx"),
|
||||
@@ -140,12 +190,24 @@ function testProjectTreeWiresEveryRaceGuard() {
|
||||
join(dirname(fileURLToPath(import.meta.url)), "../lib/projectTreeArchive.ts"),
|
||||
"utf8",
|
||||
);
|
||||
assert.match(archiveSource, /invalidateProjectTreeTopicLoads[\s\S]*optimisticallyRemoveTopic\(topicId\)/);
|
||||
const sessionMenuSource = readFileSync(
|
||||
join(dirname(fileURLToPath(import.meta.url)), "../components/ProjectTreeSessionArchiveMenu.tsx"),
|
||||
"utf8",
|
||||
);
|
||||
assert.match(archiveSource, /await archive\(\)[\s\S]*commit\(\)[\s\S]*await reload\(\)/);
|
||||
assert.match(archiveSource, /commitArchiveTombstone\(topicId\)[\s\S]*invalidateProjectTreeTopicLoads[\s\S]*optimisticallyRemoveTopic\(topicId\)/);
|
||||
assert.match(source, /projectTreeWithoutTopics\(asArray\(page\.items\), currentArchiveTombstones\(\)\)/);
|
||||
assert.match(archiveSource, /archiveQueueRef\.current\.catch[\s\S]*\.then\(async \(\) =>/);
|
||||
assert.match(archiveSource, /return previous\.catch\(\(\) => undefined\)\.then\(work\)/);
|
||||
assert.match(archiveSource, /pendingLoads = targets\.map[\s\S]*onReloadStarted[\s\S]*await Promise\.all\(pendingLoads\)/);
|
||||
assert.match(archiveSource, /onReloadStarted: \(\) => releaseArchiveTombstone\(topicId\)/);
|
||||
assert.match(archiveSource, /await refreshRef\.current\(reloadOptions\)[\s\S]*finally \{[\s\S]*endTrashingTopic\(topicId\)/);
|
||||
assert.match(archiveSource, /finishPending: \(\) => endTrashingTopic\(topicId\)/);
|
||||
assert.match(source, /const topicMenuOpen = menuNodeKey === key/);
|
||||
assert.match(source, /onContextMenu=\{openTopicMenu\}/);
|
||||
assert.match(source, /node\.sessionPath \?\? ""/);
|
||||
assert.match(sessionMenuSource, /disabled: !sessionPath \|\| blocked \|\| busy/);
|
||||
assert.match(source, /sessionPath=\{sessionPath\} blocked=\{archiveBlocked \|\| topicTrashing\}/);
|
||||
assert.match(source, /void trashSession\(sessionPath\)/);
|
||||
assert.match(archiveSource, /projectTreeFolderKeyForSession\(treeRef\.current, sessionPath\)[\s\S]*refreshRef\.current\(reloadOptions\)/);
|
||||
}
|
||||
|
||||
await testLatePreArchivePageCannotReinsertTopic();
|
||||
@@ -154,5 +216,7 @@ await testPostCommitRestoreCanWinWhilePendingIndicatorFinishes();
|
||||
await testConcurrentArchivesReachBackendSerially();
|
||||
await testPendingEndsOnlyAfterCanonicalReload();
|
||||
await testFailedArchiveRestoresVisibilityBeforeRecoveryReload();
|
||||
testArchiveTargetsDoNotCollideAcrossRows();
|
||||
testSessionArchiveReloadsItsOwningFolder();
|
||||
testProjectTreeWiresEveryRaceGuard();
|
||||
console.log("project tree archive race: 7 passed");
|
||||
console.log("project tree archive race: 9 passed");
|
||||
@@ -0,0 +1,76 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { summarizeProjectTreeSessions } from "../lib/projectTreeDiagnostics";
|
||||
import type { ProjectNode } from "../lib/types";
|
||||
|
||||
const topic = (id: string, extra: Partial<ProjectNode> = {}): ProjectNode => ({
|
||||
key: `topic-${id}`,
|
||||
kind: "topic",
|
||||
label: id,
|
||||
topicId: id,
|
||||
...extra,
|
||||
});
|
||||
|
||||
const tree: ProjectNode[] = [{
|
||||
key: "project-a",
|
||||
kind: "project",
|
||||
label: "Project A",
|
||||
root: "private-root",
|
||||
children: [
|
||||
topic("one"),
|
||||
topic("two"),
|
||||
topic("three"),
|
||||
topic("four"),
|
||||
topic("five"),
|
||||
topic("six", { runtimeOnly: true, recoveryCopyCount: 2, running: true }),
|
||||
],
|
||||
}, {
|
||||
key: "global",
|
||||
kind: "global_folder",
|
||||
label: "Global",
|
||||
children: [topic("global-one", { kind: "global_topic", topicId: "global-one", pinned: true })],
|
||||
}];
|
||||
|
||||
const summary = summarizeProjectTreeSessions({
|
||||
tree,
|
||||
visibleTree: tree,
|
||||
expanded: new Set(["project-a"]),
|
||||
showAllTopics: new Set(),
|
||||
classicTruncationActive: true,
|
||||
queryActive: false,
|
||||
timeFilterActive: false,
|
||||
projectNodeKey: (node) => node.key,
|
||||
isActive: (node) => node.topicId === "one",
|
||||
isUnread: (node) => node.topicId === "two",
|
||||
});
|
||||
|
||||
assert.equal(summary.workspaceSessions, 7);
|
||||
assert.equal(summary.visibleSessions, 5, "classic preview plus collapsed global folder hides two rows");
|
||||
assert.equal(summary.hiddenSessions, 2);
|
||||
assert.equal(summary.hiddenByTruncation, 1);
|
||||
assert.equal(summary.hiddenByCollapsed, 1);
|
||||
assert.equal(summary.runtimeOnlySessions, 1);
|
||||
assert.equal(summary.recoveryCopySessions, 1);
|
||||
assert.equal(summary.recoveryCopies, 2);
|
||||
assert.equal(summary.runningSessions, 1);
|
||||
assert.equal(summary.unreadSessions, 1);
|
||||
assert.equal(summary.pinnedSessions, 1);
|
||||
assert.equal(summary.activeSessions, 1);
|
||||
assert.equal(summary.activeVisibleSessions, 1);
|
||||
|
||||
const filtered = summarizeProjectTreeSessions({
|
||||
tree,
|
||||
visibleTree: [{ ...tree[0], children: [tree[0].children?.[0] ?? topic("one")] }],
|
||||
expanded: new Set(["project-a"]),
|
||||
showAllTopics: new Set(["project-a"]),
|
||||
classicTruncationActive: false,
|
||||
queryActive: true,
|
||||
timeFilterActive: false,
|
||||
projectNodeKey: (node) => node.key,
|
||||
});
|
||||
assert.equal(filtered.workspaceSessions, 7);
|
||||
assert.equal(filtered.visibleSessions, 1);
|
||||
assert.equal(filtered.hiddenByFilter, 6);
|
||||
assert.equal(filtered.hiddenByCollapsed, 0);
|
||||
assert.equal(filtered.hiddenByTruncation, 0);
|
||||
|
||||
console.log("project tree diagnostics tests passed");
|
||||
@@ -7,10 +7,14 @@ import { createRoot } from "react-dom/client";
|
||||
import {
|
||||
AddProviderPanel,
|
||||
ProviderAccessCard,
|
||||
providerAccessGroups,
|
||||
SettingsPanel,
|
||||
type ProviderAccessGroup,
|
||||
} from "../components/SettingsPanel";
|
||||
import { LocaleProvider } from "../lib/i18n";
|
||||
import type { ProviderView } from "../lib/types";
|
||||
import type { AppBindings } from "../lib/bridge";
|
||||
import type { ProviderPresetView, ProviderView, SettingsView } from "../lib/types";
|
||||
import { baseSettings } from "../test-support/settingsTestFixtures";
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
@@ -46,6 +50,8 @@ globalThis.localStorage = dom.window.localStorage;
|
||||
globalThis.sessionStorage = dom.window.sessionStorage;
|
||||
globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(dom.window);
|
||||
globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.window);
|
||||
Object.defineProperty(dom.window.HTMLElement.prototype, "attachEvent", { configurable: true, value: () => undefined });
|
||||
Object.defineProperty(dom.window.HTMLElement.prototype, "detachEvent", { configurable: true, value: () => undefined });
|
||||
window.scrollTo = () => {};
|
||||
window.matchMedia = () => ({
|
||||
matches: true,
|
||||
@@ -112,6 +118,15 @@ function group(providers: ProviderView[]): ProviderAccessGroup {
|
||||
};
|
||||
}
|
||||
|
||||
function customGroup(provider: ProviderView): ProviderAccessGroup {
|
||||
return {
|
||||
...group([provider]),
|
||||
id: `custom:${provider.name}`,
|
||||
label: provider.name,
|
||||
builtIn: false,
|
||||
};
|
||||
}
|
||||
|
||||
function renderCard(
|
||||
providerGroup: ProviderAccessGroup,
|
||||
actions: {
|
||||
@@ -125,7 +140,6 @@ function renderCard(
|
||||
group={providerGroup}
|
||||
busy={false}
|
||||
fetching={false}
|
||||
defaultProvider=""
|
||||
editing={null}
|
||||
kinds={["anthropic", "openai"]}
|
||||
onEdit={() => undefined}
|
||||
@@ -217,6 +231,121 @@ ok(
|
||||
"card-level removal submits every grouped provider in one action",
|
||||
);
|
||||
|
||||
let removedCustomProviders: string[] = [];
|
||||
await act(async () => {
|
||||
root.render(renderCard(customGroup({ ...deepSeekAnthropic, name: "my-proxy", builtIn: false }), {
|
||||
onDelete: async (providers) => {
|
||||
removedCustomProviders = providers.map((provider) => provider.name);
|
||||
},
|
||||
}));
|
||||
await flushPromises();
|
||||
});
|
||||
const defaultCustomMoreButton = rootEl.querySelector<HTMLButtonElement>('button[aria-haspopup="menu"]');
|
||||
ok(defaultCustomMoreButton?.disabled === false, "configured custom providers keep the removal menu available");
|
||||
await act(async () => {
|
||||
defaultCustomMoreButton?.click();
|
||||
await flushPromises();
|
||||
});
|
||||
let deleteDefaultCustomButton = Array.from(document.querySelectorAll("button"))
|
||||
.find((button) => button.textContent?.trim() === "Remove access") as HTMLButtonElement | undefined;
|
||||
await act(async () => {
|
||||
deleteDefaultCustomButton?.click();
|
||||
await flushPromises();
|
||||
});
|
||||
deleteDefaultCustomButton = Array.from(document.querySelectorAll("button"))
|
||||
.find((button) => button.textContent?.trim() === "Confirm delete provider") as HTMLButtonElement | undefined;
|
||||
ok(deleteDefaultCustomButton !== undefined, "configured custom provider can be confirmed for deletion");
|
||||
await act(async () => {
|
||||
deleteDefaultCustomButton?.click();
|
||||
await flushPromises();
|
||||
});
|
||||
ok(
|
||||
removedCustomProviders.join(",") === "my-proxy",
|
||||
"configured custom provider removal submits the selected provider",
|
||||
);
|
||||
|
||||
const defaultCustomSettings: SettingsView = baseSettings("standard");
|
||||
const defaultCustomProvider: ProviderView = {
|
||||
...deepSeekAnthropic,
|
||||
name: "my-proxy",
|
||||
builtIn: false,
|
||||
added: true,
|
||||
kind: "openai",
|
||||
baseUrl: "https://proxy.example/v1",
|
||||
models: ["my-model"],
|
||||
default: "my-model",
|
||||
apiKeyEnv: "",
|
||||
keySet: true,
|
||||
configured: true,
|
||||
webSearch: false,
|
||||
serverWebSearchCapability: false,
|
||||
};
|
||||
defaultCustomSettings.defaultModel = "my-proxy/my-model";
|
||||
defaultCustomSettings.providers = [defaultCustomProvider];
|
||||
defaultCustomSettings.providerKinds = ["openai"];
|
||||
let removedDefaultCustomProviders: string[] = [];
|
||||
window.go = {
|
||||
main: {
|
||||
App: {
|
||||
Settings: async () => defaultCustomSettings,
|
||||
RemoveProviderAccesses: async (names: string[]) => {
|
||||
removedDefaultCustomProviders = [...names];
|
||||
defaultCustomSettings.providers = defaultCustomSettings.providers.filter((provider) => !names.includes(provider.name));
|
||||
},
|
||||
} as Partial<AppBindings> as AppBindings,
|
||||
},
|
||||
};
|
||||
const settingsRootEl = document.createElement("div");
|
||||
document.body.appendChild(settingsRootEl);
|
||||
const settingsRoot = createRoot(settingsRootEl);
|
||||
await act(async () => {
|
||||
settingsRoot.render(
|
||||
<LocaleProvider>
|
||||
<SettingsPanel
|
||||
initialTab="models"
|
||||
initialFocus={{ target: "model-access", requestId: 1 }}
|
||||
desktopPlatform="linux"
|
||||
onClose={() => undefined}
|
||||
onChanged={() => undefined}
|
||||
onUseSubagent={() => undefined}
|
||||
/>
|
||||
</LocaleProvider>,
|
||||
);
|
||||
await flushPromises();
|
||||
await flushPromises();
|
||||
});
|
||||
const defaultCustomSettingsMoreButton = settingsRootEl.querySelector<HTMLButtonElement>('button[aria-haspopup="menu"]');
|
||||
ok(
|
||||
defaultCustomSettingsMoreButton?.disabled === false,
|
||||
"current default custom provider keeps the removal menu available",
|
||||
);
|
||||
await act(async () => {
|
||||
defaultCustomSettingsMoreButton?.click();
|
||||
await flushPromises();
|
||||
});
|
||||
let deleteCurrentDefaultButton = Array.from(document.querySelectorAll("button"))
|
||||
.find((button) => button.textContent?.trim() === "Remove access") as HTMLButtonElement | undefined;
|
||||
await act(async () => {
|
||||
deleteCurrentDefaultButton?.click();
|
||||
await flushPromises();
|
||||
});
|
||||
deleteCurrentDefaultButton = Array.from(document.querySelectorAll("button"))
|
||||
.find((button) => button.textContent?.trim() === "Confirm delete provider") as HTMLButtonElement | undefined;
|
||||
ok(deleteCurrentDefaultButton !== undefined, "current default custom provider can be confirmed for deletion");
|
||||
await act(async () => {
|
||||
deleteCurrentDefaultButton?.click();
|
||||
await flushPromises();
|
||||
await flushPromises();
|
||||
});
|
||||
ok(
|
||||
removedDefaultCustomProviders.join(",") === "my-proxy",
|
||||
"current default custom provider removal reaches the settings backend",
|
||||
);
|
||||
await act(async () => {
|
||||
settingsRoot.unmount();
|
||||
});
|
||||
settingsRootEl.remove();
|
||||
|
||||
await act(async () => {
|
||||
root.render(renderCard(group([
|
||||
deepSeekAnthropic,
|
||||
@@ -244,6 +373,7 @@ await act(async () => {
|
||||
root.render(
|
||||
<LocaleProvider>
|
||||
<AddProviderPanel
|
||||
key="official-installed"
|
||||
mode="official"
|
||||
kinds={["anthropic", "openai"]}
|
||||
officialProviders={[{ ...deepSeekAnthropic, name: "deepseek" }]}
|
||||
@@ -266,6 +396,142 @@ const installedOfficialChoice = Array.from(rootEl.querySelectorAll("button"))
|
||||
ok(installedOfficialChoice?.disabled === true, "installed official providers cannot be added again");
|
||||
ok(installedOfficialChoice?.textContent?.includes("Added") === true, "installed official providers show their backend status");
|
||||
|
||||
const openCodePreset = (
|
||||
id: string,
|
||||
label: string,
|
||||
providerNames: string[],
|
||||
displayTier: "primary" | "advanced" | "compatibility",
|
||||
displaySection: "go" | "zen" = "go",
|
||||
): ProviderPresetView => ({
|
||||
id,
|
||||
label,
|
||||
description: `${label} description`,
|
||||
keyEnv: displaySection === "zen" ? "OPENCODE_API_KEY" : "OPENCODE_GO_API_KEY",
|
||||
recommended: id === "opencode-go-recommended",
|
||||
displayGroup: "opencode",
|
||||
displaySection,
|
||||
displayTier,
|
||||
routeKind: id === "opencode-go-recommended" ? "bundle" : "anthropic",
|
||||
providerNames,
|
||||
models: ["model"],
|
||||
added: false,
|
||||
status: "available",
|
||||
statusProviderNames: [],
|
||||
keySet: false,
|
||||
requiresKey: true,
|
||||
configured: false,
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<LocaleProvider>
|
||||
<AddProviderPanel
|
||||
key="opencode-quick-setup"
|
||||
mode="official"
|
||||
kinds={["anthropic", "openai", "responses"]}
|
||||
officialProviders={[{ ...deepSeekAnthropic, name: "deepseek" }]}
|
||||
providerPresets={[
|
||||
openCodePreset("opencode-go-recommended", "OpenCode Go (Recommended)", ["opencode-go", "opencode-go-anthropic", "opencode-go-responses"], "primary"),
|
||||
openCodePreset("opencode-go-anthropic", "OpenCode Go Anthropic", ["opencode-go-anthropic"], "advanced"),
|
||||
openCodePreset("opencode-go-responses", "OpenCode Go Responses", ["opencode-go-responses"], "advanced"),
|
||||
openCodePreset("opencode-go", "OpenCode Go Chat", ["opencode-go"], "compatibility"),
|
||||
openCodePreset("opencode-zen-anthropic", "OpenCode Zen", ["opencode-zen-anthropic"], "primary", "zen"),
|
||||
]}
|
||||
busy={false}
|
||||
onMode={() => undefined}
|
||||
onCancel={() => undefined}
|
||||
onAddOfficial={async () => undefined}
|
||||
onAddPreset={async () => undefined}
|
||||
onViewPresetConflict={() => undefined}
|
||||
onResetPreset={async () => undefined}
|
||||
onAddCustom={() => undefined}
|
||||
/>
|
||||
</LocaleProvider>,
|
||||
);
|
||||
await flushPromises();
|
||||
});
|
||||
ok(rootEl.textContent?.includes("OpenCode Go Anthropic") === false, "OpenCode setup hides protocol-specific provider entries");
|
||||
ok(rootEl.textContent?.includes("Advanced routes") === false, "OpenCode setup does not expose an advanced-route decision");
|
||||
ok(rootEl.textContent?.includes("Compatibility") === false, "OpenCode setup hides compatibility entries from new users");
|
||||
const featuredProviders = rootEl.querySelector(".provider-featured-grid");
|
||||
ok(
|
||||
featuredProviders?.textContent?.includes("DeepSeek") === true
|
||||
&& featuredProviders.textContent.includes("OpenCode Go"),
|
||||
"OpenCode Go and DeepSeek are presented as same-level recommended providers",
|
||||
);
|
||||
ok(
|
||||
featuredProviders?.querySelectorAll(".provider-template-card").length === 2
|
||||
&& featuredProviders.textContent?.includes("OpenCode Zen") === false,
|
||||
"the top-level provider row contains only DeepSeek and OpenCode Go",
|
||||
);
|
||||
ok(
|
||||
rootEl.querySelector<HTMLInputElement>('input[placeholder*="OPENCODE_GO_API_KEY"]') !== null,
|
||||
"OpenCode Go opens directly on its API key field",
|
||||
);
|
||||
ok(
|
||||
Array.from(rootEl.querySelectorAll("button")).some((button) => button.textContent?.trim() === "Connect and start using"),
|
||||
"OpenCode Go setup exposes one outcome-oriented primary action",
|
||||
);
|
||||
ok(rootEl.querySelector('[role="tablist"]') === null, "OpenCode Go quick setup does not ask users to choose a configuration mode");
|
||||
const chooseAnotherProvider = Array.from(rootEl.querySelectorAll("button"))
|
||||
.find((button) => button.textContent?.trim() === "Choose another provider") as HTMLButtonElement | undefined;
|
||||
await act(async () => {
|
||||
chooseAnotherProvider?.click();
|
||||
await flushPromises();
|
||||
});
|
||||
ok(rootEl.textContent?.includes("OpenCode Zen") === true, "OpenCode Zen remains an independent product choice");
|
||||
|
||||
const openCodeProviders: ProviderView[] = [
|
||||
{ ...deepSeekAnthropic, builtIn: false, name: "opencode-go", kind: "openai", baseUrl: "https://opencode.ai/zen/go/v1", apiKeyEnv: "OPENCODE_GO_API_KEY", webSearch: false },
|
||||
{ ...deepSeekAnthropic, builtIn: false, name: "opencode-go-anthropic", baseUrl: "https://opencode.ai/zen/go", apiKeyEnv: "OPENCODE_GO_API_KEY", webSearch: false },
|
||||
{ ...deepSeekAnthropic, builtIn: false, name: "opencode-go-responses", kind: "responses", baseUrl: "https://opencode.ai/zen/go/v1", apiKeyEnv: "OPENCODE_GO_API_KEY", webSearch: false },
|
||||
];
|
||||
const openCodeGroups = providerAccessGroups(openCodeProviders, ((key: string) => key) as never);
|
||||
ok(openCodeGroups.length === 1, "installed OpenCode Go routes render as one product connection");
|
||||
ok(openCodeGroups[0]?.id === "custom:opencode-go", "OpenCode Go connection has a stable product-level identity");
|
||||
|
||||
await act(async () => {
|
||||
root.render(renderCard(openCodeGroups[0]));
|
||||
await flushPromises();
|
||||
});
|
||||
const routeSettings = rootEl.querySelector<HTMLDetailsElement>("details.provider-route-settings");
|
||||
ok(routeSettings !== null && routeSettings.open === false, "installed OpenCode Go keeps protocol routes collapsed by default");
|
||||
ok(
|
||||
routeSettings?.querySelector("summary")?.textContent?.trim() === "Advanced route settings",
|
||||
"installed OpenCode Go exposes route controls only through an advanced disclosure",
|
||||
);
|
||||
|
||||
let removedOpenCodeProviders: string[] = [];
|
||||
await act(async () => {
|
||||
root.render(renderCard(openCodeGroups[0], {
|
||||
onDelete: async (providers) => {
|
||||
removedOpenCodeProviders = providers.map((provider) => provider.name);
|
||||
},
|
||||
}));
|
||||
await flushPromises();
|
||||
});
|
||||
const openCodeMoreButton = rootEl.querySelector<HTMLButtonElement>('button[aria-haspopup="menu"]');
|
||||
await act(async () => {
|
||||
openCodeMoreButton?.click();
|
||||
await flushPromises();
|
||||
});
|
||||
let removeOpenCodeButton = Array.from(document.querySelectorAll("button"))
|
||||
.find((button) => button.textContent?.trim() === "Remove access") as HTMLButtonElement | undefined;
|
||||
await act(async () => {
|
||||
removeOpenCodeButton?.click();
|
||||
await flushPromises();
|
||||
});
|
||||
removeOpenCodeButton = Array.from(document.querySelectorAll("button"))
|
||||
.find((button) => button.textContent?.trim() === "Confirm delete provider") as HTMLButtonElement | undefined;
|
||||
await act(async () => {
|
||||
removeOpenCodeButton?.click();
|
||||
await flushPromises();
|
||||
});
|
||||
ok(
|
||||
removedOpenCodeProviders.join(",") === "opencode-go,opencode-go-anthropic,opencode-go-responses",
|
||||
"OpenCode Go group removal submits every installed route atomically",
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
|
||||
@@ -51,13 +51,17 @@ globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(dom.win
|
||||
globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.window);
|
||||
window.scrollTo = () => {};
|
||||
|
||||
function renderPicker(candidates: string[], visionCapability: "configurable" | "unsupported" = "configurable") {
|
||||
function renderPicker(
|
||||
candidates: string[],
|
||||
visionCapability: "configurable" | "unsupported" = "configurable",
|
||||
visionModels: string[] = [],
|
||||
) {
|
||||
return (
|
||||
<LocaleProvider>
|
||||
<ProviderEditorModelPicker
|
||||
candidates={candidates}
|
||||
selectedModels={[]}
|
||||
visionModels={[]}
|
||||
visionModels={visionModels}
|
||||
visionCapability={visionCapability}
|
||||
contextWindows={{}}
|
||||
disabled={false}
|
||||
@@ -101,6 +105,22 @@ await act(async () => {
|
||||
});
|
||||
ok(rootEl.textContent?.includes("No image input") === true, "known text-only DeepSeek models show a read-only image capability");
|
||||
ok(rootEl.querySelectorAll('input[type="checkbox"]').length === 1, "text-only model card does not render a second image checkbox");
|
||||
await act(async () => {
|
||||
root.render(renderPicker(["deepseek-v4-flash-vision-exp"], "unsupported", ["deepseek-v4-flash-vision-exp"]));
|
||||
await flushPromises();
|
||||
});
|
||||
ok(rootEl.textContent?.includes("No image input") !== true, "pinned DeepSeek vision SKU does not show the text-only image label");
|
||||
ok(rootEl.querySelectorAll('input[type="checkbox"]').length === 1, "pinned DeepSeek vision SKU does not render a configurable image checkbox");
|
||||
await act(async () => {
|
||||
root.render(renderPicker(
|
||||
["deepseek-v4-flash", "deepseek-v4-flash-vision-exp"],
|
||||
"configurable",
|
||||
["deepseek-v4-flash-vision-exp"],
|
||||
));
|
||||
await flushPromises();
|
||||
});
|
||||
ok(rootEl.querySelectorAll('input[type="checkbox"]').length === 4, "configurable DeepSeek models expose image-input checkboxes");
|
||||
ok(rootEl.textContent?.includes("No image input") !== true, "configurable DeepSeek models do not use the read-only image-unsupported label");
|
||||
ok(providerSupportsServerWebSearch("responses", "https://api.deepseek.com"), "DeepSeek Responses exposes server-side web search");
|
||||
ok(providerSupportsServerWebSearch("anthropic", "https://api.deepseek.com/anthropic"), "DeepSeek Anthropic exposes server-side web search");
|
||||
ok(!providerSupportsServerWebSearch("openai", "https://api.deepseek.com"), "DeepSeek Chat Completions does not expose server-side web search");
|
||||
@@ -137,12 +157,12 @@ ok(
|
||||
"backend vision capability overrides frontend endpoint inference",
|
||||
);
|
||||
ok(
|
||||
providerVisionCapabilityForView({ kind: "openai", baseUrl: "https://api.deepseek.com" }) === "unsupported",
|
||||
"older backend payloads retain the endpoint-based vision fallback",
|
||||
providerVisionCapabilityForView({ kind: "openai", baseUrl: "https://api.deepseek.com" }) === "configurable",
|
||||
"official DeepSeek Settings uses the same image-input checkboxes as other providers",
|
||||
);
|
||||
ok(
|
||||
providerVisionCapabilityForView({ kind: "anthropic", baseUrl: "https://eu.deepseek.com/anthropic" }) === "unsupported",
|
||||
"older backend payloads treat regional DeepSeek subdomains as text-only",
|
||||
providerVisionCapabilityForView({ kind: "anthropic", baseUrl: "https://eu.deepseek.com/anthropic" }) === "configurable",
|
||||
"regional DeepSeek Settings also expose per-model image-input checkboxes",
|
||||
);
|
||||
ok(
|
||||
providerVisionCapabilityForView({ kind: "responses", baseUrl: "https://deepseek.com" }) === "configurable",
|
||||
@@ -271,7 +291,8 @@ await act(async () => {
|
||||
const webSearchSwitch = rootEl.querySelector<HTMLInputElement>('input[role="switch"]');
|
||||
ok(rootEl.textContent?.includes("Server-side web search") === true, "DeepSeek Responses editor separates service capabilities from model selection");
|
||||
ok(webSearchSwitch?.checked === true, "curated DeepSeek Responses capability is enabled in the editor");
|
||||
ok(rootEl.textContent?.includes("No image input") === true, "DeepSeek Responses editor labels image input as unsupported");
|
||||
ok(rootEl.textContent?.includes("Image input") === true, "DeepSeek Responses editor exposes per-model image-input checkboxes");
|
||||
ok(rootEl.textContent?.includes("No image input") !== true, "DeepSeek Responses editor does not use the read-only image-unsupported label");
|
||||
|
||||
await act(async () => {
|
||||
root.render(renderProviderEditor(longCatAnthropicProvider));
|
||||
|
||||
@@ -28,6 +28,7 @@ function ok(cond: boolean, label: string) {
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const appSource = readFileSync(resolve(here, "../App.tsx"), "utf8");
|
||||
const controllerSource = readFileSync(resolve(here, "../lib/useController.ts"), "utf8");
|
||||
const noticesSource = readFileSync(resolve(here, "../lib/controllerNotices.ts"), "utf8");
|
||||
|
||||
console.log("\nquiet recovery notifications");
|
||||
|
||||
@@ -38,7 +39,7 @@ ok(!appSource.includes("AcknowledgeTabRecovery"), "App does not expose recovery
|
||||
ok(!appSource.includes("OpenTabRecoveryParent"), "App does not expose recovery compare controls");
|
||||
ok(!appSource.includes("recovery.openOriginalFailed"), "App does not carry recovery compare failure text");
|
||||
|
||||
ok(controllerSource.includes("function quietTranscriptNoticeKey"), "controller centralizes quiet transcript notices");
|
||||
ok(noticesSource.includes("function quietTranscriptNoticeKey"), "controller centralizes quiet transcript notices");
|
||||
ok(controllerSource.includes("if (quietTranscriptNoticeKey(rawText, code))"), "raw quiet notices are skipped before localization");
|
||||
ok(controllerSource.includes("if (quietTranscriptNoticeKey(text, code))"), "localized quiet notices are skipped before rendering");
|
||||
|
||||
|
||||
@@ -0,0 +1,458 @@
|
||||
// Run: tsx src/__tests__/remote-connect-wizard.test.tsx
|
||||
|
||||
import React from "react";
|
||||
import { JSDOM } from "jsdom";
|
||||
import { act } from "react";
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import type { AppBindings } from "../lib/bridge";
|
||||
import type { RemoteDirEntry, RemoteHostView } from "../lib/types";
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
function ok(value: boolean, label: string) {
|
||||
if (value) {
|
||||
process.stdout.write(` PASS ${label}\n`);
|
||||
passed += 1;
|
||||
} else {
|
||||
process.stdout.write(` FAIL ${label}\n`);
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
|
||||
console.log("\nRemote connect wizard (three steps)");
|
||||
const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", {
|
||||
pretendToBeVisual: true,
|
||||
url: "http://localhost/",
|
||||
});
|
||||
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
globalThis.window = dom.window as unknown as Window & typeof globalThis;
|
||||
globalThis.document = dom.window.document;
|
||||
Object.defineProperty(globalThis, "navigator", { configurable: true, value: dom.window.navigator });
|
||||
globalThis.HTMLElement = dom.window.HTMLElement;
|
||||
globalThis.Event = dom.window.Event;
|
||||
globalThis.KeyboardEvent = dom.window.KeyboardEvent;
|
||||
Object.defineProperty(dom.window.HTMLElement.prototype, "attachEvent", { configurable: true, value: () => {} });
|
||||
Object.defineProperty(dom.window.HTMLElement.prototype, "detachEvent", { configurable: true, value: () => {} });
|
||||
|
||||
const [{ createRoot }, { RemoteConnectWizard }, { LocaleProvider }, { useRemoteStore }] = await Promise.all([
|
||||
import("react-dom/client"),
|
||||
import("../components/RemoteConnectWizard"),
|
||||
import("../lib/i18n"),
|
||||
import("../store/remote"),
|
||||
]);
|
||||
|
||||
// Bridge call tape: every remote method appends "<name>:<detail>".
|
||||
const tape: string[] = [];
|
||||
const savedHosts: RemoteHostView[] = [
|
||||
{ id: "gpu-box", label: "gpu-box", host: "192.168.1.10", port: 22, user: "dev", identityFile: "~/.ssh/id_ed25519", proxyJump: "", defaultWorkspace: "", serveInstall: "auto", credentialMode: "remote", useSSHConfig: false },
|
||||
{ id: "pw-box", label: "pw-box", host: "10.0.0.8", port: 22, user: "ops", identityFile: "", proxyJump: "", defaultWorkspace: "", serveInstall: "auto", credentialMode: "remote", useSSHConfig: false, passwordSet: true },
|
||||
];
|
||||
let hostCount = 0;
|
||||
|
||||
function setInput(input: HTMLInputElement, value: string) {
|
||||
const setter = Object.getOwnPropertyDescriptor(dom.window.HTMLInputElement.prototype, "value")?.set;
|
||||
setter?.call(input, value);
|
||||
input.dispatchEvent(new dom.window.Event("input", { bubbles: true }));
|
||||
input.dispatchEvent(new dom.window.Event("change", { bubbles: true }));
|
||||
}
|
||||
|
||||
function buttonByText(text: string): HTMLButtonElement | undefined {
|
||||
return [...document.querySelectorAll<HTMLButtonElement>("button")].find((b) => b.textContent?.trim() === text);
|
||||
}
|
||||
|
||||
async function flush(ticks = 20) {
|
||||
for (let i = 0; i < ticks; i++) await Promise.resolve();
|
||||
}
|
||||
|
||||
// Real-time wait: the ConnectRemoteHost mock holds the connecting step for a
|
||||
// 20ms macrotask so the log panel renders; microtask flushes cannot cover it.
|
||||
function delay(ms: number): Promise<void> {
|
||||
return new Promise<void>((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
const dirs: Record<string, Array<{ name: string; path: string; isDir: boolean }>> = {
|
||||
"/home/dev": [
|
||||
{ name: "projects", path: "/home/dev/projects", isDir: true },
|
||||
{ name: "notes.txt", path: "/home/dev/notes.txt", isDir: false },
|
||||
],
|
||||
"/home/dev/projects": [{ name: "app", path: "/home/dev/projects/app", isDir: true }],
|
||||
"/home/dev/projects/web": [],
|
||||
};
|
||||
const slowDirectory = Promise.withResolvers<RemoteDirEntry[]>();
|
||||
|
||||
// Connection attempt counter: the first attempt fails (the wizard stays on
|
||||
// the connecting step so its log panel stays observable); the retry succeeds.
|
||||
let connectAttempts = 0;
|
||||
// Platform-gate attempt counter: the first check models a Windows SSH host
|
||||
// (uname reports MINGW64); later checks pass.
|
||||
let platformAttempts = 0;
|
||||
// Last AddRemoteHost payload, for credential-mode assertions.
|
||||
let lastAddInput: RemoteHostInput | undefined;
|
||||
window.go = { main: { App: {
|
||||
async RemoteHosts() {
|
||||
tape.push("RemoteHosts");
|
||||
return savedHosts.slice();
|
||||
},
|
||||
async AddRemoteHost(input: RemoteHostView & { label?: string; host?: string }) {
|
||||
lastAddInput = input as unknown as RemoteHostInput;
|
||||
tape.push(`AddRemoteHost:${input.label}:${input.host}:${(input as RemoteHostInput).credentialMode ?? ""}`);
|
||||
hostCount += 1;
|
||||
const view = { id: `new-${hostCount}`, label: String(input.label), host: String(input.host), port: 22, user: "", identityFile: "", proxyJump: "", defaultWorkspace: "", serveInstall: "npm", credentialMode: "remote", useSSHConfig: false } as RemoteHostView;
|
||||
savedHosts.push(view);
|
||||
return view;
|
||||
},
|
||||
async UpdateRemoteHost(id: string, input: RemoteHostView & { label?: string; host?: string }) {
|
||||
tape.push(`UpdateRemoteHost:${id}:${input.host}`);
|
||||
return savedHosts.find((h) => h.id === id) ?? savedHosts[0] as RemoteHostView;
|
||||
},
|
||||
async RemoteLastWorkspace() {
|
||||
tape.push("RemoteLastWorkspace");
|
||||
return "/home/dev";
|
||||
},
|
||||
async ListRemoteDir(_hostId: string, path: string) {
|
||||
tape.push(`ListRemoteDir:${path}`);
|
||||
if (path === "/slow") return slowDirectory.promise;
|
||||
if (path === "/fast") {
|
||||
return [{ name: "latest", path: "/fast/latest", isDir: true, size: 0, mtimeUnix: 0, symlink: false }];
|
||||
}
|
||||
return (dirs[path] ?? []).map((entry) => ({ ...entry, size: 0, mtimeUnix: 0, symlink: false }));
|
||||
},
|
||||
async MkdirRemote(_hostId: string, path: string) {
|
||||
tape.push(`MkdirRemote:${path}`);
|
||||
return undefined;
|
||||
},
|
||||
async ConnectRemoteHost(hostId: string) {
|
||||
tape.push(`ConnectRemoteHost:${hostId}`);
|
||||
// Hold 60ms so the connecting step mounts, then emit the kernel status
|
||||
// per attempt: first stopped+error → waitForRemoteConnection rejects
|
||||
// immediately and the wizard stays on the connecting step; later
|
||||
// attempts connected → the flow proceeds to the platform check.
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 60));
|
||||
connectAttempts += 1;
|
||||
if (connectAttempts === 1) {
|
||||
useRemoteStore.getState().applyStatus({ hostId, state: "stopped", error: "ssh: handshake failed" });
|
||||
return undefined;
|
||||
}
|
||||
useRemoteStore.getState().applyStatus({ hostId, state: "connected" });
|
||||
return undefined;
|
||||
},
|
||||
// Platform gate: the first check models a Windows SSH host (uname reports
|
||||
// MINGW64), later checks pass.
|
||||
async CheckRemotePlatform(hostId: string) {
|
||||
tape.push(`CheckRemotePlatform:${hostId}`);
|
||||
platformAttempts += 1;
|
||||
if (platformAttempts === 1) {
|
||||
throw new Error('remote host platform check failed: unsupported remote OS "MINGW64_NT-10.0-19045" (V1 supports Linux and macOS)');
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
async PickRemoteIdentityFile() {
|
||||
tape.push("PickRemoteIdentityFile");
|
||||
return "/home/dev/.ssh/id_wizard";
|
||||
},
|
||||
async OpenRemoteWorkspace(hostId: string, workspace: string) {
|
||||
tape.push(`OpenRemoteWorkspace:${hostId}:${workspace}`);
|
||||
},
|
||||
async OpenRemoteProjectTab(hostId: string, workspace: string, opts?: { newSession?: boolean }) {
|
||||
tape.push(`OpenRemoteProjectTab:${hostId}:${workspace}:${opts?.newSession === true}`);
|
||||
},
|
||||
async AddRemoteProject(hostId: string, workspace: string) {
|
||||
tape.push(`AddRemoteProject:${hostId}:${workspace}`);
|
||||
return { hostId, workspace };
|
||||
},
|
||||
} as Partial<AppBindings> as AppBindings } };
|
||||
|
||||
function WizardHarness() {
|
||||
return (
|
||||
<LocaleProvider>
|
||||
<RemoteConnectWizard
|
||||
onRefresh={async () => { tape.push("refresh"); }}
|
||||
onClose={() => {
|
||||
tape.push("close");
|
||||
}}
|
||||
/>
|
||||
</LocaleProvider>
|
||||
);
|
||||
}
|
||||
|
||||
const rootElement = document.getElementById("root");
|
||||
if (!rootElement) throw new Error("missing root");
|
||||
const root = createRoot(rootElement);
|
||||
await act(async () => {
|
||||
root.render(<WizardHarness />);
|
||||
});
|
||||
await act(async () => flush());
|
||||
|
||||
// ── Step ① initial state: stepper rail, config form ──
|
||||
const railItems = () => [...document.querySelectorAll(".remote-wizard__rail-item")];
|
||||
ok(railItems().length === 3, "stepper rail lists all three steps");
|
||||
ok(railItems()[0]?.className.includes("--current") === true, "step 1 is current on open");
|
||||
ok(railItems().every((item) => !item.className.includes("--done")), "no step is done on open");
|
||||
ok(document.querySelectorAll(".remote-wizard__seg").length === 3, "auth, download, and credential mode use segmented sliders");
|
||||
const hostInput = document.querySelector<HTMLInputElement>(".remote-wizard__suggest input");
|
||||
ok(Boolean(hostInput), "config step shows the host input");
|
||||
ok(document.activeElement === hostInput, "opening the dialog focuses the first field");
|
||||
await act(async () => {
|
||||
document.dispatchEvent(new dom.window.KeyboardEvent("keydown", { key: "Tab", shiftKey: true, bubbles: true }));
|
||||
await Promise.resolve();
|
||||
});
|
||||
ok(document.activeElement === buttonByText("Next"), "Shift+Tab from the first field wraps to the last action");
|
||||
await act(async () => {
|
||||
document.dispatchEvent(new dom.window.KeyboardEvent("keydown", { key: "Tab", bubbles: true }));
|
||||
await Promise.resolve();
|
||||
});
|
||||
ok(document.activeElement === hostInput, "Tab from the last action wraps to the first field");
|
||||
|
||||
// ── Empty host+user: footer alert names both missing fields ──
|
||||
{
|
||||
const userInput = [...document.querySelectorAll<HTMLInputElement>("input")].find((i) => i.placeholder.includes("root"));
|
||||
await act(async () => {
|
||||
if (hostInput) setInput(hostInput, "");
|
||||
if (userInput) setInput(userInput, "");
|
||||
await Promise.resolve();
|
||||
});
|
||||
await act(async () => {
|
||||
buttonByText("Next")?.click();
|
||||
await Promise.resolve();
|
||||
});
|
||||
const alert = document.querySelector(".remote-wizard__footer [role='alert']");
|
||||
const text = alert?.textContent ?? "";
|
||||
ok(text.includes("Host") && text.includes("username") && text.includes("Password"), "empty form reports host, username, and password");
|
||||
ok(text.includes("⚠"), "footer alert uses the warning mark");
|
||||
await act(async () => {
|
||||
if (userInput) setInput(userInput, "root");
|
||||
await Promise.resolve();
|
||||
});
|
||||
}
|
||||
// ── Saved-host suggestion: focus → dropdown → prefill ──
|
||||
await act(async () => {
|
||||
hostInput?.dispatchEvent(new dom.window.Event("focusin", { bubbles: true }));
|
||||
hostInput?.dispatchEvent(new dom.window.Event("focus", { bubbles: false }));
|
||||
await Promise.resolve();
|
||||
});
|
||||
const suggestion = document.querySelector<HTMLButtonElement>(".remote-wizard__suggest-list button");
|
||||
ok(Boolean(suggestion), "focusing the host input lists saved SSH connections");
|
||||
await act(async () => {
|
||||
suggestion?.dispatchEvent(new dom.window.Event("mousedown", { bubbles: true, cancelable: true }));
|
||||
await Promise.resolve();
|
||||
});
|
||||
ok(hostInput?.value === "192.168.1.10", "picking a suggestion prefills the host");
|
||||
const keyInput = [...document.querySelectorAll<HTMLInputElement>("input")].find((i) => i.value.includes("id_ed25519"));
|
||||
ok(Boolean(keyInput), "saved key auth switches the form to key mode with the identity file");
|
||||
await act(async () => {
|
||||
document.querySelector<HTMLButtonElement>(".remote-wizard__pick-btn")?.click();
|
||||
await flush();
|
||||
});
|
||||
ok(tape.includes("PickRemoteIdentityFile"), "identity-file action uses the native desktop picker");
|
||||
ok(keyInput?.value === "/home/dev/.ssh/id_wizard", "native picker returns the absolute identity-file path");
|
||||
|
||||
{
|
||||
await act(async () => {
|
||||
if (hostInput) setInput(hostInput, "");
|
||||
await Promise.resolve();
|
||||
});
|
||||
await act(async () => {
|
||||
hostInput?.dispatchEvent(new dom.window.Event("focusin", { bubbles: true }));
|
||||
hostInput?.dispatchEvent(new dom.window.Event("focus", { bubbles: false }));
|
||||
await Promise.resolve();
|
||||
});
|
||||
const listed = [...document.querySelectorAll<HTMLButtonElement>(".remote-wizard__suggest-list button")].find((b) => b.textContent?.includes("pw-box"));
|
||||
await act(async () => {
|
||||
listed?.dispatchEvent(new dom.window.Event("mousedown", { bubbles: true, cancelable: true }));
|
||||
await Promise.resolve();
|
||||
});
|
||||
const passwordInput = document.querySelector<HTMLInputElement>(".remote-wizard__field input[type='password']");
|
||||
ok((passwordInput?.placeholder ?? "").toLowerCase().includes("saved") || (passwordInput?.placeholder ?? "").includes("已保存"), "saved password host keeps a keep-existing placeholder");
|
||||
await act(async () => {
|
||||
if (hostInput) setInput(hostInput, "");
|
||||
await Promise.resolve();
|
||||
});
|
||||
await act(async () => {
|
||||
hostInput?.dispatchEvent(new dom.window.Event("focusin", { bubbles: true }));
|
||||
hostInput?.dispatchEvent(new dom.window.Event("focus", { bubbles: false }));
|
||||
await Promise.resolve();
|
||||
});
|
||||
const gpuSuggestion = [...document.querySelectorAll<HTMLButtonElement>(".remote-wizard__suggest-list button")].find((b) => b.textContent?.includes("gpu-box"));
|
||||
await act(async () => {
|
||||
gpuSuggestion?.dispatchEvent(new dom.window.Event("mousedown", { bubbles: true, cancelable: true }));
|
||||
await Promise.resolve();
|
||||
});
|
||||
}
|
||||
// ── Next: first connect fails; the wizard stays on the connecting step ──
|
||||
await act(async () => {
|
||||
buttonByText("Next")?.click();
|
||||
await delay(120);
|
||||
await flush();
|
||||
});
|
||||
ok(tape.includes("UpdateRemoteHost:gpu-box:192.168.1.10"), "next on a picked host updates it instead of adding a duplicate");
|
||||
ok(!tape.some((entry) => entry.startsWith("AddRemoteHost:")), "no AddRemoteHost for a saved host");
|
||||
// The failure path keeps the connecting step on screen (act has ended and
|
||||
// the DOM is committed), so counting log lines here is reliable: at least
|
||||
// two — connecting + failed.
|
||||
{
|
||||
const logCount = document.querySelectorAll(".remote-wizard__log-line").length;
|
||||
ok(logCount >= 2, "connecting step streams the deployment log");
|
||||
const connectError = document.querySelector(".remote-wizard__connecting .remote-wizard__error");
|
||||
ok(Boolean(connectError?.textContent?.includes("ssh: handshake failed")), "failed connect surfaces the kernel error");
|
||||
ok(Boolean(buttonByText("Retry")), "retry action is offered after a failed connect");
|
||||
await act(async () => {
|
||||
document.dispatchEvent(new dom.window.KeyboardEvent("keydown", { key: "Tab", bubbles: true }));
|
||||
await Promise.resolve();
|
||||
});
|
||||
ok(document.activeElement === buttonByText("Back to edit"), "Tab after a step transition returns focus to the dialog");
|
||||
}
|
||||
// ── Retry #1: SSH connects, but the platform check rejects the host ──
|
||||
await act(async () => {
|
||||
buttonByText("Retry")?.click();
|
||||
await flush();
|
||||
});
|
||||
ok(buttonByText("Cancel")?.disabled === true, "retry keeps the wizard busy while the connection is pending");
|
||||
await act(async () => {
|
||||
await delay(120);
|
||||
await flush();
|
||||
});
|
||||
ok(tape.includes("CheckRemotePlatform:gpu-box"), "a connected host runs the platform check before the workspace step");
|
||||
ok(railItems()[1]?.className.includes("--current") === true, "an unsupported OS keeps the wizard on the connecting step");
|
||||
{
|
||||
const platformError = document.querySelector(".remote-wizard__connecting .remote-wizard__error");
|
||||
ok(Boolean(platformError?.textContent?.includes("unsupported remote OS")), "the platform failure surfaces the kernel error");
|
||||
ok(!tape.some((entry) => entry.startsWith("ListRemoteDir")), "directory browsing never starts for an unsupported host");
|
||||
ok(Boolean(buttonByText("Retry")), "retry stays available after a platform rejection");
|
||||
}
|
||||
// ── Retry #2: the platform check passes and lands on step ③ ──
|
||||
await act(async () => {
|
||||
buttonByText("Retry")?.click();
|
||||
await delay(120);
|
||||
await flush();
|
||||
});
|
||||
ok(railItems()[0]?.className.includes("--done") === true, "step 1 turns done (green check) after advancing");
|
||||
ok(railItems()[2]?.className.includes("--current") === true, "step 3 is current after connecting");
|
||||
ok(document.querySelector<HTMLInputElement>(".remote-wizard__path-input")?.value === "/home/dev", "workspace picker starts at RemoteLastWorkspace path");
|
||||
ok(Boolean([...document.querySelectorAll(".remote-wizard__dir")].find((b) => b.textContent === "projects")), "directory entries render");
|
||||
ok(Boolean([...document.querySelectorAll(".remote-wizard__file")].find((row) => row.textContent?.includes("notes.txt"))), "files render in the tree next to folders");
|
||||
{
|
||||
const fileRow = [...document.querySelectorAll<HTMLButtonElement>(".remote-wizard__file")].find((row) => row.textContent?.includes("notes.txt"));
|
||||
await act(async () => {
|
||||
fileRow?.click();
|
||||
await Promise.resolve();
|
||||
});
|
||||
ok(fileRow?.className.includes("--selected") === true, "clicking a file highlights the row");
|
||||
ok(document.querySelector<HTMLInputElement>(".remote-wizard__path-input")?.value === "/home/dev", "clicking a file selects its parent directory as the workspace");
|
||||
}
|
||||
|
||||
await act(async () => {
|
||||
[...document.querySelectorAll<HTMLButtonElement>(".remote-wizard__dir")].find((b) => b.textContent === "projects")?.click();
|
||||
await flush();
|
||||
});
|
||||
ok(Boolean([...document.querySelectorAll(".remote-wizard__dir")].find((b) => b.textContent === "app")), "drilling into a directory lists its children");
|
||||
ok(!document.querySelector(".remote-wizard__mkdir"), "workspace step has no create-folder controls");
|
||||
|
||||
// ── Directory race: an older slow response cannot replace the newer path ──
|
||||
{
|
||||
const pathInput = document.querySelector<HTMLInputElement>(".remote-wizard__path-input");
|
||||
await act(async () => {
|
||||
if (pathInput) setInput(pathInput, "/slow");
|
||||
await Promise.resolve();
|
||||
});
|
||||
await act(async () => {
|
||||
buttonByText("Go")?.click();
|
||||
await flush();
|
||||
});
|
||||
await act(async () => {
|
||||
if (pathInput) setInput(pathInput, "/fast");
|
||||
await Promise.resolve();
|
||||
});
|
||||
await act(async () => {
|
||||
buttonByText("Go")?.click();
|
||||
await flush();
|
||||
});
|
||||
ok(Boolean([...document.querySelectorAll(".remote-wizard__dir")].find((b) => b.textContent === "latest")), "newer directory response renders first");
|
||||
await act(async () => {
|
||||
slowDirectory.resolve([{ name: "stale", path: "/slow/stale", isDir: true, size: 0, mtimeUnix: 0, symlink: false }]);
|
||||
await flush();
|
||||
});
|
||||
ok(![...document.querySelectorAll(".remote-wizard__dir")].some((b) => b.textContent === "stale"), "stale directory response cannot overwrite the latest path");
|
||||
await act(async () => {
|
||||
if (pathInput) setInput(pathInput, "/home/dev/projects");
|
||||
await Promise.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
// ── Finish: pin, open an in-app session tab, then refresh the tree ──
|
||||
await act(async () => {
|
||||
buttonByText("Connect and open")?.click();
|
||||
await flush();
|
||||
});
|
||||
ok(tape.includes("OpenRemoteProjectTab:gpu-box:/home/dev/projects:true"), "finish opens the selected workspace in a new remote session tab");
|
||||
ok(tape.includes("AddRemoteProject:gpu-box:/home/dev/projects"), "finish pins the selected remote workspace");
|
||||
ok(tape.indexOf("AddRemoteProject:gpu-box:/home/dev/projects") < tape.indexOf("OpenRemoteProjectTab:gpu-box:/home/dev/projects:true"), "the workspace is pinned before its session tab opens");
|
||||
ok(tape.indexOf("OpenRemoteProjectTab:gpu-box:/home/dev/projects:true") < tape.indexOf("refresh"), "the project tree refreshes after the session tab opens");
|
||||
ok(tape.includes("close"), "wizard closes after a successful finish");
|
||||
|
||||
await act(async () => root.unmount());
|
||||
|
||||
// ── Second harness: brand-new host goes through AddRemoteHost ──
|
||||
const secondRootEl = document.createElement("div");
|
||||
document.body.appendChild(secondRootEl);
|
||||
const secondRoot = createRoot(secondRootEl);
|
||||
await act(async () => {
|
||||
secondRoot.render(<WizardHarness />);
|
||||
});
|
||||
await act(async () => flush());
|
||||
const newHostInput = document.querySelector<HTMLInputElement>(".remote-wizard__suggest input");
|
||||
const newUserInput = [...document.querySelectorAll<HTMLInputElement>("input")].find((i) => i.placeholder.includes("root"));
|
||||
const newPasswordInput = document.querySelector<HTMLInputElement>(".remote-wizard__field input[type='password']");
|
||||
await act(async () => {
|
||||
if (newHostInput) setInput(newHostInput, "10.9.8.7");
|
||||
if (newUserInput) setInput(newUserInput, "root");
|
||||
await Promise.resolve();
|
||||
});
|
||||
await act(async () => {
|
||||
if (newPasswordInput) setInput(newPasswordInput, "s3cret");
|
||||
await Promise.resolve();
|
||||
});
|
||||
{
|
||||
// Credential mode: a segmented control mirroring the download method;
|
||||
// picking local-proxy must ride the AddRemoteHost payload.
|
||||
const segButtons = Array.from(document.querySelectorAll<HTMLButtonElement>(".remote-wizard__field .provider-add-segmented__item"));
|
||||
const localProxy = segButtons.find((b) => b.textContent?.includes("本机") || b.textContent?.includes("this computer"));
|
||||
ok(Boolean(localProxy), "wizard host form offers the credential-mode segmented control");
|
||||
await act(async () => {
|
||||
localProxy?.click();
|
||||
await Promise.resolve();
|
||||
});
|
||||
ok(localProxy?.className.includes("--active") === true, "local-proxy segment highlights when selected");
|
||||
}
|
||||
await act(async () => {
|
||||
buttonByText("Next")?.click();
|
||||
await flush();
|
||||
});
|
||||
ok(tape.some((entry) => entry.startsWith("AddRemoteHost:10.9.8.7:10.9.8.7")), "a new host is added (label defaults to the host)");
|
||||
ok(lastAddInput?.credentialMode === "local-proxy", `AddRemoteHost carries the chosen credential mode (got ${lastAddInput?.credentialMode})`);
|
||||
|
||||
await act(async () => secondRoot.unmount());
|
||||
|
||||
// ── Merged finish: source contract for overlapping workspaces ──
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const wizardSource = readFileSync(resolve(here, "../components/RemoteConnectWizard.tsx"), "utf8");
|
||||
ok(
|
||||
/const canonical = project\.merged \? project\.workspace : target;/.test(wizardSource) &&
|
||||
/OpenRemoteProjectTab\(hostId, canonical, \{ newSession: true \}\)/.test(wizardSource),
|
||||
"a merged finish opens the tab on the canonical workspace",
|
||||
);
|
||||
ok(
|
||||
/if \(!project\.merged\) \{[\s\S]*?RemoveRemoteProject\(hostId, target\)/.test(wizardSource),
|
||||
"rollback only removes a pin the wizard actually added (a merge owns none)",
|
||||
);
|
||||
ok(
|
||||
/onMerged\?\.\(t\("remoteWizard\.mergedProject"/.test(wizardSource),
|
||||
"a merged finish notifies through onMerged",
|
||||
);
|
||||
|
||||
dom.window.close();
|
||||
process.stdout.write(`\n${passed} passed, ${failed} failed\n`);
|
||||
if (failed > 0) process.exit(1);
|
||||
@@ -1,7 +1,7 @@
|
||||
// Run: tsx src/__tests__/remote-error-ux.test.tsx
|
||||
|
||||
import { JSDOM } from "jsdom";
|
||||
import React from "react";
|
||||
import { JSDOM } from "jsdom";
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
|
||||
@@ -29,6 +29,10 @@ const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body>
|
||||
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
globalThis.window = dom.window as unknown as Window & typeof globalThis;
|
||||
globalThis.document = dom.window.document;
|
||||
// Pin the locale source to the JSDOM navigator (en-US): Node's own global
|
||||
// navigator follows the machine's system language, which flips detectLocale
|
||||
// to Chinese on zh hosts and breaks the English assertions below.
|
||||
Object.defineProperty(globalThis, "navigator", { configurable: true, value: dom.window.navigator });
|
||||
globalThis.Node = dom.window.Node;
|
||||
globalThis.HTMLElement = dom.window.HTMLElement;
|
||||
globalThis.Event = dom.window.Event;
|
||||
@@ -51,6 +55,7 @@ const host: RemoteHostView = {
|
||||
proxyJump: "",
|
||||
defaultWorkspace: "/srv/app",
|
||||
serveInstall: "auto",
|
||||
credentialMode: "remote",
|
||||
useSSHConfig: false,
|
||||
};
|
||||
const rawError = "remote: host key mismatch (/home/dev/.ssh/known_hosts:7)";
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
// Run: tsx src/__tests__/remote-hosts-page.test.tsx
|
||||
|
||||
import { JSDOM } from "jsdom";
|
||||
import React from "react";
|
||||
import { JSDOM } from "jsdom";
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
|
||||
import { RemoteHostsPage } from "../components/RemoteHostsPage";
|
||||
import type { AppBindings } from "../lib/bridge";
|
||||
import { LocaleProvider } from "../lib/i18n";
|
||||
import type { RemoteHostView } from "../lib/types";
|
||||
import type { RemoteHostInput, RemoteHostView } from "../lib/types";
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
@@ -61,11 +61,13 @@ let hosts: RemoteHostView[] = [{
|
||||
proxyJump: "",
|
||||
defaultWorkspace: "/srv/app",
|
||||
serveInstall: "auto",
|
||||
credentialMode: "remote",
|
||||
useSSHConfig: false,
|
||||
passwordSet: true,
|
||||
keyPassphraseSet: true,
|
||||
}];
|
||||
let removeCalls = 0;
|
||||
const recordedUpdates: Array<RemoteHostInput & { id: string }> = [];
|
||||
let legacyView = { mirrorCount: 0, mirrorBytes: 0, trustFile: false };
|
||||
let cleanCalls: string[] = [];
|
||||
const bindings = {
|
||||
@@ -75,6 +77,10 @@ const bindings = {
|
||||
removeCalls += 1;
|
||||
hosts = hosts.filter((host) => host.id !== id);
|
||||
},
|
||||
async UpdateRemoteHost(id: string, input: RemoteHostInput) {
|
||||
recordedUpdates.push({ id, ...input });
|
||||
return { ...hosts.find((host) => host.id === id)!, ...input, id } as RemoteHostView;
|
||||
},
|
||||
async ScanRemoteLegacyWorkbenchData() { return legacyView; },
|
||||
async CleanRemoteLegacyWorkbenchData(target: "mirrors" | "trust") {
|
||||
cleanCalls.push(target);
|
||||
@@ -121,6 +127,25 @@ await act(async () => {
|
||||
});
|
||||
ok(document.body.textContent?.includes("saved password will be removed") === true, "explicit clear action is staged until Save");
|
||||
|
||||
// Credential mode: the host form offers remote | local-proxy and the choice
|
||||
// rides the UpdateRemoteHost payload.
|
||||
{
|
||||
const modeSelect = Array.from(document.querySelectorAll<HTMLSelectElement>("select"))
|
||||
.find((sel) => Array.from(sel.options).some((opt) => opt.value === "local-proxy"));
|
||||
ok(Boolean(modeSelect), "edit form offers the credential-mode select");
|
||||
const setter = Object.getOwnPropertyDescriptor(dom.window.HTMLSelectElement.prototype, "value")?.set;
|
||||
await act(async () => {
|
||||
setter?.call(modeSelect, "local-proxy");
|
||||
modeSelect?.dispatchEvent(new dom.window.Event("change", { bubbles: true }));
|
||||
await flush();
|
||||
});
|
||||
await act(async () => {
|
||||
button("Save")?.click();
|
||||
await flush();
|
||||
});
|
||||
ok(recordedUpdates.length === 1 && recordedUpdates[0].credentialMode === "local-proxy", `save carries the chosen credential mode (got ${JSON.stringify(recordedUpdates.map((u) => u.credentialMode))})`);
|
||||
}
|
||||
|
||||
await act(async () => {
|
||||
button("Cancel")?.click();
|
||||
await flush();
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
// Run: tsx src/__tests__/remote-project-tree.test.tsx
|
||||
// Source-contract test: the remote project group's tree behavior — session
|
||||
// rows, the + action, the remote context menu, the connection badge, and
|
||||
// the local-action guards — is wired exactly once and in the remote shape.
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { activeRemoteProjectAncestorKeys } from "../components/ProjectTreeRemoteGroups";
|
||||
import type { ProjectNode } from "../lib/types";
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
function ok(value: boolean, label: string) {
|
||||
if (value) {
|
||||
process.stdout.write(` PASS ${label}\n`);
|
||||
passed += 1;
|
||||
} else {
|
||||
process.stdout.write(` FAIL ${label}\n`);
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
|
||||
console.log("\nRemote project tree wiring");
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const source = readFileSync(resolve(here, "../components/ProjectTree.tsx"), "utf8");
|
||||
const remoteSource = readFileSync(resolve(here, "../components/ProjectTreeRemoteGroups.tsx"), "utf8");
|
||||
const appSource = readFileSync(resolve(here, "../App.tsx"), "utf8");
|
||||
const modeActionsSource = readFileSync(resolve(here, "../lib/useComposerModeActions.ts"), "utf8");
|
||||
const composerSource = readFileSync(resolve(here, "../components/Composer.tsx"), "utf8");
|
||||
const contentMenuSource = readFileSync(resolve(here, "../components/ComposerContentMenuActions.tsx"), "utf8");
|
||||
const remoteIntegrationSource = readFileSync(resolve(here, "../lib/useRemoteComposerIntegration.ts"), "utf8");
|
||||
const topicbarMenuSource = readFileSync(resolve(here, "../components/TopicbarMoreMenuContent.tsx"), "utf8");
|
||||
const bridgeSource = readFileSync(resolve(here, "../lib/remoteProjectBridge.ts"), "utf8");
|
||||
const remoteOpenSource = readFileSync(resolve(here, "../../../remote_projects.go"), "utf8");
|
||||
const explicitEnsureSource = remoteSource.match(
|
||||
/const ensureRemoteGroupSessions[\s\S]*?\n const openRemoteWindow/,
|
||||
)?.[0] ?? "";
|
||||
|
||||
const sameWorkspaceRemoteTree: ProjectNode[] = [
|
||||
{ key: "remote-host-a", kind: "project", label: "A", remote: { hostId: "host-a", workspace: "/repo" }, children: [] },
|
||||
{ key: "remote-host-b", kind: "project", label: "B", remote: { hostId: "host-b", workspace: "/repo" }, children: [] },
|
||||
];
|
||||
ok(
|
||||
JSON.stringify(activeRemoteProjectAncestorKeys(sameWorkspaceRemoteTree, { hostId: "host-b", workspace: "/repo" }, (node) => node.key ?? "")) === JSON.stringify(["remote-host-b"]),
|
||||
"active remote group expansion uses host-qualified identity instead of local workspace fallback",
|
||||
);
|
||||
|
||||
ok(
|
||||
/remoteSession: \{ hostId: node\.remote!\.hostId, workspace: node\.remote!\.workspace, name: row\.name, path: row\.path, title: row\.title \}/.test(remoteSource) &&
|
||||
/openRemoteSessionNode\(remote, openRemoteProject\)/.test(source) &&
|
||||
/sessionPath: remote\.path, sessionTitle: remote\.title/.test(remoteSource),
|
||||
"session rows open the matching in-app remote session",
|
||||
);
|
||||
ok(
|
||||
/rows\.map\(\(row\): ProjectNode =>/.test(remoteSource) && /mergeRemoteSessionsIntoTree\(tree, remoteSessions, t\)/.test(source) &&
|
||||
/root: node\.remote!\.workspace/.test(remoteSource) && /sessionPath: row\.path/.test(remoteSource),
|
||||
"remote group children render with the active workspace and session identity",
|
||||
);
|
||||
ok(
|
||||
/app\.RemoteProjectSessions\(hostId, workspace\)/.test(remoteSource),
|
||||
"sessions are fetched through the bridge",
|
||||
);
|
||||
ok(
|
||||
/state !== "connected" && state !== "degraded"/.test(remoteSource),
|
||||
"session fetch waits for a connected host",
|
||||
);
|
||||
ok(
|
||||
/key: "remote-new-session"[\s\S]*?key: "remote-open-window"[\s\S]*?key: "remote-stop-server"[\s\S]*?key: "remote-unpin"/.test(remoteSource),
|
||||
"the remote menu exposes new-session, browser, stop, and unpin actions",
|
||||
);
|
||||
ok(
|
||||
/items=\{node\.remote \? remoteProjectMenuItems :/.test(source),
|
||||
"remote groups swap out the local project menu",
|
||||
);
|
||||
ok(
|
||||
/app\.OpenRemoteProjectTab\(ref\.hostId, ref\.workspace,[\s\S]*?newSession: true/.test(remoteSource) &&
|
||||
/app\.ConnectRemoteHost\(ref\.hostId\)[\s\S]*?waitForRemoteConnection\(ref\.hostId\)[\s\S]*?app\.OpenRemoteWorkspace\(ref\.hostId, ref\.workspace\)/.test(remoteSource),
|
||||
"in-app tabs use the remote-session bridge while the browser surface reconnects first",
|
||||
);
|
||||
ok(
|
||||
/app\.RemoveRemoteProject\(ref\.hostId, ref\.workspace\)/.test(remoteSource) && /void refresh\(\);/.test(remoteSource),
|
||||
"unpin removes the registration and refreshes the tree",
|
||||
);
|
||||
ok(
|
||||
/remoteServeBadgeState\(remoteServers\[node\.remote\.hostId\]\?\.\[node\.remote\.workspace\], remoteGroupBusy/.test(source),
|
||||
"the group row badge reflects the workspace-specific serve state",
|
||||
);
|
||||
ok(
|
||||
/sessionLoads\.current\.has\(key\)/.test(remoteSource) &&
|
||||
/sessionLoadGenerations\.current\.set\(key, load\)/.test(remoteSource) &&
|
||||
/eligibleSessionKeys\.current\.has\(key\)/.test(remoteSource) &&
|
||||
/filter\(\(\[key\]\) => retained\.has\(key\)\)/.test(remoteSource) &&
|
||||
/acceptRemoteSessionRows\(key, rows\)/.test(remoteSource) &&
|
||||
/catch\(\(error\) => \{[\s\S]*?sessionLoadGenerations\.current\.get\(key\) === load && eligibleSessionKeys\.current\.has\(key\)[\s\S]*?recordRemoteSessionLoadError\(key, error\)/.test(remoteSource) &&
|
||||
/recordRemoteSessionLoadError[\s\S]*?setGroupError/.test(remoteSource) &&
|
||||
/setGroupError\(\(current\) => current\[key\] \? \{ \.\.\.current, \[key\]: "" \} : current\)/.test(remoteSource),
|
||||
"session fetches fence stale results, retain rows on passive failures, and clear recovered group errors",
|
||||
);
|
||||
ok(
|
||||
/catch \(error\) \{[\s\S]*?recordRemoteSessionLoadError\(key, error\)/.test(explicitEnsureSource) &&
|
||||
!/removeRemoteSessionCache\(key\)/.test(explicitEnsureSource) &&
|
||||
!/setSessions\(/.test(explicitEnsureSource),
|
||||
"an explicit session refresh preserves the last successful rows and cache when Serve fails",
|
||||
);
|
||||
ok(
|
||||
/useComposerModeActions\(\{[\s\S]*?remote: remoteSurfaceActive/.test(appSource) &&
|
||||
/if \(remote && activeTabId\)[\s\S]*?SetRemoteTabComposerProfile\(/.test(modeActionsSource),
|
||||
"remote composer mode changes publish all axes through one remote transaction",
|
||||
);
|
||||
ok(
|
||||
/tab\.id === tabId && tab\.remote[\s\S]*?SetRemoteTabGoal\(tabId, trimmed\)/.test(appSource) &&
|
||||
/onSend=\{remoteSurfaceActive \? remoteComposerSend : handleSend\}/.test(appSource),
|
||||
"remote goal activation and goal-draft submission stay on the remote controller",
|
||||
);
|
||||
ok(
|
||||
/remoteRuntimeCommand\(trimmed\)[\s\S]*?command\?\.method === "setModel"[\s\S]*?session\[command\.method\]\(command\.value\)[\s\S]*?await send/.test(remoteIntegrationSource) &&
|
||||
/\^\\\/\(model\|effort\)/.test(remoteIntegrationSource),
|
||||
"remote model and effort slash commands bypass optimistic conversational submit",
|
||||
);
|
||||
ok(
|
||||
/trimmed === "\/new"[\s\S]*?method: "newSession"/.test(remoteIntegrationSource) &&
|
||||
/trimmed === "\/clear"[\s\S]*?method: "clearSession"/.test(remoteIntegrationSource) &&
|
||||
/command\?\.method === "clearSession"[\s\S]*?requestClear\(\)/.test(remoteIntegrationSource) &&
|
||||
/command\?\.method === "newSession"[\s\S]*?openRemoteNewSession\(activeRemote, session\.retryHydration\)/.test(remoteIntegrationSource),
|
||||
"remote clear and new commands bypass optimistic submit and use session rotation",
|
||||
);
|
||||
ok(
|
||||
/verb === "compact"[\s\S]*?method: "compact"/.test(remoteIntegrationSource) &&
|
||||
/const management = new Set\(\[[\s\S]*?"context"[\s\S]*?"goal"[\s\S]*?"mcp"/.test(remoteIntegrationSource) &&
|
||||
/command\?\.method === "runManagementCommand"[\s\S]*?session\.runManagementCommand\(trimmed, command\.rehydrate\)/.test(remoteIntegrationSource) &&
|
||||
/command\?\.method === "compact"[\s\S]*?session\.compact\(command\.value\)/.test(remoteIntegrationSource) &&
|
||||
/verb === "goal" && remoteGoalCommandStartsTurn\(trimmed\)/.test(remoteIntegrationSource) &&
|
||||
/rehydrate: verb === "branch" \|\| verb === "switch" \|\| verb === "rewind"/.test(remoteIntegrationSource),
|
||||
"remote non-turn management commands bypass optimistic conversational submit",
|
||||
);
|
||||
ok(
|
||||
/if \(activeTab\?\.remote\) return openRemoteNewSession\(activeTab\.remote, remoteSession\.retryHydration\)/.test(appSource),
|
||||
"global New Session routes the active remote tab through its Serve controller",
|
||||
);
|
||||
ok(
|
||||
/item\.id !== "cmd-terminal" && item\.id !== "cmd-reload-runtime"/.test(appSource),
|
||||
"remote command palettes hide local-only terminal and runtime reload actions",
|
||||
);
|
||||
ok(
|
||||
/const remote = index\.get\(topicId\);[\s\S]*?if \(!remote\) return false;[\s\S]*?await action\(remote\);/.test(remoteSource) &&
|
||||
!/if \(remote\.name\) await action\(remote\)/.test(remoteSource),
|
||||
"synthetic blank remote sessions still invoke rename, pin, and delete mutations",
|
||||
);
|
||||
ok(
|
||||
/onRemoteTabUpdated\(\(meta\)/.test(remoteSource) &&
|
||||
/RemoteProjectSessions\(meta\.remote\.hostId, meta\.remote\.workspace\)/.test(remoteSource),
|
||||
"remote tab metadata updates refresh the affected session group",
|
||||
);
|
||||
ok(
|
||||
/attachmentInputEnabled=\{!remoteSurfaceActive\}/.test(appSource) &&
|
||||
/if \(!attachmentInputEnabled\) return;/.test(composerSource) &&
|
||||
/disabled=\{!attachmentInputEnabled\}/.test(composerSource) &&
|
||||
/attachmentInputEnabled \?/.test(contentMenuSource),
|
||||
"remote composer disables local attachment input and native file paths",
|
||||
);
|
||||
ok(
|
||||
/localDurableGuidance=\{!remoteSurfaceActive\}/.test(appSource) &&
|
||||
/if \(!localDurableGuidance && onSteer\)[\s\S]*?await onSteer\(guidanceSubmitText, submitTabId\)/.test(composerSource) &&
|
||||
/app\.SteerRemoteTab\(sourceTabId, text\.trim\(\)\)/.test(appSource),
|
||||
"running remote guidance uses the Serve inbox instead of the local durable inbox",
|
||||
);
|
||||
ok(
|
||||
/remoteSurfaceActive \? remoteSession\.transcript\.items : state\.items/.test(appSource) &&
|
||||
/sessionItemsToMarkdown\(sessionTitle, exportItems, exportLive\)/.test(appSource),
|
||||
"remote exports use the visible remote transcript",
|
||||
);
|
||||
ok(
|
||||
/const visibleRuntimeState = remoteSurfaceActive \? remoteSession\.transcript : state/.test(appSource) &&
|
||||
/tabId=\{remoteSurfaceActive \? undefined : activeTabId\}/.test(appSource) &&
|
||||
/onCancelJob=\{remoteSurfaceActive \? remoteSession\.cancelJob : cancelJob\}/.test(appSource) &&
|
||||
/backgroundRuntimes=\{remoteSurfaceActive \? \[\] : backgroundRuntimes\}/.test(appSource),
|
||||
"remote status and context chrome never fall back to local session telemetry",
|
||||
);
|
||||
ok(
|
||||
/turnPhase=\{visibleRuntimeState\.turnPhase\}/.test(appSource) &&
|
||||
/turnStartAt=\{visibleRuntimeState\.turnStartAt\}/.test(appSource) &&
|
||||
/liveStore=\{remoteSurfaceActive \? remoteSession\.liveStore : liveStore\}/.test(appSource) &&
|
||||
/goalRuntime=\{remoteSurfaceActive \? remoteSession\.goalRuntime : state\.meta\?\.goalRuntime\}/.test(appSource) &&
|
||||
/context=\{visibleRuntimeState\.context\}/.test(appSource),
|
||||
"remote composer timing, tokens, live stream, and cost use the visible remote runtime",
|
||||
);
|
||||
ok(
|
||||
/localWorkspaceDockBlocked = remoteSurfaceActive && \(rightDockMode === "files" \|\| rightDockMode === "changed"\)/.test(appSource) &&
|
||||
/surfaceWorkspacePanelRenderable = effectiveWorkspacePanelRenderable && !localWorkspaceDockBlocked/.test(appSource) &&
|
||||
/\{surfaceWorkspacePanelRenderable && \([\s\S]*?<WorkspacePanel/.test(appSource),
|
||||
"remote surfaces do not mount the local Files or Changes workspace panel",
|
||||
);
|
||||
ok(
|
||||
/session\.pauseGoal\(\)/.test(remoteIntegrationSource) && /session\.resumeGoal\(\)/.test(remoteIntegrationSource),
|
||||
"remote Goal pause and resume actions route to the remote session",
|
||||
);
|
||||
ok(
|
||||
/for \(let i = visibleRuntimeState\.items\.length - 1/.test(appSource) &&
|
||||
/!remoteSurfaceActive && activeTabId && todoBatch/.test(appSource),
|
||||
"remote todo shelf projects the visible transcript without calling the local dismissal backend",
|
||||
);
|
||||
ok(
|
||||
/if \(remoteSurfaceActive\) return;[\s\S]*?setTerminalPanelOpen/.test(appSource) &&
|
||||
/!remoteSurfaceActive && terminalContentVisible/.test(appSource) &&
|
||||
/disabled=\{!terminalEnabled\}/.test(topicbarMenuSource),
|
||||
"remote surfaces disable terminal shortcuts, mounting, and menu actions",
|
||||
);
|
||||
ok(
|
||||
/SetRemoteTabComposerProfile\(activeTabId, controllerMode, toolApprovalMode, ""\)/.test(modeActionsSource) &&
|
||||
!/Promise\.allSettled\(\[[\s\S]*?SetRemoteTabGoal\(activeTabId, goal\)/.test(modeActionsSource),
|
||||
"remote collaboration changes rely on the atomic backend transaction instead of tunnel rollback",
|
||||
);
|
||||
ok(
|
||||
/EnsureRemoteProjectSessions\(hostId: string, workspace: string\): Promise<RemoteSessionView\[\]>;/.test(bridgeSource),
|
||||
"the bridge exposes the explicit-intent ensure listing",
|
||||
);
|
||||
ok(
|
||||
/app\.EnsureRemoteProjectSessions\(hostId, workspace\)/.test(remoteSource) &&
|
||||
/if \(groupBusyRef\.current\.has\(key\)\) return;/.test(remoteSource) &&
|
||||
/sessionLoadGenerations\.current\.set\(key, load\);[\s\S]*?EnsureRemoteProjectSessions\(hostId, workspace\)[\s\S]*?sessionLoadGenerations\.current\.get\(key\) !== load/.test(remoteSource),
|
||||
"a group click cold-starts the serve through the deduped, generation-fenced ensure listing",
|
||||
);
|
||||
ok(
|
||||
/if \(node\.remote && willExpand\) \{\s*void ensureRemoteGroupSessions\(node\.remote\.hostId, node\.remote\.workspace\);/.test(source),
|
||||
"an explicit expand refreshes remote rows even when an optimistic cache exists",
|
||||
);
|
||||
ok(
|
||||
/activeRemoteProjectAncestorKeys\(treeWithRemoteSessions, activeRemote, projectNodeKey\)/.test(source) &&
|
||||
/defaultExpandedProjectTreeKeys\(treeWithRemoteSessions, activeScope, activeWorkspaceRoot, activeTopicId, activeSessionPath\)/.test(source),
|
||||
"active remote ancestors come from the merged tree and host-qualified group identity",
|
||||
);
|
||||
ok(
|
||||
/markActive\(treeWithRemoteSessions\)/.test(source) &&
|
||||
/markNodeRead, treeWithRemoteSessions\]\);/.test(source) &&
|
||||
!/markActive\(tree\);/.test(source),
|
||||
"active synthesized remote rows are marked read when the merged session tree arrives",
|
||||
);
|
||||
ok(
|
||||
/project-tree__remote-status/.test(remoteSource) && /projectTree\.remoteConnectFailed/.test(remoteSource),
|
||||
"remote groups render retryable connect/error rows instead of going silent",
|
||||
);
|
||||
ok(
|
||||
/existing\.selectionRevision\+\+[\s\S]*?a\.goRemoteTabSafe\("remoteTabResume"[\s\S]*?restoreRejectedRemoteTabOpenSelection/.test(remoteOpenSource),
|
||||
"session switches resume in the background behind a generation guard",
|
||||
);
|
||||
|
||||
process.stdout.write(`\n${passed} passed, ${failed} failed\n`);
|
||||
if (failed > 0) process.exit(1);
|
||||
@@ -0,0 +1,96 @@
|
||||
// Run: tsx src/__tests__/remote-running-reconcile.test.ts
|
||||
//
|
||||
// The remote session reuses this reducer; these cases pin the reconciliation
|
||||
// contract that keeps the remote pill from spinning forever:
|
||||
// - a failed submit rolls the optimistic running flag back (send_failed)
|
||||
// - losing the serve connection mid-turn stops the pill (turn_interrupted)
|
||||
// - provider_unreachable surfaces as one self-replacing notice and clears
|
||||
// on the next turn
|
||||
|
||||
import { initialState, reducer } from "../lib/useController";
|
||||
import type { WireEvent } from "../lib/types";
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
function eq(a: unknown, b: unknown, label: string) {
|
||||
if (a === b) {
|
||||
process.stdout.write(` PASS ${label}\n`);
|
||||
passed += 1;
|
||||
} else {
|
||||
process.stdout.write(` FAIL ${label}: expected ${JSON.stringify(b)}, got ${JSON.stringify(a)}\n`);
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
|
||||
const submit = { type: "user" as const, text: "你好", seq: 1, submissionId: "remote-1" };
|
||||
const event = (e: Partial<WireEvent> & { kind: WireEvent["kind"] }) =>
|
||||
reducer(initialState, { type: "event", e: e as WireEvent });
|
||||
|
||||
// --- a failed submit rolls running back and marks the bubble failed ---
|
||||
{
|
||||
const started = reducer(initialState, submit);
|
||||
eq(started.running, true, "optimistic submit sets running");
|
||||
const rolled = reducer(started, { type: "send_failed", submissionId: "remote-1", error: "Send failed: refused" });
|
||||
eq(rolled.running, false, "send_failed clears running");
|
||||
eq(rolled.turnActive, false, "send_failed clears turnActive");
|
||||
eq(rolled.items.some((it) => it.kind === "user" && it.failed), true, "send_failed marks the bubble failed");
|
||||
eq(rolled.items.some((it) => it.kind === "notice"), true, "send_failed adds a notice");
|
||||
// A stale send_failed for another submission must not clobber a live turn.
|
||||
const live = reducer(started, { type: "event", e: { kind: "turn_started" } as WireEvent });
|
||||
const untouched = reducer(live, { type: "send_failed", submissionId: "remote-999", error: "x" });
|
||||
eq(untouched.running, true, "stale send_failed leaves a live turn running");
|
||||
}
|
||||
|
||||
// --- losing the connection mid-turn stops the pill ---
|
||||
{
|
||||
const running = event({ kind: "turn_started" });
|
||||
eq(running.running, true, "turn_started sets running");
|
||||
const interrupted = reducer(running, { type: "turn_interrupted" });
|
||||
eq(interrupted.running, false, "turn_interrupted clears running");
|
||||
eq(interrupted.turnActive, false, "turn_interrupted clears turnActive");
|
||||
eq(interrupted.cancellable, false, "turn_interrupted clears cancellable");
|
||||
eq(interrupted.items.some((it) => it.kind === "notice"), true, "turn_interrupted adds a notice");
|
||||
// Idempotent on an idle transcript: no phantom notice.
|
||||
const idle = reducer(initialState, { type: "turn_interrupted" });
|
||||
eq(idle.items.length, initialState.items.length, "turn_interrupted is a no-op when idle");
|
||||
}
|
||||
|
||||
// --- provider_unreachable: one self-replacing notice, cleared next turn ---
|
||||
{
|
||||
const running = event({ kind: "turn_started" });
|
||||
const first = event({ kind: "provider_unreachable", text: "dial tcp 127.0.0.1:38211: connect: connection refused" }) as typeof running;
|
||||
const withNotice = reducer(running, { type: "event", e: { kind: "provider_unreachable", text: "dial tcp 127.0.0.1:38211: connect: connection refused", retryAttempt: 1, retryMax: 10 } as WireEvent });
|
||||
const count = withNotice.items.filter((it) => it.id === "provider-unreachable").length;
|
||||
eq(count, 1, "provider_unreachable adds exactly one notice");
|
||||
const again = reducer(withNotice, { type: "event", e: { kind: "provider_unreachable", text: "still down", retryAttempt: 2, retryMax: 10 } as WireEvent });
|
||||
eq(again.items.filter((it) => it.id === "provider-unreachable").length, 1, "repeat provider_unreachable replaces the notice");
|
||||
const nextTurn = reducer(again, { type: "event", e: { kind: "turn_started" } as WireEvent });
|
||||
eq(nextTurn.items.some((it) => it.id === "provider-unreachable"), false, "next turn clears the channel notice");
|
||||
void first;
|
||||
}
|
||||
|
||||
// --- remote /status reconciliation maps onto backend_status ---
|
||||
{
|
||||
const running = event({ kind: "turn_started" });
|
||||
const idleNow = reducer(running, {
|
||||
type: "backend_status",
|
||||
running: false,
|
||||
cancellable: false,
|
||||
snapshotAt: Date.now(),
|
||||
});
|
||||
eq(idleNow.running, false, "idle /status snapshot clears running");
|
||||
eq(idleNow.turnActive, false, "idle /status snapshot clears turnActive");
|
||||
const busyAgain = reducer(idleNow, {
|
||||
type: "backend_status",
|
||||
running: true,
|
||||
snapshotAt: Date.now(),
|
||||
});
|
||||
eq(busyAgain.running, true, "running /status snapshot restores running");
|
||||
}
|
||||
|
||||
if (failed > 0) {
|
||||
process.stdout.write(`\n${failed} FAILED, ${passed} passed\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
process.stdout.write(`\n${passed} passed, 0 failed\n`);
|
||||
@@ -1,7 +1,7 @@
|
||||
// Run: tsx src/__tests__/remote-secret-dialog.test.tsx
|
||||
|
||||
import { JSDOM } from "jsdom";
|
||||
import React from "react";
|
||||
import { JSDOM } from "jsdom";
|
||||
import { act } from "react";
|
||||
|
||||
import type { AppBindings } from "../lib/bridge";
|
||||
|
||||
@@ -4,8 +4,10 @@
|
||||
// "Open Remote Web" entry, Serve progress states, the workspace home-directory
|
||||
// fallback, and the fixed remote-provider hint.
|
||||
|
||||
import { JSDOM } from "jsdom";
|
||||
import React from "react";
|
||||
import { JSDOM } from "jsdom";
|
||||
|
||||
import type { AppBindings } from "../lib/bridge";
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
@@ -48,12 +50,18 @@ onRemoteServer((s) => useRemoteStore.getState().setServer(s));
|
||||
onRemoteStatus((s) => useRemoteStore.getState().applyStatus(s));
|
||||
|
||||
const openCalls: Array<{ hostId: string; workspace: string }> = [];
|
||||
const stopCalls: Array<{ hostId: string; workspace: string }> = [];
|
||||
const statusCalls: Array<{ hostId: string; workspace: string }> = [];
|
||||
window.go = { main: { App: {
|
||||
async RemoteLastWorkspace(hostId: string) {
|
||||
return hostId === "box" ? "/srv/app" : "";
|
||||
},
|
||||
async RemoteServerStatus() {
|
||||
return { hostId: "box", workspace: "/srv/app", state: "stopped" };
|
||||
async RemoteServerStatus(hostId: string, workspace: string) {
|
||||
statusCalls.push({ hostId, workspace });
|
||||
// Echo the registered per-workspace view when one exists (the bound call
|
||||
// returns the tracked entry), else a fresh stopped view.
|
||||
return useRemoteStore.getState().servers[hostId]?.[workspace]
|
||||
?? { hostId, workspace, state: "stopped" };
|
||||
},
|
||||
async RemoteServerLogs() {
|
||||
return "";
|
||||
@@ -61,10 +69,12 @@ window.go = { main: { App: {
|
||||
async OpenRemoteWorkspace(hostId: string, workspace: string) {
|
||||
openCalls.push({ hostId, workspace });
|
||||
},
|
||||
async StopRemoteServer() {},
|
||||
} } };
|
||||
async StopRemoteServer(hostId: string, workspace: string) {
|
||||
stopCalls.push({ hostId, workspace });
|
||||
},
|
||||
} as Partial<AppBindings> as AppBindings } };
|
||||
|
||||
const host = { id: "box", label: "box", host: "box.test", port: 22, user: "dev", identityFile: "", proxyJump: "", defaultWorkspace: "/srv/app", serveInstall: "auto", useSSHConfig: false };
|
||||
const host = { id: "box", label: "box", host: "box.test", port: 22, user: "dev", identityFile: "", proxyJump: "", defaultWorkspace: "/srv/app", serveInstall: "auto", credentialMode: "remote", useSSHConfig: false };
|
||||
useRemoteStore.getState().setHosts([host]);
|
||||
useRemoteStore.getState().openExplorer("box");
|
||||
useRemoteStore.getState().setExplorerTab("server");
|
||||
@@ -124,7 +134,7 @@ await act(async () => {
|
||||
useRemoteStore.getState().setExplorerTab("server");
|
||||
useRemoteStore.getState().setHosts([
|
||||
host,
|
||||
{ id: "bare", label: "bare", host: "bare.test", port: 22, user: "dev", identityFile: "", proxyJump: "", defaultWorkspace: "", serveInstall: "auto", useSSHConfig: false },
|
||||
{ id: "bare", label: "bare", host: "bare.test", port: 22, user: "dev", identityFile: "", proxyJump: "", defaultWorkspace: "", serveInstall: "auto", credentialMode: "remote", useSSHConfig: false },
|
||||
]);
|
||||
useRemoteStore.getState().applyStatus({ hostId: "bare", state: "connected" });
|
||||
await Promise.resolve();
|
||||
@@ -142,6 +152,53 @@ ok(
|
||||
`fresh host opens the SSH login home (got ${JSON.stringify(openCalls)})`,
|
||||
);
|
||||
|
||||
// ── Per-workspace management: two registered serves on one host ──
|
||||
await act(async () => {
|
||||
useRemoteStore.getState().openExplorer("box");
|
||||
useRemoteStore.getState().setExplorerTab("server");
|
||||
await Promise.resolve();
|
||||
});
|
||||
await act(async () => {
|
||||
__emitMockRemote("server", { hostId: "box", workspace: "/srv/app", state: "ready" });
|
||||
__emitMockRemote("server", { hostId: "box", workspace: "/srv/web", state: "ready" });
|
||||
await Promise.resolve();
|
||||
});
|
||||
const stopButton = () => Array.from(document.querySelectorAll("button")).find((b) => b.textContent?.includes("Stop"));
|
||||
ok(statusCalls.some((c) => c.hostId === "box" && c.workspace === "/srv/app"), "status is fetched per workspace (the input's workspace)");
|
||||
await act(async () => {
|
||||
stopButton()?.dispatchEvent(new dom.window.MouseEvent("click", { bubbles: true }));
|
||||
await Promise.resolve();
|
||||
});
|
||||
ok(
|
||||
stopCalls.length === 1 && stopCalls[0].hostId === "box" && stopCalls[0].workspace === "/srv/app",
|
||||
`stop targets the managed workspace (got ${JSON.stringify(stopCalls)})`,
|
||||
);
|
||||
// Switching the input switches which registered serve the panel manages; the
|
||||
// other workspace's entry is untouched in the store.
|
||||
{
|
||||
const wsInput = document.querySelector<HTMLInputElement>('input[placeholder="~"]');
|
||||
const setter = Object.getOwnPropertyDescriptor(dom.window.HTMLInputElement.prototype, "value")?.set;
|
||||
await act(async () => {
|
||||
setter?.call(wsInput, "/srv/web");
|
||||
wsInput?.dispatchEvent(new dom.window.Event("input", { bubbles: true }));
|
||||
await Promise.resolve();
|
||||
});
|
||||
ok(stopButton()?.hasAttribute("disabled") === false, "switching the workspace keeps the other ready serve manageable");
|
||||
await act(async () => {
|
||||
stopButton()?.dispatchEvent(new dom.window.MouseEvent("click", { bubbles: true }));
|
||||
await Promise.resolve();
|
||||
});
|
||||
ok(
|
||||
stopCalls.length === 2 && stopCalls[1].workspace === "/srv/web",
|
||||
`stop follows the workspace switch (got ${JSON.stringify(stopCalls)})`,
|
||||
);
|
||||
const servers = useRemoteStore.getState().servers;
|
||||
ok(
|
||||
servers.box?.["/srv/app"]?.state === "ready" && servers.box?.["/srv/web"]?.state === "ready",
|
||||
"both workspaces keep independent entries in the store",
|
||||
);
|
||||
}
|
||||
|
||||
await act(async () => { root.unmount(); });
|
||||
dom.window.close();
|
||||
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
// Run: tsx src/__tests__/remote-session-cache.test.ts
|
||||
|
||||
import { JSDOM } from "jsdom";
|
||||
import {
|
||||
loadRemoteSessionCache,
|
||||
REMOTE_SESSION_CACHE_TTL_MS,
|
||||
removeRemoteSessionCache,
|
||||
saveRemoteSessionCache,
|
||||
} from "../lib/remoteSessionCache";
|
||||
import type { RemoteSessionView } from "../lib/types";
|
||||
|
||||
let passed = 0;
|
||||
function ok(value: boolean, label: string): void {
|
||||
if (!value) throw new Error(label);
|
||||
passed += 1;
|
||||
process.stdout.write(` PASS ${label}\n`);
|
||||
}
|
||||
|
||||
const dom = new JSDOM("<!doctype html>", { url: "http://localhost/" });
|
||||
globalThis.localStorage = dom.window.localStorage;
|
||||
|
||||
const key = "box\u0000~/app";
|
||||
const rows: RemoteSessionView[] = [{ name: "one", path: "/sessions/one.jsonl", title: "One", turns: 2 }];
|
||||
const now = 10_000;
|
||||
|
||||
saveRemoteSessionCache(key, rows, now);
|
||||
ok(loadRemoteSessionCache(key, now + 1)[0]?.name === "one", "restores a current versioned snapshot");
|
||||
|
||||
saveRemoteSessionCache(key, [
|
||||
{ name: "", path: "/sessions/transient-blank.jsonl", title: "", turns: 0, current: true },
|
||||
...rows,
|
||||
], now);
|
||||
const withoutBlank = loadRemoteSessionCache(key, now + 1);
|
||||
ok(withoutBlank.length === 1 && withoutBlank[0]?.name === "one",
|
||||
"does not persist a transient blank session with a generated path");
|
||||
|
||||
ok(loadRemoteSessionCache(key, now + REMOTE_SESSION_CACHE_TTL_MS + 1).length === 0, "expires stale snapshots");
|
||||
ok(localStorage.length === 0, "removes an expired snapshot from storage");
|
||||
|
||||
localStorage.setItem("projectTree:remoteSessions:" + key, JSON.stringify(rows));
|
||||
ok(loadRemoteSessionCache(key, now).length === 0, "invalidates the legacy unversioned array schema");
|
||||
ok(localStorage.length === 0, "removes an invalid legacy snapshot");
|
||||
|
||||
localStorage.setItem("projectTree:remoteSessions:" + key, JSON.stringify({
|
||||
version: 1,
|
||||
savedAt: now,
|
||||
rows: [{ name: "bad", title: "Bad", turns: 1, running: "yes" }],
|
||||
}));
|
||||
ok(loadRemoteSessionCache(key, now).length === 0, "rejects malformed optional row fields");
|
||||
ok(localStorage.length === 0, "removes a malformed versioned snapshot");
|
||||
|
||||
localStorage.setItem("projectTree:remoteSessions:" + key, JSON.stringify({
|
||||
version: 1,
|
||||
savedAt: now,
|
||||
rows: [{ name: "empty" }],
|
||||
}));
|
||||
const empty = loadRemoteSessionCache(key, now);
|
||||
ok(empty.length === 1 && empty[0]?.title === "" && empty[0]?.turns === 0,
|
||||
"normalizes Go-omitted zero-value title and turns");
|
||||
|
||||
saveRemoteSessionCache(key, rows, now);
|
||||
removeRemoteSessionCache(key);
|
||||
ok(loadRemoteSessionCache(key, now).length === 0, "explicit removal clears an unpinned project cache");
|
||||
|
||||
console.log(`\n${passed} remote session cache assertions passed.`);
|
||||
@@ -0,0 +1,800 @@
|
||||
// Run: tsx src/__tests__/remote-session-surface.test.tsx
|
||||
|
||||
import React from "react";
|
||||
import { JSDOM } from "jsdom";
|
||||
import { act } from "react";
|
||||
|
||||
import type { AppBindings } from "../lib/bridge";
|
||||
import type { TabMeta } from "../lib/types";
|
||||
import type { RemoteSessionApi } from "../lib/useRemoteSession";
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
function ok(value: boolean, label: string) {
|
||||
if (value) {
|
||||
process.stdout.write(` PASS ${label}\n`);
|
||||
passed += 1;
|
||||
} else {
|
||||
process.stdout.write(` FAIL ${label}\n`);
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
|
||||
console.log("\nRemote session surface + hook");
|
||||
const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", {
|
||||
pretendToBeVisual: true,
|
||||
url: "http://localhost/",
|
||||
});
|
||||
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
globalThis.window = dom.window as unknown as Window & typeof globalThis;
|
||||
globalThis.document = dom.window.document;
|
||||
Object.defineProperty(globalThis, "navigator", { configurable: true, value: dom.window.navigator });
|
||||
globalThis.Node = dom.window.Node;
|
||||
globalThis.Element = dom.window.Element;
|
||||
globalThis.HTMLElement = dom.window.HTMLElement;
|
||||
globalThis.Event = dom.window.Event;
|
||||
globalThis.KeyboardEvent = dom.window.KeyboardEvent;
|
||||
globalThis.localStorage = dom.window.localStorage;
|
||||
globalThis.getComputedStyle = dom.window.getComputedStyle.bind(dom.window) as typeof getComputedStyle;
|
||||
const elementProto = dom.window.HTMLElement.prototype;
|
||||
Object.defineProperty(elementProto, "attachEvent", { configurable: true, value: () => {} });
|
||||
Object.defineProperty(elementProto, "offsetHeight", {
|
||||
configurable: true,
|
||||
get(this: HTMLElement) { return this.classList.contains("transcript") ? 800 : 40; },
|
||||
});
|
||||
Object.defineProperty(elementProto, "offsetWidth", { configurable: true, get: () => 800 });
|
||||
Object.defineProperty(elementProto, "clientHeight", {
|
||||
configurable: true,
|
||||
get(this: HTMLElement) { return this.classList.contains("transcript") ? 800 : 40; },
|
||||
});
|
||||
Object.defineProperty(elementProto, "clientWidth", { configurable: true, get: () => 800 });
|
||||
(elementProto as unknown as { scrollTo: (arg?: number | ScrollToOptions) => void }).scrollTo = function (
|
||||
this: HTMLElement,
|
||||
arg?: number | ScrollToOptions,
|
||||
) {
|
||||
this.scrollTop = typeof arg === "number" ? arg : arg?.top ?? this.scrollTop;
|
||||
};
|
||||
// Transcript's virtualization calls the global rAF; jsdom only exposes it on
|
||||
// the (visual) window.
|
||||
globalThis.requestAnimationFrame = dom.window.requestAnimationFrame?.bind(dom.window) ?? ((cb: FrameRequestCallback) => setTimeout(() => cb(Date.now()), 16) as unknown as number);
|
||||
globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame?.bind(dom.window) ?? ((handle: number) => clearTimeout(handle));
|
||||
Object.defineProperty(elementProto, "detachEvent", { configurable: true, value: () => {} });
|
||||
|
||||
globalThis.getComputedStyle = dom.window.getComputedStyle.bind(dom.window);
|
||||
|
||||
const tape: string[] = [];
|
||||
let failApproval = false;
|
||||
let failOpen = false;
|
||||
let failHydration = true;
|
||||
let statusGoalStatus: "stopped" | "complete" = "stopped";
|
||||
let statusQualityFloor: "standard" | "delivery" = "standard";
|
||||
let statusModelLabel = "DeepSeek · Mock";
|
||||
let statusEffort = "high";
|
||||
let statusPendingPrompt = false, replayedPrompts: unknown[] = [];
|
||||
let snapshotHistory: unknown[] = [];
|
||||
let blockApproval = false;
|
||||
let releaseApproval: (() => void) | undefined;
|
||||
let blockAnswer = false;
|
||||
let releaseAnswer: (() => void) | undefined;
|
||||
let resolveRaceSnapshot: ((value: { history: unknown[]; status: unknown }) => void) | undefined;
|
||||
const resolveStateRaceSnapshots: Array<(value: { history: unknown[]; status: unknown }) => void> = [];
|
||||
let rotationSnapshotCalls = 0;
|
||||
let resolveRotationReconcile: ((value: { history: unknown[]; status: unknown }) => void) | undefined;
|
||||
window.go = { main: { App: {
|
||||
async RemoteTabSnapshot(tabId: string) {
|
||||
tape.push(`snapshot:${tabId}`);
|
||||
if (tabId === "tab-hydration-failure" && failHydration) throw new Error("history exceeds bridge limit");
|
||||
if (tabId === "tab-pending-model") return new Promise(() => {});
|
||||
if (tabId === "tab-race") return new Promise<{ history: unknown[]; status: unknown }>((resolve) => { resolveRaceSnapshot = resolve; });
|
||||
if (tabId === "tab-state-race") return new Promise<{ history: unknown[]; status: unknown }>((resolve) => { resolveStateRaceSnapshots.push(resolve); });
|
||||
if (tabId === "tab-reconcile-rotation") {
|
||||
rotationSnapshotCalls += 1;
|
||||
if (rotationSnapshotCalls === 2) {
|
||||
return new Promise<{ history: unknown[]; status: unknown }>((resolve) => { resolveRotationReconcile = resolve; });
|
||||
}
|
||||
return {
|
||||
history: [{ role: "assistant", content: rotationSnapshotCalls === 1 ? "initial session" : rotationSnapshotCalls === 3 ? "fresh rotated session" : "fresh reconciled turn" }],
|
||||
status: { running: false, label: "Rotation", plan: false, toolApprovalMode: "ask", goal: "" },
|
||||
};
|
||||
}
|
||||
if (tabId === "tab-tool-history") return {
|
||||
history: [
|
||||
{ role: "assistant", content: "", toolCalls: [{ id: "remote-tool", name: "bash", arguments: "{\"command\":\"go test ./...\"}" }] },
|
||||
{ role: "tool", content: "remote tool output", toolCallId: "remote-tool", toolName: "bash" },
|
||||
],
|
||||
status: { running: false, label: "Tools", plan: false, toolApprovalMode: "ask", goal: "" },
|
||||
};
|
||||
if (tabId === "tab-replay") return {
|
||||
history: [], status: { label: "Replay", plan: false, toolApprovalMode: "ask", goal: "" },
|
||||
pendingEvents: [
|
||||
{ kind: "approval_request", approval: { id: "replayed-approval", tool: "bash", subject: "pending while inactive" } },
|
||||
{ kind: "extension_surface", extension: { pluginId: "replayed-plugin", surfaceId: "replayed-form", kind: "form", form: { title: "Pending form", fields: [{ key: "region", label: "Region", kind: "input" }] } } },
|
||||
],
|
||||
};
|
||||
if (tabId === "tab-status-fallback") return { history: [] };
|
||||
return {
|
||||
history: snapshotHistory,
|
||||
checkpoints: [{
|
||||
turn: 3,
|
||||
prompt: "checkpoint",
|
||||
files: ["src/main.ts"],
|
||||
fileCount: 2,
|
||||
time: 1,
|
||||
canCode: true,
|
||||
canConversation: true,
|
||||
}],
|
||||
commands: [{ name: "remote-review", description: "Review remotely", kind: "custom", group: "skills" }],
|
||||
status: {
|
||||
label: statusModelLabel,
|
||||
plan: true,
|
||||
toolApprovalMode: "auto",
|
||||
goal: "",
|
||||
goalStatus: statusGoalStatus,
|
||||
goalRuntime: { turnsUsed: 2, turnsLimit: 0, tokensUsed: 321, requestsUsed: 3, workDurationMs: 4000, tokensLimit: 0, noProgressTurns: 0, noProgressLimit: 0, budgetExtensions: 0 },
|
||||
qualityFloor: statusQualityFloor,
|
||||
effort: { supported: true, current: statusEffort, default: "auto", levels: ["auto", "high", "max"] },
|
||||
used: 1200,
|
||||
window: 64000,
|
||||
cacheHit: 800,
|
||||
cacheMiss: 400,
|
||||
lastUsage: { promptTokens: 1000, completionTokens: 200, totalTokens: 1200, cacheHitTokens: 800, cacheMissTokens: 200 },
|
||||
balance: { available: true, display: "¥88.00" },
|
||||
sessionCostQuote: { original: { amount: "0.12", currency: "CNY" }, selected: { amount: "0.12", currency: "CNY" }, estimated: false, costComplete: true, displayComplete: true, complete: true },
|
||||
jobs: [{ id: "job-remote", kind: "bash", label: "tests", status: "running", startedAt: 1 }],
|
||||
},
|
||||
};
|
||||
},
|
||||
async RemoteTabStatus(tabId: string) {
|
||||
tape.push(`status:${tabId}`);
|
||||
if (tabId === "tab-status-fallback") {
|
||||
return { running: false, plan: true, toolApprovalMode: "yolo", goal: "", qualityFloor: "standard" };
|
||||
}
|
||||
return {
|
||||
running: false, pendingPrompt: statusPendingPrompt, backgroundJobs: 0,
|
||||
label: statusModelLabel, plan: true, toolApprovalMode: "auto", goal: "",
|
||||
goalStatus: statusGoalStatus, qualityFloor: statusQualityFloor,
|
||||
goalRuntime: { turnsUsed: 2, turnsLimit: 0, tokensUsed: 321, requestsUsed: 3, workDurationMs: 4000, tokensLimit: 0, noProgressTurns: 0, noProgressLimit: 0, budgetExtensions: 0 },
|
||||
effort: { supported: true, current: statusEffort, default: "auto", levels: ["auto", "high", "max"] },
|
||||
};
|
||||
},
|
||||
async ReplayRemoteTabPrompts(tabId: string) { tape.push(`replay-prompts:${tabId}`); return replayedPrompts; },
|
||||
async SubmitRemoteTab(tabId: string, text: string) {
|
||||
tape.push(`submit:${tabId}:${text}`);
|
||||
},
|
||||
async CancelRemoteTab(tabId: string) {
|
||||
tape.push(`cancel:${tabId}`);
|
||||
},
|
||||
async RewindRemoteTab(tabId: string, checkpointId: string, scope: string) {
|
||||
tape.push(`rewind:${tabId}:${checkpointId}:${scope}`);
|
||||
},
|
||||
async ForkRemoteTab(tabId: string, turn: number, name: string) {
|
||||
tape.push(`fork:${tabId}:${turn}:${name}`);
|
||||
},
|
||||
async SummarizeRemoteTab(tabId: string, turn: number, mode: string) {
|
||||
tape.push(`summarize:${tabId}:${turn}:${mode}`);
|
||||
},
|
||||
async CompactRemoteTab(tabId: string, instructions: string) { tape.push(`compact:${tabId}:${instructions}`); },
|
||||
async SetRemoteTabEffort(tabId: string, level: string) {
|
||||
tape.push(`effort:${tabId}:${level}`);
|
||||
statusEffort = level;
|
||||
},
|
||||
async SetRemoteTabModel(tabId: string, ref: string) {
|
||||
tape.push(`model:${tabId}:${ref}`);
|
||||
statusModelLabel = `Model · ${ref}`;
|
||||
statusEffort = "max";
|
||||
},
|
||||
async SetRemoteTabQualityFloor(tabId: string, floor: string) {
|
||||
tape.push(`quality-floor:${tabId}:${floor}`);
|
||||
statusQualityFloor = floor === "delivery" ? "delivery" : "standard";
|
||||
},
|
||||
async PauseRemoteTabGoal(tabId: string) {
|
||||
tape.push(`pause-goal:${tabId}`);
|
||||
},
|
||||
async ResumeRemoteTabGoal(tabId: string) {
|
||||
tape.push(`resume-goal:${tabId}`);
|
||||
},
|
||||
async SteerRemoteTab(tabId: string, input: string) {
|
||||
tape.push(`steer:${tabId}:${input}`);
|
||||
},
|
||||
async CancelRemoteTabJobs(tabId: string, jobIds: string[]) {
|
||||
tape.push(`cancel-jobs:${tabId}:${jobIds.join(",")}`);
|
||||
},
|
||||
async ApproveRemoteTab(tabId: string, callId: string, decision: string) {
|
||||
tape.push(`approve:${tabId}:${callId}:${decision}`);
|
||||
if (failApproval) throw new Error("tunnel write failed");
|
||||
if (blockApproval) await new Promise<void>((resolve) => { releaseApproval = resolve; });
|
||||
},
|
||||
async ResolveRemoteTabPlanDecision(tabId: string, callId: string, action: string, feedback: string) {
|
||||
tape.push(`plan-decision:${tabId}:${callId}:${action}:${feedback}`);
|
||||
},
|
||||
async AnswerRemoteTab(tabId: string, callId: string, answers: Array<{ QuestionID: string; Selected: string[] }>) {
|
||||
tape.push(`answer:${tabId}:${callId}:${JSON.stringify(answers)}`);
|
||||
if (blockAnswer) await new Promise<void>((resolve) => { releaseAnswer = resolve; });
|
||||
},
|
||||
async SubmitRemoteTabExtensionForm(tabId: string, pluginId: string, surfaceId: string, values: Record<string, unknown>) {
|
||||
tape.push(`extension-form:${tabId}:${pluginId}:${surfaceId}:${JSON.stringify(values)}`);
|
||||
},
|
||||
async OpenRemoteProjectTab(hostId: string, workspace: string, opts?: { newSession?: boolean }) {
|
||||
tape.push(`open:${hostId}:${workspace}:${opts?.newSession ? "new" : ""}`);
|
||||
if (failOpen) throw new Error("reconnect failed");
|
||||
return { ...remoteTab, remote: { hostId, workspace } };
|
||||
},
|
||||
async SetActiveTab(tabID: string) {
|
||||
tape.push(`setActive:${tabID}`);
|
||||
},
|
||||
} as Partial<AppBindings> as AppBindings } };
|
||||
|
||||
const [{ createRoot }, { RemoteSessionSurface }, { LocaleProvider }, { useRemoteSession }, { __emitMockRemoteTab }, { remoteRuntimeCommand }] = await Promise.all([
|
||||
import("react-dom/client"),
|
||||
import("../components/RemoteSessionSurface"),
|
||||
import("../lib/i18n"),
|
||||
import("../lib/useRemoteSession"),
|
||||
import("../lib/bridge"),
|
||||
import("../lib/useRemoteComposerIntegration"),
|
||||
]);
|
||||
|
||||
const remoteTab: TabMeta = {
|
||||
id: "tab-remote-1",
|
||||
scope: "project",
|
||||
workspaceRoot: "~/app",
|
||||
workspaceName: "app",
|
||||
topicId: "",
|
||||
topicTitle: "app",
|
||||
label: "gpu-box",
|
||||
ready: true,
|
||||
running: false,
|
||||
mode: "normal",
|
||||
active: true,
|
||||
cwd: "~/app",
|
||||
remote: { hostId: "gpu-box", workspace: "~/app" },
|
||||
};
|
||||
|
||||
async function flush(ticks = 4) {
|
||||
for (let i = 0; i < ticks; i++) await Promise.resolve();
|
||||
await new Promise((resolve) => setTimeout(resolve, 40));
|
||||
}
|
||||
|
||||
// The surface takes its session from the hook — the same wiring the app
|
||||
// shell uses (the shared Transcript renders the content, the composer lives
|
||||
// in the shell).
|
||||
function RemoteSurfaceHarness({ tab }: { tab: TabMeta }) {
|
||||
const session = useRemoteSession(tab.id);
|
||||
return <RemoteSessionSurface tab={tab} session={session} />;
|
||||
}
|
||||
|
||||
// ── Surface: shared Transcript renders reducer-driven items ──
|
||||
const root = createRoot(document.getElementById("root")!);
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<LocaleProvider>
|
||||
<RemoteSurfaceHarness tab={remoteTab} />
|
||||
</LocaleProvider>,
|
||||
);
|
||||
});
|
||||
await act(async () => flush());
|
||||
|
||||
ok(document.querySelector(".remote-surface__log") === null, "no bespoke log rows — the shared Transcript owns rendering");
|
||||
ok(!document.querySelector(".remote-surface__composer"), "the surface renders no composer of its own");
|
||||
|
||||
await act(async () => {
|
||||
__emitMockRemoteTab("tab-remote-1", "event", { kind: "turn_started" });
|
||||
__emitMockRemoteTab("tab-remote-1", "event", { kind: "reasoning", reasoning: "thinking hard" });
|
||||
__emitMockRemoteTab("tab-remote-1", "event", { kind: "text", text: "streaming answer" });
|
||||
await flush();
|
||||
});
|
||||
ok(document.body.textContent?.includes("streaming answer") === true, "serve text frames render through the local transcript pipeline");
|
||||
ok(document.body.textContent?.includes("thinking hard") === true, "reasoning renders through the local pipeline");
|
||||
{
|
||||
const snapshotsBefore = tape.filter((entry) => entry.startsWith("snapshot:")).length;
|
||||
await act(async () => {
|
||||
__emitMockRemoteTab("tab-remote-1", "state", { state: "ready" });
|
||||
await flush();
|
||||
});
|
||||
ok(tape.filter((entry) => entry.startsWith("snapshot:")).length > snapshotsBefore, "a ready transition re-syncs the snapshot (session reset / reconnect path)");
|
||||
}
|
||||
ok(!document.body.textContent?.includes("streaming answer"), "the re-synced snapshot replaces the old session content")
|
||||
|
||||
await act(async () => {
|
||||
const statusBefore = tape.filter((entry) => entry === "status:tab-remote-1").length;
|
||||
__emitMockRemoteTab("tab-remote-1", "event", { kind: "turn_done" });
|
||||
await flush();
|
||||
ok(tape.filter((entry) => entry === "status:tab-remote-1").length > statusBefore, "turn_done refreshes remote goal/runtime status");
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
__emitMockRemoteTab("tab-remote-1", "event", { kind: "approval_request", approval: { id: "call-9", tool: "bash", subject: "rm -rf /tmp/junk" } });
|
||||
await flush();
|
||||
});
|
||||
{
|
||||
const dialog = document.querySelector(".remote-surface__approval");
|
||||
ok(Boolean(dialog), "approval card renders");
|
||||
ok(dialog?.textContent?.includes("rm -rf /tmp/junk") === true, "approval subject renders");
|
||||
ok(dialog?.textContent?.includes("Allow matching for this session") === true
|
||||
&& dialog?.textContent?.includes("Always allow matching operations") === true,
|
||||
"remote approval exposes session and persistent scopes");
|
||||
await act(async () => {
|
||||
[...dialog!.querySelectorAll<HTMLButtonElement>(".prompt-action")].find((b) => b.textContent?.includes("Allow matching for this session"))?.click();
|
||||
await flush();
|
||||
});
|
||||
await act(async () => {
|
||||
dialog?.querySelector<HTMLButtonElement>(".decision-confirm-bar__confirm")?.click();
|
||||
await new Promise((resolve) => setTimeout(resolve, 220));
|
||||
});
|
||||
ok(tape.includes("approve:tab-remote-1:call-9:session"), "session grant forwards its approval scope");
|
||||
ok(!document.querySelector(".remote-surface__approval"), "approval card clears after deciding");
|
||||
}
|
||||
|
||||
await act(async () => {
|
||||
__emitMockRemoteTab("tab-remote-1", "event", {
|
||||
kind: "approval_request",
|
||||
approval: { id: "plan-remote", tool: "exit_plan_mode", subject: "Plan ready" },
|
||||
});
|
||||
await flush();
|
||||
});
|
||||
{
|
||||
const planDialog = document.querySelector(".remote-surface__approval");
|
||||
await act(async () => {
|
||||
[...planDialog!.querySelectorAll<HTMLButtonElement>(".prompt-action")]
|
||||
.find((button) => button.textContent?.includes("Revise plan"))?.click();
|
||||
await flush();
|
||||
});
|
||||
await act(async () => {
|
||||
const input = planDialog?.querySelector<HTMLTextAreaElement>(".plan-revision__input");
|
||||
if (input) {
|
||||
Object.getOwnPropertyDescriptor(dom.window.HTMLTextAreaElement.prototype, "value")?.set?.call(input, "cover the rollback path");
|
||||
input.dispatchEvent(new dom.window.Event("input", { bubbles: true }));
|
||||
}
|
||||
await flush();
|
||||
});
|
||||
await act(async () => {
|
||||
planDialog?.querySelector<HTMLButtonElement>(".plan-revision__actions .btn--primary")?.click();
|
||||
await new Promise((resolve) => setTimeout(resolve, 220));
|
||||
});
|
||||
ok(tape.includes("plan-decision:tab-remote-1:plan-remote:revise_plan:cover the rollback path"),
|
||||
"remote plan revision uses the specialized decision endpoint and preserves feedback");
|
||||
ok(!tape.some((entry) => entry.startsWith("approve:tab-remote-1:plan-remote")),
|
||||
"remote plan decisions never collapse into generic approval booleans");
|
||||
}
|
||||
|
||||
await act(async () => {
|
||||
failApproval = true;
|
||||
__emitMockRemoteTab("tab-remote-1", "event", { kind: "approval_request", approval: { id: "call-fail", tool: "bash", subject: "keep this prompt" } });
|
||||
await flush();
|
||||
});
|
||||
{
|
||||
await act(async () => {
|
||||
const failedDialog = document.querySelector(".remote-surface__approval");
|
||||
[...failedDialog!.querySelectorAll<HTMLButtonElement>(".prompt-action")].find((b) => b.textContent?.includes("Allow once"))?.click();
|
||||
await flush();
|
||||
});
|
||||
await act(async () => {
|
||||
const failedDialog = document.querySelector(".remote-surface__approval");
|
||||
failedDialog?.querySelector<HTMLButtonElement>(".decision-confirm-bar__confirm")?.click();
|
||||
await new Promise((resolve) => setTimeout(resolve, 220));
|
||||
await flush();
|
||||
});
|
||||
ok(Boolean(document.querySelector(".remote-surface__approval")), "a failed approval command preserves the decision card");
|
||||
ok(document.body.textContent?.includes("tunnel write failed") === true, "a failed approval command surfaces an actionable error");
|
||||
failApproval = false;
|
||||
}
|
||||
|
||||
await act(async () => {
|
||||
__emitMockRemoteTab("tab-remote-1", "event", { kind: "ask_request", ask: { id: "ask-7", questions: [{ id: "q1", prompt: "Deploy now?", options: [{ label: "yes" }, { label: "no" }] }] } });
|
||||
await flush();
|
||||
});
|
||||
{
|
||||
const dialog = document.querySelector(".prompt-shelf--ask");
|
||||
ok(Boolean(dialog), "ask card renders");
|
||||
ok(dialog?.textContent?.includes("Deploy now?") === true, "ask prompt renders");
|
||||
await act(async () => {
|
||||
[...document.querySelectorAll<HTMLButtonElement>("button")].find((b) => b.textContent?.trim() === "yes")?.click();
|
||||
await flush();
|
||||
});
|
||||
ok(!tape.some((entry) => entry.startsWith("answer:tab-remote-1:ask-7")), "selecting an option keeps the ask open until explicit submit");
|
||||
await act(async () => {
|
||||
[...document.querySelectorAll<HTMLButtonElement>("button")].find((b) => b.textContent?.trim() === "Submit")?.click();
|
||||
await flush();
|
||||
});
|
||||
ok(tape.includes('answer:tab-remote-1:ask-7:[{"QuestionID":"q1","Selected":["yes"]}]'), "submit forwards the question id and complete selection batch");
|
||||
}
|
||||
|
||||
await act(async () => {
|
||||
__emitMockRemoteTab("tab-remote-1", "event", { kind: "ask_request", ask: { id: "ask-custom", questions: [{ id: "q-custom", prompt: "Where?", options: [{ label: "staging" }] }] } });
|
||||
await flush();
|
||||
});
|
||||
await act(async () => {
|
||||
[...document.querySelectorAll<HTMLButtonElement>("button")].find((button) => button.textContent?.trim() === "Other answer")?.click();
|
||||
await flush();
|
||||
});
|
||||
await act(async () => {
|
||||
const input = document.querySelector<HTMLInputElement>(".ask-shelf__custom");
|
||||
if (input) {
|
||||
Object.getOwnPropertyDescriptor(dom.window.HTMLInputElement.prototype, "value")?.set?.call(input, "canary");
|
||||
input.dispatchEvent(new dom.window.Event("input", { bubbles: true }));
|
||||
}
|
||||
await flush();
|
||||
});
|
||||
await act(async () => {
|
||||
[...document.querySelectorAll<HTMLButtonElement>("button")].find((button) => button.textContent?.trim() === "Submit")?.click();
|
||||
await flush();
|
||||
});
|
||||
ok(tape.includes('answer:tab-remote-1:ask-custom:[{"QuestionID":"q-custom","Selected":["canary"]}]'),
|
||||
"custom AskCard text serializes as the remote question selection");
|
||||
|
||||
await act(async () => {
|
||||
__emitMockRemoteTab("tab-remote-1", "event", {
|
||||
kind: "extension_surface",
|
||||
extension: {
|
||||
pluginId: "remote-plugin", surfaceId: "setup", kind: "form",
|
||||
form: { title: "Remote setup", fields: [{ key: "region", label: "Region", kind: "input", required: true, default: "us-west" }] },
|
||||
},
|
||||
});
|
||||
await flush();
|
||||
});
|
||||
{
|
||||
const form = document.querySelector(".extension-form");
|
||||
ok(Boolean(form) && document.body.textContent?.includes("Remote setup") === true, "remote extension form renders on the shared surface");
|
||||
await act(async () => {
|
||||
form?.closest(".prompt-shelf")?.querySelector<HTMLButtonElement>(".decision-confirm-bar__confirm")?.click();
|
||||
await flush();
|
||||
});
|
||||
ok(tape.includes('extension-form:tab-remote-1:remote-plugin:setup:{"region":"us-west"}'),
|
||||
"remote extension form submits through the Serve proxy");
|
||||
ok(!document.querySelector(".extension-form"), "remote extension form clears after an accepted submission");
|
||||
}
|
||||
|
||||
await act(async () => {
|
||||
__emitMockRemoteTab("tab-remote-1", "state", { state: "serve_down", error: "tunnel closed" });
|
||||
await flush();
|
||||
});
|
||||
{
|
||||
const warning = document.querySelector(".remote-surface--warning");
|
||||
ok(Boolean(warning), "serve_down renders the warning state");
|
||||
ok(warning?.textContent?.includes("tunnel closed") === true, "serve error detail renders");
|
||||
await act(async () => {
|
||||
warning?.querySelector<HTMLButtonElement>("button")?.click();
|
||||
await flush();
|
||||
});
|
||||
ok(tape.includes("open:gpu-box:~/app:"), "serve_down retry preserves the backend's parked session target");
|
||||
failOpen = true;
|
||||
await act(async () => {
|
||||
warning?.querySelector<HTMLButtonElement>("button")?.click();
|
||||
await flush();
|
||||
});
|
||||
ok(warning?.textContent?.includes("reconnect failed") === true, "serve_down retry failures render on the surface");
|
||||
failOpen = false;
|
||||
}
|
||||
|
||||
// Mid-flight disconnected events also refuse the placeholder.
|
||||
await act(async () => { __emitMockRemoteTab("tab-remote-1", "state", { state: "disconnected" }); await flush(); });
|
||||
{
|
||||
ok(!document.querySelector(".remote-surface--disconnected"), "live disconnected events do not render the placeholder");
|
||||
ok(Boolean(document.querySelector(".remote-surface--waiting")), "live disconnected events show connecting instead");
|
||||
ok(tape.includes("setActive:tab-remote-1"), "live disconnected events trigger backend revival");
|
||||
}
|
||||
|
||||
await act(async () => root.unmount());
|
||||
|
||||
let restoredShellProbe: RemoteSessionApi | undefined;
|
||||
function RestoredShellProbe() { restoredShellProbe = useRemoteSession("tab-restored-shell", "disconnected"); return null; }
|
||||
const restoredShellRoot = createRoot(document.getElementById("root")!);
|
||||
await act(async () => { restoredShellRoot.render(<RestoredShellProbe />); await flush(); });
|
||||
ok(restoredShellProbe?.state === "ready" && restoredShellProbe.hydrated, "restored shells recover through the authoritative snapshot when the ready event is missed");
|
||||
ok(tape.includes("setActive:tab-restored-shell"), "restored disconnected shells trigger backend revival");
|
||||
await act(async () => restoredShellRoot.unmount());
|
||||
|
||||
// ── Hook: optimistic user bubble + command forwarding ──
|
||||
let probe: RemoteSessionApi | undefined;
|
||||
function HookProbe({ tabId = "tab-remote-2" }: { tabId?: string }) { probe = useRemoteSession(tabId); return null; }
|
||||
const probeRoot = createRoot(document.createElement("div"));
|
||||
await act(async () => { probeRoot.render(<LocaleProvider><HookProbe /></LocaleProvider>); await flush(); });
|
||||
ok(probe?.state === "ready", "a successful fenced snapshot recovers a ready event missed before listener mount");
|
||||
ok(probe?.composerProfile?.collaborationMode === "plan" && probe?.composerProfile?.toolApprovalMode === "auto" && probe.goalRuntime?.tokensUsed === 321,
|
||||
"snapshot status hydrates the authoritative remote composer profile");
|
||||
ok(probe?.effort?.current === "high" && probe?.transcript.checkpoints[0]?.turn === 3
|
||||
&& probe?.transcript.checkpoints[0]?.fileCount === 2 && probe?.transcript.checkpoints[0]?.files.length === 1,
|
||||
"snapshot status hydrates effort and rewind checkpoints");
|
||||
ok(probe?.commands.length === 1 && probe.commands[0]?.name === "remote-review",
|
||||
"remote hydration exposes the Serve command catalog instead of local commands");
|
||||
ok(probe?.transcript.context.used === 1200 && probe.transcript.context.window === 64000
|
||||
&& probe.transcript.balance?.display === "¥88.00" && probe.transcript.sessionCost === 0.12
|
||||
&& probe.transcript.jobs[0]?.id === "job-remote" && probe.transcript.lastTurnOutputTokens === 200,
|
||||
"snapshot status hydrates remote context, usage, balance, cost, and jobs");
|
||||
ok(remoteRuntimeCommand("/goal write regression tests") === undefined && remoteRuntimeCommand("/goal --strict write regression tests") === undefined, "goal-setting commands remain conversational turns");
|
||||
ok(remoteRuntimeCommand("/goal")?.method === "runManagementCommand" && remoteRuntimeCommand("/goal status")?.method === "runManagementCommand"
|
||||
&& remoteRuntimeCommand("/goal --strict pause")?.method === "runManagementCommand",
|
||||
"goal status and lifecycle actions remain synchronous management commands");
|
||||
ok(remoteRuntimeCommand("/branch experiment")?.rehydrate === true && remoteRuntimeCommand("/switch main")?.rehydrate === true
|
||||
&& remoteRuntimeCommand("/rewind 3 conversation")?.rehydrate === true && remoteRuntimeCommand("/context")?.rehydrate !== true
|
||||
&& remoteRuntimeCommand("/compact preserve tests")?.method === "compact",
|
||||
"session-changing management commands request authoritative rehydration");
|
||||
statusPendingPrompt = true; replayedPrompts = [{ kind: "approval_request", approval: { id: "recovered-prompt", tool: "bash", subject: "replayed after drop" } }];
|
||||
await act(async () => { await probe?.runManagementCommand("/context"); await flush(); });
|
||||
ok(tape.includes("replay-prompts:tab-remote-2") && probe?.transcript.approval?.id === "recovered-prompt", "pending status recovers an SSE-dropped prompt through Serve");
|
||||
await act(async () => { await probe?.approve("recovered-prompt", "deny"); await flush(); }); statusPendingPrompt = false; replayedPrompts = [];
|
||||
let remoteLiveNotifications = 0;
|
||||
const unsubscribeRemoteLive = probe?.liveStore.subscribe("tab-remote-2", () => { remoteLiveNotifications += 1; });
|
||||
await act(async () => {
|
||||
__emitMockRemoteTab("tab-remote-2", "event", { kind: "turn_started", turnStartedAt: 1234 });
|
||||
__emitMockRemoteTab("tab-remote-2", "event", { kind: "text", text: "remote live ticker" });
|
||||
await flush();
|
||||
});
|
||||
ok(probe?.liveStore.getSnapshot("tab-remote-2")?.text === "remote live ticker" && remoteLiveNotifications > 0,
|
||||
"remote live-store notifications drive the shared composer ticker");
|
||||
unsubscribeRemoteLive?.();
|
||||
const turnDoneGeneration = probe?.surfaceGeneration;
|
||||
statusGoalStatus = "complete";
|
||||
snapshotHistory = [{ role: "user", content: "server-side prompt" }, { role: "assistant", content: "reconciled final answer" }];
|
||||
await act(async () => {
|
||||
__emitMockRemoteTab("tab-remote-2", "event", { kind: "turn_done" });
|
||||
await flush();
|
||||
});
|
||||
ok(probe?.composerProfile?.goalStatus === "complete" && probe.surfaceGeneration === turnDoneGeneration,
|
||||
"turn_done refreshes goal status without replacing the transcript surface");
|
||||
ok(probe?.transcript.items.some((item) => item.kind === "assistant" && item.text === "reconciled final answer") === true,
|
||||
"turn_done reconciles durable history after dropped Serve frames");
|
||||
|
||||
await act(async () => {
|
||||
__emitMockRemoteTab("tab-remote-2", "event", { kind: "approval_request", approval: { id: "approval-old", tool: "bash", subject: "old" } });
|
||||
await flush();
|
||||
});
|
||||
blockApproval = true;
|
||||
let oldApproval: Promise<void> | undefined;
|
||||
await act(async () => {
|
||||
oldApproval = probe?.approve("approval-old", "allow");
|
||||
await flush();
|
||||
__emitMockRemoteTab("tab-remote-2", "event", { kind: "approval_request", approval: { id: "approval-next", tool: "bash", subject: "next" } });
|
||||
releaseApproval?.();
|
||||
await oldApproval;
|
||||
await flush();
|
||||
});
|
||||
blockApproval = false;
|
||||
ok(probe?.transcript.approval?.id === "approval-next", "an answered approval cannot clear the next prompt");
|
||||
|
||||
await act(async () => {
|
||||
__emitMockRemoteTab("tab-remote-2", "event", { kind: "ask_request", ask: { id: "ask-old", questions: [] } });
|
||||
await flush();
|
||||
});
|
||||
blockAnswer = true;
|
||||
let oldAnswer: Promise<void> | undefined;
|
||||
await act(async () => {
|
||||
oldAnswer = probe?.answer("ask-old", []);
|
||||
await flush();
|
||||
__emitMockRemoteTab("tab-remote-2", "event", { kind: "ask_request", ask: { id: "ask-next", questions: [] } });
|
||||
releaseAnswer?.();
|
||||
await oldAnswer;
|
||||
await flush();
|
||||
});
|
||||
blockAnswer = false;
|
||||
ok(probe?.transcript.ask?.id === "ask-next", "an answered ask cannot clear the next prompt");
|
||||
await act(async () => { await probe?.submit("run tests"); await flush(); });
|
||||
ok(Boolean(probe?.transcript.items.some((item) => item.kind === "user" && item.text === "run tests")), "submit adds the optimistic user bubble through the shared reducer");
|
||||
await act(async () => { await probe?.runManagementCommand("/context"); await flush(); });
|
||||
ok(tape.includes("submit:tab-remote-2:/context") && tape.includes("status:tab-remote-2"),
|
||||
"management commands dispatch without conversational admission and refresh status");
|
||||
ok(!probe?.transcript.items.some((item) => item.kind === "user" && item.text === "/context"),
|
||||
"management commands do not add an optimistic user turn");
|
||||
const beforeCompactGeneration = probe?.surfaceGeneration;
|
||||
snapshotHistory = [{ role: "assistant", content: "compacted remote history" }];
|
||||
await act(async () => { await probe?.compact("preserve tests"); await flush(); });
|
||||
ok(tape.includes("compact:tab-remote-2:preserve tests") && probe?.surfaceGeneration === (beforeCompactGeneration ?? 0) + 1 && probe.transcript.items.some((item) => item.kind === "assistant" && item.text === "compacted remote history"), "remote compact waits for the dedicated endpoint and rehydrates history");
|
||||
const beforeSwitchGeneration = probe?.surfaceGeneration;
|
||||
snapshotHistory = [{ role: "assistant", content: "adopted switched session" }];
|
||||
await act(async () => { await probe?.runManagementCommand("/switch feature", true); await flush(); });
|
||||
ok(tape.includes("submit:tab-remote-2:/switch feature") && probe?.surfaceGeneration === (beforeSwitchGeneration ?? 0) + 1
|
||||
&& probe.transcript.items.some((item) => item.kind === "assistant" && item.text === "adopted switched session"),
|
||||
"session-changing management commands replace history from an authoritative snapshot");
|
||||
ok(!probe?.transcript.items.some((item) => item.kind === "user" && item.text === "/switch feature"),
|
||||
"session-changing management commands still avoid an optimistic user turn");
|
||||
await act(async () => {
|
||||
await probe?.cancelTurn();
|
||||
await probe?.approve("call-1", "allow");
|
||||
await probe?.answer("ask-1", [{ QuestionID: "q1", Selected: ["yes"] }]);
|
||||
await probe?.rewind(3, "code");
|
||||
await probe?.rewind(3, "fork");
|
||||
await probe?.rewind(3, "summ-from");
|
||||
await probe?.rewind(3, "summ-upto");
|
||||
await flush();
|
||||
});
|
||||
const metadataGeneration = probe?.surfaceGeneration;
|
||||
statusGoalStatus = "complete";
|
||||
await act(async () => {
|
||||
await probe?.setModel("remote/new-model");
|
||||
await probe?.setEffort("high");
|
||||
await probe?.setQualityFloor("delivery");
|
||||
await probe?.pauseGoal();
|
||||
await probe?.resumeGoal();
|
||||
await probe?.steer("narrow the change");
|
||||
await probe?.cancelJob("job-remote");
|
||||
await flush();
|
||||
});
|
||||
ok(probe?.surfaceGeneration === metadataGeneration, "metadata-only remote commands preserve the transcript generation and viewport");
|
||||
ok(probe?.composerProfile?.goalStatus === "complete" && probe.composerProfile.qualityFloor === "delivery",
|
||||
"status-only refresh updates goal status and quality floor");
|
||||
ok(probe?.modelLabel === "Model · remote/new-model" && probe.effort?.current === "high",
|
||||
"model switching refreshes the authoritative remote profile before the next turn");
|
||||
for (const want of [
|
||||
"submit:tab-remote-2:run tests",
|
||||
"cancel:tab-remote-2",
|
||||
"approve:tab-remote-2:call-1:allow",
|
||||
'answer:tab-remote-2:ask-1:[{"QuestionID":"q1","Selected":["yes"]}]',
|
||||
"rewind:tab-remote-2:3:code",
|
||||
"fork:tab-remote-2:3:",
|
||||
"summarize:tab-remote-2:3:from",
|
||||
"summarize:tab-remote-2:3:upto",
|
||||
"model:tab-remote-2:remote/new-model",
|
||||
"effort:tab-remote-2:high",
|
||||
"quality-floor:tab-remote-2:delivery",
|
||||
"pause-goal:tab-remote-2",
|
||||
"resume-goal:tab-remote-2",
|
||||
"steer:tab-remote-2:narrow the change",
|
||||
"cancel-jobs:tab-remote-2:job-remote",
|
||||
]) {
|
||||
ok(tape.includes(want), `command forwarded: ${want}`);
|
||||
}
|
||||
|
||||
await act(async () => {
|
||||
probeRoot.render(<LocaleProvider><HookProbe tabId="tab-pending-model" /></LocaleProvider>);
|
||||
await Promise.resolve();
|
||||
});
|
||||
ok(probe?.modelLabel === "", "switching remote tabs clears the previous model label before hydration");
|
||||
|
||||
await act(async () => probeRoot.unmount());
|
||||
|
||||
let fallbackProbe: RemoteSessionApi | undefined;
|
||||
function FallbackProbe() {
|
||||
fallbackProbe = useRemoteSession("tab-status-fallback");
|
||||
return null;
|
||||
}
|
||||
const fallbackRoot = createRoot(document.createElement("div"));
|
||||
await act(async () => {
|
||||
fallbackRoot.render(<LocaleProvider><FallbackProbe /></LocaleProvider>);
|
||||
await flush();
|
||||
});
|
||||
ok(fallbackProbe?.hydrated === true && fallbackProbe.composerProfile?.collaborationMode === "plan"
|
||||
&& fallbackProbe.composerProfile.toolApprovalMode === "yolo"
|
||||
&& tape.includes("status:tab-status-fallback"),
|
||||
"missing aggregate status is fetched before the remote composer becomes ready");
|
||||
await act(async () => fallbackRoot.unmount());
|
||||
|
||||
let toolProbe: RemoteSessionApi | undefined;
|
||||
function ToolProbe() {
|
||||
toolProbe = useRemoteSession("tab-tool-history");
|
||||
return null;
|
||||
}
|
||||
const toolRoot = createRoot(document.createElement("div"));
|
||||
await act(async () => {
|
||||
toolRoot.render(<LocaleProvider><ToolProbe /></LocaleProvider>);
|
||||
await flush();
|
||||
});
|
||||
const remoteTool = toolProbe?.transcript.items.find((item) => item.kind === "tool" && item.id === "remote-tool");
|
||||
ok(remoteTool?.kind === "tool" && remoteTool.args.includes("go test ./...")
|
||||
&& remoteTool.output === "remote tool output" && remoteTool.dataArchived !== true,
|
||||
"remote history retains expandable tool args and output without a local rehydrate endpoint");
|
||||
await act(async () => toolRoot.unmount());
|
||||
|
||||
let failureProbe: RemoteSessionApi | undefined;
|
||||
function FailureProbe() {
|
||||
failureProbe = useRemoteSession("tab-hydration-failure", "ready");
|
||||
return null;
|
||||
}
|
||||
const failureRoot = createRoot(document.createElement("div"));
|
||||
await act(async () => {
|
||||
failureRoot.render(<LocaleProvider><FailureProbe /></LocaleProvider>);
|
||||
});
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 2200));
|
||||
});
|
||||
ok(failureProbe?.hydrated === false && failureProbe.error.includes("history exceeds bridge limit"),
|
||||
"exhausted ready-session hydration exposes a retryable error");
|
||||
failHydration = false;
|
||||
await act(async () => {
|
||||
await failureProbe?.retryHydration();
|
||||
await flush();
|
||||
});
|
||||
ok(failureProbe?.hydrated === true && failureProbe.error === "", "explicit hydration retry recovers the surface");
|
||||
await act(async () => failureRoot.unmount());
|
||||
|
||||
// ── Hydration fence: an SSE event delivered while the snapshot is in flight
|
||||
// is replayed after history instead of being overwritten by it. ──
|
||||
let raceProbe: RemoteSessionApi | undefined;
|
||||
function RaceProbe() {
|
||||
raceProbe = useRemoteSession("tab-race");
|
||||
return null;
|
||||
}
|
||||
const raceRoot = createRoot(document.createElement("div"));
|
||||
await act(async () => {
|
||||
raceRoot.render(<LocaleProvider><RaceProbe /></LocaleProvider>);
|
||||
await Promise.resolve();
|
||||
});
|
||||
await act(async () => {
|
||||
__emitMockRemoteTab("tab-race", "event", { kind: "turn_started" });
|
||||
__emitMockRemoteTab("tab-race", "event", { kind: "text", text: "arrived during hydration" });
|
||||
resolveRaceSnapshot?.({
|
||||
history: [],
|
||||
status: { running: true, label: "Race", plan: false, toolApprovalMode: "ask", goal: "" },
|
||||
});
|
||||
await flush();
|
||||
});
|
||||
ok(raceProbe?.transcript.live.text === "arrived during hydration", "hydration replays concurrently delivered remote events");
|
||||
await act(async () => raceRoot.unmount());
|
||||
|
||||
let stateRaceProbe: RemoteSessionApi | undefined;
|
||||
function StateRaceProbe() {
|
||||
stateRaceProbe = useRemoteSession("tab-state-race");
|
||||
return null;
|
||||
}
|
||||
const stateRaceRoot = createRoot(document.createElement("div"));
|
||||
await act(async () => {
|
||||
stateRaceRoot.render(<LocaleProvider><StateRaceProbe /></LocaleProvider>);
|
||||
await Promise.resolve();
|
||||
});
|
||||
await act(async () => {
|
||||
__emitMockRemoteTab("tab-state-race", "state", { state: "reconnecting" });
|
||||
__emitMockRemoteTab("tab-state-race", "state", { state: "ready" });
|
||||
resolveStateRaceSnapshots[0]?.({
|
||||
history: [],
|
||||
status: { running: false, label: "Stale", plan: false, toolApprovalMode: "ask", goal: "" },
|
||||
});
|
||||
await flush();
|
||||
});
|
||||
await act(async () => {
|
||||
resolveStateRaceSnapshots[1]?.({
|
||||
history: [{ role: "assistant", content: "fresh generation" }],
|
||||
status: { running: false, label: "Fresh", plan: false, toolApprovalMode: "ask", goal: "" },
|
||||
});
|
||||
await flush();
|
||||
});
|
||||
ok(stateRaceProbe?.state === "ready" && stateRaceProbe.hydrated === true
|
||||
&& stateRaceProbe.modelLabel === "Fresh"
|
||||
&& stateRaceProbe.transcript.items.some((item) => item.kind === "assistant" && item.text === "fresh generation"),
|
||||
"a ready generation re-hydrates after discarding the stale in-flight snapshot");
|
||||
await act(async () => stateRaceRoot.unmount());
|
||||
|
||||
// Post-turn reconciliation can overlap a ready-to-ready /new, /clear, or
|
||||
// resume. The old history response must not replace the newly adopted session.
|
||||
let rotationProbe: RemoteSessionApi | undefined;
|
||||
function RotationProbe() { rotationProbe = useRemoteSession("tab-reconcile-rotation"); return null; }
|
||||
const rotationRoot = createRoot(document.createElement("div"));
|
||||
await act(async () => { rotationRoot.render(<LocaleProvider><RotationProbe /></LocaleProvider>); await flush(); });
|
||||
await act(async () => {
|
||||
__emitMockRemoteTab("tab-reconcile-rotation", "event", { kind: "turn_started" });
|
||||
__emitMockRemoteTab("tab-reconcile-rotation", "event", { kind: "turn_done" });
|
||||
await Promise.resolve();
|
||||
__emitMockRemoteTab("tab-reconcile-rotation", "state", { state: "ready" });
|
||||
await flush();
|
||||
});
|
||||
ok(rotationProbe?.transcript.items.some((item) => item.kind === "assistant" && item.text === "fresh rotated session") === true,
|
||||
"ready-to-ready rotation hydrates the adopted session while old reconciliation is pending");
|
||||
await act(async () => { __emitMockRemoteTab("tab-reconcile-rotation", "event", { kind: "turn_started" }); __emitMockRemoteTab("tab-reconcile-rotation", "event", { kind: "turn_done" }); await Promise.resolve(); });
|
||||
await act(async () => {
|
||||
resolveRotationReconcile?.({
|
||||
history: [{ role: "assistant", content: "stale previous session" }],
|
||||
status: { running: false, label: "Stale", plan: false, toolApprovalMode: "ask", goal: "" },
|
||||
});
|
||||
await flush();
|
||||
});
|
||||
ok(rotationProbe?.transcript.items.some((item) => item.kind === "assistant" && item.text === "fresh reconciled turn") === true
|
||||
&& !rotationProbe.transcript.items.some((item) => item.kind === "assistant" && item.text === "stale previous session"),
|
||||
"session generation fence rejects stale history and hands reconciliation to the new generation");
|
||||
await act(async () => rotationRoot.unmount());
|
||||
|
||||
// Pending prompt frames retained by Desktop are replayed by the next snapshot,
|
||||
// which restores decisions missed while the tab had no frontend listener.
|
||||
let replayProbe: RemoteSessionApi | undefined;
|
||||
function ReplayProbe() { replayProbe = useRemoteSession("tab-replay"); return null; }
|
||||
const replayRoot = createRoot(document.createElement("div"));
|
||||
await act(async () => {
|
||||
replayRoot.render(<LocaleProvider><ReplayProbe /></LocaleProvider>);
|
||||
await flush();
|
||||
});
|
||||
ok(replayProbe?.transcript.approval?.id === "replayed-approval", "snapshot replays a prompt emitted while the remote tab was inactive");
|
||||
ok(replayProbe?.transcript.extensionForm?.pluginId === "replayed-plugin" && replayProbe.transcript.extensionForm.surfaceId === "replayed-form", "snapshot replays an extension form emitted while the remote tab was inactive");
|
||||
await act(async () => { replayProbe?.drainApprovals(["different-approval"]); await flush(); });
|
||||
ok(replayProbe?.transcript.approval?.id === "replayed-approval", "a remote mode transaction preserves approvals it did not drain");
|
||||
await act(async () => { replayProbe?.drainApprovals(["replayed-approval"]); await flush(); });
|
||||
ok(replayProbe?.transcript.approval === undefined, "a remote mode transaction clears the exact approval it auto-allowed");
|
||||
await act(async () => replayRoot.unmount());
|
||||
dom.window.close();
|
||||
process.stdout.write(`\n${passed} passed, ${failed} failed\n`);
|
||||
if (failed > 0) process.exit(1);
|
||||
@@ -26,7 +26,7 @@ function reset() {
|
||||
}
|
||||
|
||||
useRemoteStore.getState().setHosts([
|
||||
{ id: "box", label: "box", host: "box.test", port: 22, user: "dev", identityFile: "", proxyJump: "", defaultWorkspace: "/srv/app", serveInstall: "auto", useSSHConfig: false },
|
||||
{ id: "box", label: "box", host: "box.test", port: 22, user: "dev", identityFile: "", proxyJump: "", defaultWorkspace: "/srv/app", serveInstall: "auto", credentialMode: "remote", useSSHConfig: false },
|
||||
]);
|
||||
eq(useRemoteStore.getState().hosts[0]?.defaultWorkspace, "/srv/app", "configured hosts hydrate persistent UI state");
|
||||
|
||||
@@ -180,5 +180,19 @@ eq(useRemoteStore.getState().statuses.timeout?.state, "connecting", "connection
|
||||
eq(received, null, "unsubscribe stops delivery");
|
||||
})();
|
||||
|
||||
// setServer keys per-workspace entries under the host; one workspace's events
|
||||
// never overwrite another's.
|
||||
(function testPerWorkspaceServers() {
|
||||
const set = useRemoteStore.getState().setServer;
|
||||
set({ hostId: "box", workspace: "/srv/app", state: "ready" });
|
||||
set({ hostId: "box", workspace: "/srv/web", state: "starting" });
|
||||
const servers = useRemoteStore.getState().servers;
|
||||
eq(servers.box?.["/srv/app"]?.state, "ready", "first workspace keeps its entry");
|
||||
eq(servers.box?.["/srv/web"]?.state, "starting", "second workspace gets its own entry");
|
||||
set({ hostId: "box", workspace: "/srv/web", state: "ready" });
|
||||
eq(useRemoteStore.getState().servers.box?.["/srv/app"]?.state, "ready", "updating one workspace leaves the other untouched");
|
||||
eq(useRemoteStore.getState().servers.box?.["/srv/web"]?.state, "ready", "updated workspace reflects the new state");
|
||||
})();
|
||||
|
||||
process.stdout.write(`\n${passed} passed, ${failed} failed\n`);
|
||||
if (failed > 0) process.exit(1);
|
||||
@@ -0,0 +1,165 @@
|
||||
// Run: node --import ./scripts/svg-stub-register.mjs --import tsx src/__tests__/remote-switch-from-local.test.tsx
|
||||
//
|
||||
// Local → remote activation must not run HistorySliceForTab and must not
|
||||
// revert to the previous local tab when that local hydrate fails.
|
||||
|
||||
import { JSDOM } from "jsdom";
|
||||
import React, { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import type { AppBindings } from "../lib/bridge";
|
||||
import { useController } from "../lib/useController";
|
||||
import type { HistorySlice, HistorySliceRequest, TabMeta } from "../lib/types";
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
function ok(value: boolean, label: string) {
|
||||
if (value) {
|
||||
process.stdout.write(` PASS ${label}\n`);
|
||||
passed += 1;
|
||||
} else {
|
||||
process.stdout.write(` FAIL ${label}\n`);
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
function eq(actual: unknown, expected: unknown, label: string) {
|
||||
if (actual === expected) ok(true, label);
|
||||
else ok(false, `${label}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
|
||||
}
|
||||
function flushPromises(): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
async function waitFor(label: string, predicate: () => boolean) {
|
||||
for (let attempt = 0; attempt < 40; attempt += 1) {
|
||||
await act(async () => { await flushPromises(); });
|
||||
if (predicate()) return;
|
||||
}
|
||||
throw new Error(`timed out waiting for ${label}`);
|
||||
}
|
||||
|
||||
const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", {
|
||||
pretendToBeVisual: true,
|
||||
url: "http://localhost/",
|
||||
});
|
||||
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
globalThis.window = dom.window as unknown as Window & typeof globalThis;
|
||||
globalThis.document = dom.window.document;
|
||||
Object.defineProperty(globalThis, "navigator", { configurable: true, value: dom.window.navigator });
|
||||
globalThis.HTMLElement = dom.window.HTMLElement;
|
||||
globalThis.requestAnimationFrame = dom.window.requestAnimationFrame?.bind(dom.window) ?? ((cb: FrameRequestCallback) => setTimeout(() => cb(Date.now()), 16) as unknown as number);
|
||||
globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame?.bind(dom.window) ?? ((handle: number) => clearTimeout(handle));
|
||||
|
||||
const localTab: TabMeta = {
|
||||
id: "tab-local",
|
||||
scope: "project",
|
||||
workspaceRoot: "/repo/local",
|
||||
workspaceName: "local",
|
||||
workspacePath: "/repo/local",
|
||||
topicId: "topic-local",
|
||||
topicTitle: "local",
|
||||
sessionPath: "/repo/local/sessions/local.jsonl",
|
||||
label: "local-model",
|
||||
ready: true,
|
||||
running: false,
|
||||
mode: "normal",
|
||||
toolApprovalMode: "ask",
|
||||
tokenMode: "full",
|
||||
active: true,
|
||||
cwd: "/repo/local",
|
||||
};
|
||||
const remoteTab: TabMeta = {
|
||||
id: "tab-remote",
|
||||
scope: "project",
|
||||
workspaceRoot: "~/app",
|
||||
workspaceName: "app",
|
||||
topicId: "",
|
||||
topicTitle: "remote session",
|
||||
label: "gpu-box",
|
||||
ready: true,
|
||||
running: false,
|
||||
mode: "normal",
|
||||
toolApprovalMode: "ask",
|
||||
tokenMode: "full",
|
||||
active: false,
|
||||
cwd: "~/app",
|
||||
remote: { hostId: "gpu-box", workspace: "~/app" },
|
||||
remoteState: "ready",
|
||||
};
|
||||
|
||||
let backendActiveId = "tab-local";
|
||||
let historySliceCalls: string[] = [];
|
||||
let setActiveCalls: string[] = [];
|
||||
|
||||
window.go = {
|
||||
main: {
|
||||
App: {
|
||||
ListTabs: async () => {
|
||||
const tabs = [localTab, remoteTab].map((tab) => ({ ...tab, active: tab.id === backendActiveId }));
|
||||
return tabs;
|
||||
},
|
||||
SetActiveTab: async (tabID: string) => {
|
||||
setActiveCalls.push(tabID);
|
||||
backendActiveId = tabID;
|
||||
},
|
||||
HistorySliceForTab: async (tabID: string, _req: HistorySliceRequest): Promise<HistorySlice> => {
|
||||
historySliceCalls.push(tabID);
|
||||
if (tabID === "tab-remote") {
|
||||
return {
|
||||
messages: [],
|
||||
startTurn: 0,
|
||||
endTurn: 0,
|
||||
hasOlder: false,
|
||||
hasNewer: false,
|
||||
error: "session path unavailable before controller ready",
|
||||
};
|
||||
}
|
||||
return {
|
||||
messages: [{ role: "user", content: "local hello" }],
|
||||
startTurn: 1,
|
||||
endTurn: 1,
|
||||
hasOlder: false,
|
||||
hasNewer: false,
|
||||
};
|
||||
},
|
||||
MetaForTab: async () => ({ label: "local-model", ready: true, eventChannel: "agent:event", cwd: "/repo/local" }),
|
||||
ContextUsageForTab: async () => ({ used: 0, window: 0 }),
|
||||
EffortForTab: async () => ({ level: "high", supported: ["high"] }),
|
||||
BalanceForTab: async () => ({ available: false, display: "" }),
|
||||
JobsForTab: async () => [],
|
||||
CheckpointsForTab: async () => [],
|
||||
ReplayPendingPrompts: async () => {},
|
||||
ReportUIReady: async () => {},
|
||||
} as Partial<AppBindings> as AppBindings,
|
||||
},
|
||||
};
|
||||
|
||||
type ControllerApi = {
|
||||
activeTabId?: string;
|
||||
switchTab: (tabId: string, optimisticTab?: TabMeta) => Promise<unknown>;
|
||||
};
|
||||
|
||||
let controller: ControllerApi | undefined;
|
||||
function Probe() {
|
||||
controller = useController() as unknown as ControllerApi;
|
||||
return null;
|
||||
}
|
||||
const root = createRoot(document.getElementById("root")!);
|
||||
await act(async () => { root.render(<Probe />); });
|
||||
await waitFor("local active", () => controller?.activeTabId === "tab-local");
|
||||
|
||||
historySliceCalls = [];
|
||||
setActiveCalls = [];
|
||||
await act(async () => {
|
||||
await controller?.switchTab(remoteTab.id, remoteTab);
|
||||
await flushPromises();
|
||||
});
|
||||
await waitFor("remote stays active", () => controller?.activeTabId === "tab-remote");
|
||||
|
||||
eq(controller?.activeTabId, "tab-remote", "switching to a remote tab keeps the remote tab active");
|
||||
ok(!historySliceCalls.includes("tab-remote"), "remote switch does not request local HistorySliceForTab");
|
||||
ok(!setActiveCalls.includes("tab-local"), "failed local hydrate must not revert SetActiveTab back to the local tab");
|
||||
eq(setActiveCalls[0], "tab-remote", "SetActiveTab is called for the remote tab");
|
||||
|
||||
await act(async () => { root.unmount(); });
|
||||
dom.window.close();
|
||||
process.stdout.write(`\n${passed} passed, ${failed} failed\n`);
|
||||
if (failed > 0) process.exit(1);
|
||||
@@ -0,0 +1,129 @@
|
||||
// Run: tsx src/__tests__/remote-tab-opened.test.tsx
|
||||
|
||||
import { JSDOM } from "jsdom";
|
||||
import React, { act, useRef, useState } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { __emitMockRemoteTab, __emitMockRemoteTabOpened, __emitMockRemoteTabUpdated, app } from "../lib/bridge";
|
||||
import type { TabMeta } from "../lib/types";
|
||||
import type { RemoteSessionApi } from "../lib/useRemoteSession";
|
||||
import { useRemoteSession } from "../lib/useRemoteSession";
|
||||
import { useRemoteTabOpened } from "../lib/useRemoteTabOpened";
|
||||
import { useRemoteTabSwitch } from "../lib/useRemoteTabSwitch";
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
function eq(actual: unknown, expected: unknown, label: string) {
|
||||
if (actual === expected) {
|
||||
process.stdout.write(` PASS ${label}\n`);
|
||||
passed += 1;
|
||||
} else {
|
||||
process.stdout.write(` FAIL ${label}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}\n`);
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
|
||||
console.log("\nRemote tab opened/updated routing");
|
||||
|
||||
const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", {
|
||||
pretendToBeVisual: true,
|
||||
url: "http://localhost/",
|
||||
});
|
||||
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
globalThis.window = dom.window as unknown as Window & typeof globalThis;
|
||||
globalThis.document = dom.window.document;
|
||||
|
||||
const seeded: string[] = [];
|
||||
const updated: string[] = [];
|
||||
const switched: string[] = [];
|
||||
const remoteMeta: TabMeta = {
|
||||
id: "remote-1",
|
||||
scope: "project",
|
||||
workspaceRoot: "remote-project:host-a:/repo",
|
||||
workspaceName: "repo",
|
||||
workspacePath: "/repo",
|
||||
gitBranch: "main",
|
||||
label: "model",
|
||||
ready: true,
|
||||
running: false,
|
||||
active: false,
|
||||
remote: { hostId: "host-a", workspace: "/repo" },
|
||||
};
|
||||
|
||||
function Harness() {
|
||||
const activeTabIdRef = useRef<string | undefined>("local-1");
|
||||
useRemoteTabOpened(
|
||||
activeTabIdRef,
|
||||
(meta) => seeded.push(meta.id),
|
||||
(meta) => updated.push(meta.id),
|
||||
async (meta) => {
|
||||
switched.push(meta.id);
|
||||
},
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
const root = createRoot(document.getElementById("root")!);
|
||||
await act(async () => root.render(<Harness />));
|
||||
await act(async () => __emitMockRemoteTabOpened(remoteMeta));
|
||||
eq(seeded.join(","), "remote-1", "opened events seed the new remote tab metadata");
|
||||
eq(switched.join(","), "remote-1", "opened events activate through the dedicated remote switch");
|
||||
|
||||
await act(async () => __emitMockRemoteTabUpdated({ ...remoteMeta, topicTitle: "Background title" }));
|
||||
eq(updated.join(","), "remote-1", "metadata updates patch the remote tab");
|
||||
eq(switched.join(","), "remote-1", "metadata updates never steal focus");
|
||||
|
||||
await act(async () => root.unmount());
|
||||
|
||||
let directSwitch: ((meta: TabMeta) => Promise<void>) | undefined;
|
||||
let historyCalls = 0;
|
||||
let activeCalls = 0;
|
||||
const originalHistory = app.HistorySliceForTab;
|
||||
const previousGo = window.go;
|
||||
window.go = { main: { App: {
|
||||
HistorySliceForTab: async (...args: Parameters<typeof originalHistory>) => {
|
||||
historyCalls += 1;
|
||||
return originalHistory(...args);
|
||||
},
|
||||
SetActiveTab: async () => { activeCalls += 1; },
|
||||
} as unknown as typeof app } } as typeof window.go;
|
||||
|
||||
function SwitchHarness() {
|
||||
const [activeId, setActiveId] = useState<string | undefined>("local-1");
|
||||
const activeIdRef = useRef<string | undefined>(activeId);
|
||||
activeIdRef.current = activeId;
|
||||
directSwitch = useRemoteTabSwitch({
|
||||
activeTabIdRef: activeIdRef,
|
||||
setActiveTabId: setActiveId,
|
||||
beginNavigation: () => 1,
|
||||
navigationCanComplete: () => true,
|
||||
navigationIsCurrent: () => true,
|
||||
confirmBackendActiveTab: () => undefined,
|
||||
reassertVisibleTab: async () => undefined,
|
||||
});
|
||||
return <span data-active-id={activeId} />;
|
||||
}
|
||||
|
||||
const switchRoot = createRoot(document.getElementById("root")!);
|
||||
await act(async () => switchRoot.render(<SwitchHarness />));
|
||||
await act(async () => directSwitch?.(remoteMeta));
|
||||
eq(document.querySelector("span")?.getAttribute("data-active-id"), "remote-1", "remote activation updates the selected tab");
|
||||
eq(activeCalls, 1, "remote activation still binds backend focus");
|
||||
eq(historyCalls, 0, "remote activation bypasses local history hydration");
|
||||
await act(async () => switchRoot.unmount());
|
||||
|
||||
let terminalProbe: RemoteSessionApi | undefined;
|
||||
window.go = { main: { App: {
|
||||
RemoteTabSnapshot: async () => { throw new Error("serve unavailable"); },
|
||||
SetActiveTab: async (tabId: string) => {
|
||||
__emitMockRemoteTab(tabId, "state", { state: "serve_down", error: "bootstrap failed" });
|
||||
},
|
||||
} as unknown as typeof app } } as typeof window.go;
|
||||
function TerminalHarness() { terminalProbe = useRemoteSession("remote-terminal", "disconnected"); return null; }
|
||||
const terminalRoot = createRoot(document.getElementById("root")!);
|
||||
await act(async () => terminalRoot.render(<TerminalHarness />));
|
||||
eq(terminalProbe?.state, "serve_down", "restored shell observes terminal state republished during activation");
|
||||
await act(async () => terminalRoot.unmount());
|
||||
window.go = previousGo;
|
||||
process.stdout.write(`\n${passed} passed, ${failed} failed\n`);
|
||||
if (failed > 0) process.exit(1);
|
||||
@@ -0,0 +1,27 @@
|
||||
// Run: tsx src/__tests__/remote-topic-active.test.ts
|
||||
|
||||
import { topicIsActive } from "../components/ProjectTree";
|
||||
import { mergeRemoteSessionsIntoTree } from "../components/ProjectTreeRemoteGroups";
|
||||
import type { Translator } from "../lib/i18n";
|
||||
import type { ProjectNode } from "../lib/types";
|
||||
|
||||
const remoteProject: ProjectNode = {
|
||||
key: "project_remote_box_app", kind: "project", label: "app",
|
||||
root: "remote-project:box:~/app", remote: { hostId: "box", workspace: "~/app" },
|
||||
};
|
||||
const remoteTopic = mergeRemoteSessionsIntoTree([remoteProject], {
|
||||
"box\u0000~/app": [{ name: "s1", title: "First chat", turns: 1, path: "/remote/sessions/s1.jsonl" }],
|
||||
}, ((key: string) => key) as Translator)[0]?.children?.[0];
|
||||
if (!remoteTopic) throw new Error("remote topic was not synthesized");
|
||||
|
||||
function eq(actual: unknown, expected: unknown, label: string) {
|
||||
if (actual !== expected) throw new Error(`${label}: expected ${String(expected)}, got ${String(actual)}`);
|
||||
process.stdout.write(` PASS ${label}\n`);
|
||||
}
|
||||
|
||||
eq(topicIsActive(remoteTopic, "project", "~/app", remoteTopic.topicId, remoteTopic.sessionPath), true,
|
||||
"matching remote identity is active");
|
||||
eq(topicIsActive(remoteTopic, "project", "~/app", "other-host\u0000~/app\u0000s1", remoteTopic.sessionPath), false,
|
||||
"the same absolute path on another host stays inactive");
|
||||
eq(topicIsActive(remoteTopic, "project", "~/app", "", remoteTopic.sessionPath), false,
|
||||
"remote rows never use an unqualified path-only fallback");
|
||||
@@ -30,7 +30,7 @@ function ok(value: boolean, label: string) {
|
||||
}
|
||||
}
|
||||
|
||||
console.log("\nsearch footnote render");
|
||||
console.log("\nsearch sources panel render");
|
||||
|
||||
const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", {
|
||||
pretendToBeVisual: true,
|
||||
@@ -64,7 +64,7 @@ await act(async () => {
|
||||
text: "answer only",
|
||||
reasoning: "",
|
||||
streaming: false,
|
||||
searchSources: [{ title: "新闻本文", url: "https://example.com/a" }],
|
||||
searchSources: [{ title: "新闻本文", url: "https://example.com/a?utm_source=test#section" }],
|
||||
}}
|
||||
/>
|
||||
</LocaleProvider>,
|
||||
@@ -72,13 +72,21 @@ await act(async () => {
|
||||
});
|
||||
|
||||
const body = document.querySelector(".msg__body");
|
||||
const footnotes = document.querySelector(".msg-search-sources");
|
||||
const title = footnotes?.querySelector("strong");
|
||||
const link = footnotes?.querySelector("a");
|
||||
const panel = document.querySelector(".msg-search-sources");
|
||||
const toggle = panel?.querySelector<HTMLButtonElement>(".msg-search-sources__toggle");
|
||||
ok(Boolean(body?.textContent?.includes("answer only")), "answer body keeps the model text");
|
||||
ok(Boolean(footnotes), "footnotes render under the answer");
|
||||
ok(title?.textContent === "新闻本文", "footnote shows the result title");
|
||||
ok(Boolean(link), "footnote title list keeps a rendered link");
|
||||
ok(Boolean(panel), "sources panel renders under the answer");
|
||||
ok(toggle?.getAttribute("aria-expanded") === "false", "sources panel is collapsed by default");
|
||||
ok(!panel?.querySelector(".msg-search-sources__body"), "collapsed panel does not expose source details");
|
||||
await act(async () => {
|
||||
toggle?.click();
|
||||
});
|
||||
const title = panel?.querySelector(".msg-search-source__title");
|
||||
const link = panel?.querySelector<HTMLAnchorElement>(".msg-search-source__link");
|
||||
ok(toggle?.getAttribute("aria-expanded") === "true", "click expands the sources panel");
|
||||
ok(title?.textContent === "新闻本文", "expanded panel shows the result title");
|
||||
ok(link?.getAttribute("href") === "https://example.com/a", "source link uses the cleaned URL");
|
||||
ok(Boolean(panel?.querySelector(".msg-search-source__url")?.textContent?.includes("example.com/a")), "expanded panel shows the hostname and short URL");
|
||||
ok(!(document.querySelector(".msg__body > .md")?.textContent ?? "").includes("新闻本文"), "answer markdown does not include the search title");
|
||||
|
||||
if (failed) {
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
parseSearchSources,
|
||||
searchSourcesFromHistory,
|
||||
} from "../lib/searchSources";
|
||||
import { normalizeSearchSources } from "../lib/searchSourcesPresentation";
|
||||
import { historyMessagesToItems, initialState, reducer } from "../lib/useController";
|
||||
import type { HistoryMessage, WireEvent } from "../lib/types";
|
||||
|
||||
@@ -40,6 +41,23 @@ eq(degraded[0]?.url, "https://example.com/a", "degraded dump unwraps the autolin
|
||||
eq(parseSearchSources("<https://example.com/a>")[0]?.url, "https://example.com/a", "autolink-only lines parse as URL sources");
|
||||
eq(mergeSearchSources([{ title: "A", url: "https://a.example" }], [{ title: "A", url: "https://a.example" }]).length, 1, "duplicate hits collapse");
|
||||
|
||||
const normalizedInput = [
|
||||
{ title: "Tracked", url: "https://example.com/article?utm_source=search&gclid=abc&part=1#section" },
|
||||
{ title: "Same canonical URL", url: "https://example.com/article?part=1" },
|
||||
{ title: "Google redirect", url: "https://www.google.com/url?q=https%3A%2F%2Fdocs.example.com%2Fguide%3Futm_medium%3Dcpc%26x%3D1" },
|
||||
{ title: "Broken redirect", url: "https://page.sm.cn/blm/midpage-317/index?id=11" },
|
||||
{ title: "Unsafe", url: "javascript:alert(1)" },
|
||||
{ title: " ", url: "https://empty-title.example" },
|
||||
];
|
||||
const normalized = normalizeSearchSources(normalizedInput);
|
||||
eq(normalized.visible.length, 2, "normalization keeps only valid unique HTTP(S) sources");
|
||||
eq(normalized.visible[0]?.href, "https://example.com/article?part=1", "tracking parameters and fragments are removed");
|
||||
eq(normalized.visible[1]?.href, "https://docs.example.com/guide?x=1", "google redirect target is unwrapped and cleaned");
|
||||
eq(normalized.visible[0]?.hostname, "example.com", "source projection exposes the hostname");
|
||||
eq(normalized.visible[0]?.displayUrl, "example.com/article?part=1", "source projection exposes a compact URL");
|
||||
eq(normalized.hiddenCount, 4, "invalid, duplicate, and missing-title sources are counted as hidden");
|
||||
eq(normalizedInput[0]?.url, "https://example.com/article?utm_source=search&gclid=abc&part=1#section", "normalization does not mutate raw search data");
|
||||
|
||||
const history = historyMessagesToItems([{
|
||||
role: "assistant",
|
||||
content: "answer only",
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { acceptsRuntimeEventEpoch, historyMessagesToItems, initialState, normalizeTurnSubmit, reducer, replayPendingPromptsForActiveTab, runtimeReadyForSubmit } from "../lib/useController";
|
||||
import { acceptsRuntimeEventEpoch, historyMessagesToItems, initialState, isLocalRuntimeCommand, normalizeTurnSubmit, reducer, replayPendingPromptsForActiveTab, runtimeReadyForSubmit } from "../lib/useController";
|
||||
import { continueDelivery } from "../lib/deliveryContinue";
|
||||
import {
|
||||
activateGoalAndSubmit,
|
||||
@@ -115,6 +115,9 @@ eq(runtimeReadyForSubmit({ label: "", ready: false, eventChannel: "", cwd: "", r
|
||||
eq(runtimeReadyForSubmit({ label: "", ready: false, eventChannel: "", cwd: "", runtime: { phase: "failed", epoch: "e1" } }), false, "failed runtime cannot submit");
|
||||
eq(runtimeReadyForSubmit({ label: "", ready: true, eventChannel: "", cwd: "", runtime: { phase: "ready", epoch: "e1" } }), true, "ready runtime can submit");
|
||||
eq(normalizeTurnSubmit(" visible prompt ", " provider prompt ").submit, "provider prompt", "submit normalization trims provider input");
|
||||
eq(isLocalRuntimeCommand(" /reload "), true, "/reload remains a host-only command without a turn receipt");
|
||||
eq(isLocalRuntimeCommand("/effort max"), true, "/effort remains a host-only command without a turn receipt");
|
||||
eq(isLocalRuntimeCommand("/reload now"), false, "non-command /reload text still starts an agent turn");
|
||||
let rejectedVisibleOnlySubmit = false;
|
||||
try {
|
||||
normalizeTurnSubmit("visible prompt", " ");
|
||||
@@ -181,11 +184,11 @@ const readinessNotice = readinessState.items[readinessState.items.length - 1];
|
||||
eq(readinessNotice.kind, "notice", "final readiness appends a notice");
|
||||
eq(readinessNotice.kind === "notice" && readinessNotice.level, "info", "final readiness uses informational severity");
|
||||
eq(readinessNotice.kind === "notice" && readinessNotice.variant, "delivery", "final readiness uses the delivery status treatment");
|
||||
eq(readinessNotice.kind === "notice" && readinessNotice.title, "The assistant replied, but task completion checks have not passed", "final readiness distinguishes assistant prose from host-owned completion state");
|
||||
eq(readinessNotice.kind === "notice" && readinessNotice.title, "Delivery checks are not complete", "final readiness uses the explicit Delivery recovery title");
|
||||
eq(
|
||||
readinessNotice.kind === "notice" && readinessNotice.text,
|
||||
"The response above is assistant-generated text, not the system check result. Reasonix still found required completion steps that are not complete; expand Details to see the exact gaps.",
|
||||
"final readiness explains why a completed-sounding answer can still be gated",
|
||||
"The response was generated, but verification and review still need to be completed.",
|
||||
"final readiness explains the explicit Delivery recovery boundary",
|
||||
);
|
||||
eq(readinessNotice.kind === "notice" && readinessNotice.detail, "Still needed: verification, change review", "structured requirements produce localized detail");
|
||||
eq(readinessNotice.kind === "notice" && readinessNotice.action, "continue_delivery", "final readiness offers a recovery action");
|
||||
|
||||
@@ -283,7 +283,7 @@ await act(async () => {
|
||||
let customCompactInput = compactRootEl.querySelector('input[aria-label="Custom compaction threshold percentage"]') as HTMLInputElement | null;
|
||||
if (!customCompactInput) throw new Error("custom compaction threshold input did not open");
|
||||
eq(customCompactInput.value, "80", "custom compaction threshold defaults older backends to 80 percent");
|
||||
ok(compactRootEl.textContent?.includes("65%") === true, "custom compact ratio explains the lower guard rail");
|
||||
ok(compactRootEl.textContent?.includes("30%") === true, "custom compact ratio explains the lower guard rail");
|
||||
ok(compactRootEl.textContent?.includes("85%") === true, "custom compact ratio explains the upper guard rail");
|
||||
ok(document.activeElement === customCompactInput, "opening the custom compact ratio moves focus to its input");
|
||||
ok(customCompactButton.getAttribute("aria-expanded") === "true", "custom compact ratio exposes its expanded state");
|
||||
@@ -299,7 +299,7 @@ const setCustomCompactInput = (input: HTMLInputElement, value: string) => {
|
||||
input.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
};
|
||||
await act(async () => {
|
||||
setCustomCompactInput(customCompactInput, "64");
|
||||
setCustomCompactInput(customCompactInput, "29");
|
||||
await flushPromises();
|
||||
});
|
||||
ok(customCompactApply.disabled, "out-of-range custom compact ratio cannot be applied");
|
||||
|
||||
@@ -317,8 +317,8 @@ ok(
|
||||
"browser mock exposes the Token Rhythm preset",
|
||||
);
|
||||
ok(
|
||||
/function mockProviderPresetDisplayRank\(id: string\): number \{\s*if \(id === "deepseek-responses"\) return -2;/.test(bridgeSource),
|
||||
"browser mock keeps the visible DeepSeek Responses preset first",
|
||||
/function mockProviderPresetDisplayRank\(id: string\): number \{\s*if \(id === "opencode-go-recommended"\) return -3;\s*if \(id === "deepseek-responses"\) return -2;/.test(bridgeSource),
|
||||
"browser mock keeps OpenCode Go recommended first and DeepSeek Responses next",
|
||||
);
|
||||
|
||||
const values = new Map<string, string>();
|
||||
|
||||
@@ -58,7 +58,7 @@ console.log("\nstatus bar workspace");
|
||||
|
||||
{
|
||||
const remoteHosts = [
|
||||
{ id: "demo", label: "demo", host: "192.0.2.10", port: 22, user: "dev", identityFile: "", proxyJump: "", defaultWorkspace: "~/app", serveInstall: "auto", useSSHConfig: false },
|
||||
{ id: "demo", label: "demo", host: "192.0.2.10", port: 22, user: "dev", identityFile: "", proxyJump: "", defaultWorkspace: "~/app", serveInstall: "auto", credentialMode: "remote", useSSHConfig: false },
|
||||
];
|
||||
const stopped = renderStatusBar({ workspacePath: "/workspace/repo", workspaceName: "repo", remoteHosts });
|
||||
ok(stopped.includes("SSH · Disconnected"), "disconnected SSH entry keeps its full accessible status");
|
||||
|
||||
@@ -144,6 +144,7 @@ const tabK = tabMeta("tab-k", { sessionRevision: 1, sessionDigest: "digest-k-v1"
|
||||
const tabL = tabMeta("tab-l", { sessionRevision: 1, sessionDigest: "digest-l-v1" });
|
||||
const tabM = tabMeta("tab-m", { sessionRevision: 2, sessionDigest: "digest-m-v2" });
|
||||
const tabN = tabMeta("tab-n", { sessionRevision: 2, sessionDigest: "digest-n-v2" });
|
||||
const tabO = tabMeta("tab-o");
|
||||
let backendActiveId = "tab-a";
|
||||
const historyB = deferred<HistoryMessage[]>();
|
||||
const historyD = deferred<HistoryMessage[]>();
|
||||
@@ -187,7 +188,7 @@ let staleForkStarted = false;
|
||||
let holdStaleForkReassertG = false;
|
||||
let staleForkReassertGStarted = false;
|
||||
const runningTabs = new Set<string>();
|
||||
const tabsById = new Map([tabA, tabB, tabC, tabD, tabE, tabF, tabG, tabH, tabI, tabK, tabL, tabM, tabN].map((tab) => [tab.id, tab]));
|
||||
const tabsById = new Map([tabA, tabB, tabC, tabD, tabE, tabF, tabG, tabH, tabI, tabK, tabL, tabM, tabN, tabO].map((tab) => [tab.id, tab]));
|
||||
const eventHandlers: Array<(e: WireEvent) => void> = [];
|
||||
const readyHandlers: Array<(tabId?: string) => void> = [];
|
||||
const topicActivationHandlers: Array<(e: TopicActivationEvent) => void> = [];
|
||||
@@ -240,6 +241,7 @@ window.go = {
|
||||
CheckpointsForTab: async () => checkpoints,
|
||||
HistoryForTab: async (tabID: string) => {
|
||||
historyCalls.push(tabID);
|
||||
if (tabID === "tab-o") { failSetActiveFor = "tab-a"; throw new Error("history failed at /private/session.jsonl"); }
|
||||
if (tabID === "tab-b") return historyB.promise;
|
||||
if (tabID === "tab-d") return historyD.promise;
|
||||
if (tabID === "tab-e") return [userMessage("fork E")];
|
||||
@@ -466,7 +468,6 @@ eq(newSessionTargets.join(","), "tab-b", "newSession keeps the selected tab as i
|
||||
|
||||
await act(async () => {
|
||||
setActiveBGate.resolve();
|
||||
await switchToB;
|
||||
await newSessionWhileSwitching;
|
||||
await flushPromises();
|
||||
});
|
||||
@@ -483,7 +484,7 @@ eq(historyCalls.length, historyCallsBeforeReturnToA, "cached idle tab skips hist
|
||||
|
||||
await act(async () => {
|
||||
historyB.resolve([userMessage("late B")]);
|
||||
await historyB.promise;
|
||||
await Promise.all([historyB.promise, switchToB]);
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
@@ -598,6 +599,14 @@ ok(controller?.state.items.some((item) => item.kind === "user" && item.text ===
|
||||
eq(historyCalls.length, historyCallsBeforeFailedSwitch, "failed backend tab switch does not hydrate the rejected target");
|
||||
failSetActiveFor = "";
|
||||
|
||||
await act(async () => { await controller?.switchTab("tab-o", tabO); await flushPromises(); });
|
||||
await waitFor("failed history and failed source restore settle on target", () => controller?.activeTabId === "tab-o" && Boolean(controller.state.hydrateError));
|
||||
eq(controller?.activeTabId, "tab-o", "failed source rebind does not falsely restore the frontend source");
|
||||
eq(backendActiveId, "tab-o", "failed source rebind keeps frontend and backend on the same target");
|
||||
failSetActiveFor = "";
|
||||
await act(async () => { await controller?.switchTab("tab-a", tabA); await flushPromises(); });
|
||||
await waitFor("tab-a restored after failed source rebind", () => controller?.activeTabId === "tab-a");
|
||||
|
||||
await act(async () => {
|
||||
for (const handler of eventHandlers) handler({ kind: "phase", text: "Planner is thinking", tabId: "tab-a" });
|
||||
for (const handler of eventHandlers) handler({ kind: "message", text: "Planner kept", reasoning: "Planner notes", tabId: "tab-a" });
|
||||
@@ -785,6 +794,7 @@ await waitFor("single-surface activation replaces visible tab", () =>
|
||||
controller?.activeTabId === "tab-g" &&
|
||||
(controller.state.items.some((item) => item.kind === "user" && item.text === "history G") ?? false)
|
||||
);
|
||||
await act(async () => { controller?.commitSingleSurfaceNavigation("tab-g"); await flushPromises(); });
|
||||
await act(async () => {
|
||||
metaH.resolve({ ...metaFor(tabH), label: "stale-model-tab-h" });
|
||||
await metaH.promise;
|
||||
@@ -812,8 +822,7 @@ await waitFor("reopened tab-h finishes after stale meta discard", () =>
|
||||
(controller.state.items.some((item) => item.kind === "user" && item.text === "history H after stale meta") ?? false)
|
||||
);
|
||||
|
||||
// A stale repair is itself asynchronous. Force a third navigation to complete
|
||||
// while that repair is pending and verify the repair follows the newest tab.
|
||||
// A third navigation must remain authoritative while stale repair is pending.
|
||||
holdStaleSwitchF = true;
|
||||
holdStaleSwitchReassertG = true;
|
||||
let threeWaySwitchToF: Promise<TabMeta[] | undefined> | undefined;
|
||||
@@ -858,10 +867,7 @@ eq(controller?.activeTabId, "tab-h", "third navigation remains visible after sta
|
||||
eq(backendActiveId, "tab-h", "third navigation remains backend-active after stale fork repair");
|
||||
runningTabs.delete("tab-j");
|
||||
|
||||
// A same-path session can advance while this tab is inactive (another runtime,
|
||||
// crash recovery, or a completed turn). Matching only the path would reuse the
|
||||
// old transcript and hide the newer durable suffix. The persisted fingerprint
|
||||
// must force one fresh history read, then allow reuse once the cache is current.
|
||||
// A changed durable fingerprint must defeat same-path cache reuse, then become reusable.
|
||||
await act(async () => {
|
||||
await controller?.openProjectTab(tabK.workspaceRoot, tabK.topicId || "");
|
||||
await flushPromises();
|
||||
@@ -891,9 +897,7 @@ await act(async () => {
|
||||
await waitFor("matching fingerprint reuses tab history", () => controller?.activeTabId === "tab-k");
|
||||
eq(historyCalls.length, historyCallsAfterFingerprintRefresh, "matching fingerprint reuses the hydrated transcript after navigation");
|
||||
|
||||
// Older-page hydration must use the same canonical fingerprint as the visible
|
||||
// page. A session can advance while the tab is open; an older response must be
|
||||
// discarded instead of being prepended to the newer transcript.
|
||||
// An older response with a stale fingerprint must not prepend into the newer page.
|
||||
await act(async () => {
|
||||
await controller?.openProjectTab(tabL.workspaceRoot, tabL.topicId || "");
|
||||
await flushPromises();
|
||||
@@ -923,9 +927,7 @@ await verifyDeferredHistoryCloseRace({
|
||||
historyCalls: () => historyLCalls, waitFor, flushPromises, equal: eq, sessionPath: tabL.sessionPath,
|
||||
});
|
||||
|
||||
// The backend transcript and sidecar are separate durable files. If a save
|
||||
// advances between those reads, hydration must reconcile the pair instead of
|
||||
// caching an older page under the newer metadata fingerprint.
|
||||
// Transcript and sidecar reads must reconcile when a save advances between them.
|
||||
await act(async () => {
|
||||
await controller?.openProjectTab(tabM.workspaceRoot, tabM.topicId || "");
|
||||
await flushPromises();
|
||||
@@ -937,9 +939,7 @@ await waitFor("split transcript/meta read reconciles", () =>
|
||||
eq(historyMCalls, 2, "mismatched page and metadata trigger one bounded history reload");
|
||||
ok(!(controller?.state.items.some((item) => item.kind === "user" && item.text === "stale M v1") ?? false), "reconciled hydration does not retain the stale page");
|
||||
|
||||
// A live turn may start after the first durable page returns but before the
|
||||
// metadata fingerprint is sampled. That live state owns the transcript; the
|
||||
// bounded durable reconciliation must stop instead of replacing it.
|
||||
// A live turn that starts between durable page and metadata reads owns the transcript.
|
||||
startTabNDuringMeta = true;
|
||||
await act(async () => {
|
||||
await controller?.openProjectTab(tabN.workspaceRoot, tabN.topicId || "");
|
||||
|
||||
@@ -204,7 +204,11 @@ const reboundTabO = { ...tabO, sessionGeneration: 2 };
|
||||
tabsById.set("tab-o", reboundTabO);
|
||||
generationTwoHistory = deferred<HistoryMessage[]>();
|
||||
heldTabOHistory = generationTwoHistory.promise;
|
||||
await act(async () => { await controller?.switchTab("tab-o", reboundTabO); await flushPromises(); });
|
||||
let generationTwoSwitch: Promise<TabMeta[] | undefined> | undefined;
|
||||
await act(async () => {
|
||||
generationTwoSwitch = controller?.switchTab("tab-o", reboundTabO);
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
eq(controller?.activeTabId, "tab-o", "generation-rebound tab becomes the selected target");
|
||||
eq(controller?.state.items.length, 0, "generation-rebound tab clears its prior session before history settles");
|
||||
@@ -213,11 +217,19 @@ eq(controller?.state.hydrating, true, "generation-rebound tab remains in target
|
||||
|
||||
await act(async () => {
|
||||
generationTwoHistory.reject(new Error("generation 2 history failed"));
|
||||
await generationTwoHistory.promise.catch(() => undefined);
|
||||
await Promise.all([generationTwoHistory.promise.catch(() => undefined), generationTwoSwitch]);
|
||||
await flushPromises();
|
||||
});
|
||||
await waitFor("target history error", () => Boolean(controller?.state.hydrateError));
|
||||
ok(!(controller?.state.items.some((item) => item.kind === "user" && item.text === "history O generation 1") ?? false), "target history failure never restores the prior generation");
|
||||
await waitFor("source restored after target history failure", () => controller?.activeTabId === "tab-a");
|
||||
eq(backendActiveId, "tab-a", "target history failure rebinds backend focus to the retained source session");
|
||||
ok(controller?.state.items.some((item) => item.kind === "user" && item.text === "history A") ?? false, "target history failure restores the retained source transcript");
|
||||
ok(!(controller?.state.items.some((item) => item.kind === "user" && item.text === "history O generation 1") ?? false), "target history failure never restores the prior target generation");
|
||||
|
||||
await act(async () => {
|
||||
await controller?.openProjectTab(reboundTabO.workspaceRoot, reboundTabO.topicId || "");
|
||||
await flushPromises();
|
||||
});
|
||||
await waitFor("generation two retry", () => controller?.state.items.some((item) => item.kind === "user" && item.text === "history O generation 2") ?? false);
|
||||
|
||||
// A mount/ready sync can start before a same-tab session rebind and resolve
|
||||
// afterwards. Its tab id still matches, so the navigation generation — not the
|
||||
@@ -238,8 +250,9 @@ tabsById.set("tab-o", reboundTabOGenerationThree);
|
||||
backendActiveId = "tab-o";
|
||||
const generationThreeHistory = deferred<HistoryMessage[]>();
|
||||
heldTabOHistory = generationThreeHistory.promise;
|
||||
let generationThreeNavigation: Promise<TabMeta[] | undefined> | undefined;
|
||||
await act(async () => {
|
||||
await controller?.openProjectTab(reboundTabOGenerationThree.workspaceRoot, reboundTabOGenerationThree.topicId || "");
|
||||
generationThreeNavigation = controller?.openProjectTab(reboundTabOGenerationThree.workspaceRoot, reboundTabOGenerationThree.topicId || "");
|
||||
await flushPromises();
|
||||
});
|
||||
eq(controller?.state.meta?.sessionGeneration, 3, "newer same-tab navigation installs generation three identity");
|
||||
@@ -254,7 +267,7 @@ eq(controller?.state.hydrating, true, "stale same-tab sync cannot cancel generat
|
||||
|
||||
await act(async () => {
|
||||
generationThreeHistory.resolve([userMessage("history O generation 3")]);
|
||||
await Promise.all([generationThreeHistory.promise, staleSync]);
|
||||
await Promise.all([generationThreeHistory.promise, generationThreeNavigation, staleSync]);
|
||||
await flushPromises();
|
||||
});
|
||||
await waitFor("generation three history", () => controller?.state.items.some((item) => item.kind === "user" && item.text === "history O generation 3") ?? false);
|
||||
|
||||
@@ -300,11 +300,24 @@ ok(styleProps.get("--theme-pane-task-card-pct") === "82%", "computes task card p
|
||||
ok(styleProps.get("--theme-pane-session-hover-pct") === "76%", "computes home session-hover opacity tier");
|
||||
ok(styleProps.get("--theme-pane-child-pct") === "80%", "computes home child opacity tier");
|
||||
ok(styleProps.get("--theme-pane-interact-pct") === "90%", "computes home interaction opacity tier");
|
||||
ok(styleProps.get("--theme-pane-overlay-pct") === "90%", "computes home operational overlay opacity tier");
|
||||
ok(styleProps.get("--theme-pane-task-session-hover-pct") === "94%", "computes task session-hover opacity tier");
|
||||
ok(styleProps.get("--theme-pane-task-child-pct") === "98%", "computes task child opacity tier");
|
||||
ok(styleProps.get("--theme-pane-task-interact-pct") === "100%", "caps task interaction opacity tier");
|
||||
ok(styleProps.get("--theme-pane-task-overlay-pct") === "100%", "caps task operational overlay opacity tier");
|
||||
ok(attrs.get("data-theme-safe-area") === "right", "task background controls safe area");
|
||||
|
||||
for (const [paneOpacity, expected] of [[0, "40%"], [0.5, "90%"], [1, "100%"]] as const) {
|
||||
const opacityDraft = draftPackView({
|
||||
...twoSceneDraft,
|
||||
background: { ...twoSceneDraft.background!, paneOpacity },
|
||||
taskBackground: { ...twoSceneDraft.taskBackground!, paneOpacity },
|
||||
});
|
||||
applyThemePack(opacityDraft);
|
||||
ok(styleProps.get("--theme-pane-overlay-pct") === expected, `home overlay follows pane opacity ${paneOpacity}`);
|
||||
ok(styleProps.get("--theme-pane-task-overlay-pct") === expected, `task overlay follows pane opacity ${paneOpacity}`);
|
||||
}
|
||||
|
||||
// Older shells and partial mocks can expose the independent task scene without
|
||||
// the newly added paneOpacity field. It must inherit the home pane value rather
|
||||
// than falling through clamp01(undefined)'s generic midpoint.
|
||||
@@ -329,12 +342,19 @@ ok(
|
||||
"--theme-pane-session-hover-pct",
|
||||
"--theme-pane-child-pct",
|
||||
"--theme-pane-interact-pct",
|
||||
"--theme-pane-overlay-pct",
|
||||
"--theme-pane-task-session-hover-pct",
|
||||
"--theme-pane-task-child-pct",
|
||||
"--theme-pane-task-interact-pct",
|
||||
"--theme-pane-task-overlay-pct",
|
||||
].every((property) => !styleProps.has(property)),
|
||||
"clearing a pack removes every extended pane opacity tier",
|
||||
);
|
||||
applyThemePack(tokenOnlyPreview);
|
||||
ok(!attrs.has("data-theme-has-bg"), "token-only themes keep operational overlays on the opaque base surface");
|
||||
ok(!styleProps.has("--theme-pane-overlay-pct"), "token-only themes do not inject home overlay transparency");
|
||||
ok(!styleProps.has("--theme-pane-task-overlay-pct"), "token-only themes do not inject task overlay transparency");
|
||||
clearThemePack();
|
||||
ok(styleText("reasonix-base-code-readability").includes("--code-add-bg:"), "applyTheme installs the base code readability stylesheet");
|
||||
beginThemePreview(draft);
|
||||
ok(attrs.get("data-theme-pack") === "preview-pack", "preview applies pack");
|
||||
|
||||
@@ -97,6 +97,8 @@ const searchHistory = historyMessagesToItems([{
|
||||
eq(searchHistory[0]?.kind, "tool", "server search hydrates as a tool card before the answer");
|
||||
eq(searchHistory[0]?.kind === "tool" ? searchHistory[0].name : "", "web_search", "server search card uses web_search");
|
||||
eq(searchHistory[0]?.kind === "tool" ? subjectOf("web_search", searchHistory[0].args) : "", "bitcoin", "search card subject is the query");
|
||||
eq(searchHistory[0]?.kind === "tool" ? searchHistory[0].searchSources?.length : 0, 1, "search card keeps structured sources for display");
|
||||
eq(searchHistory[0]?.kind === "tool" ? searchHistory[0].output : "", "新闻本文\nhttps://example.com/a", "search card preserves raw output for replay/history");
|
||||
eq(searchHistory[1]?.kind, "assistant", "model text stays a separate assistant item");
|
||||
eq(searchHistory[1]?.kind === "assistant" ? searchHistory[1].text : "", "answer only", "answer text does not include search results");
|
||||
eq(searchHistory[1]?.kind === "assistant" ? searchHistory[1].searchSources?.[0]?.title : "", "新闻本文", "answer hydrates search footnotes");
|
||||
|
||||
@@ -146,6 +146,9 @@ const requestIdByTab = new Map<string, string>();
|
||||
// When true, the mock emits starting+ready synchronously BEFORE returning the
|
||||
// ticket (exercises the terminal-event stash path).
|
||||
let eagerActivationEvents = false;
|
||||
let failedHistoryTabId = "";
|
||||
let failSetActiveTabId = "";
|
||||
let restoredTabSeq = 0;
|
||||
|
||||
function emitActivation(event: TopicActivationEvent): void {
|
||||
for (const handler of topicActivationHandlers) handler(event);
|
||||
@@ -153,6 +156,7 @@ function emitActivation(event: TopicActivationEvent): void {
|
||||
|
||||
function historyFor(tabID: string): HistoryMessage[] {
|
||||
if (tabID === "tab-r") return [{ role: "user", content: "帮我查询,现在我链接的 deep" }];
|
||||
if (tabID.startsWith("tab-c-restored-")) return [{ role: "user", content: "history tab-c" }];
|
||||
return [{ role: "user", content: `history ${tabID}` }];
|
||||
}
|
||||
|
||||
@@ -181,7 +185,10 @@ window.go = {
|
||||
JobsForTab: async () => jobs,
|
||||
CheckpointsForTab: async () => checkpoints,
|
||||
HistoryForTab: async (tabID: string) => historyFor(tabID),
|
||||
HistorySliceForTab: async (tabID: string, req: HistorySliceRequest) => historySliceFromMessages(tabID, historyFor(tabID), req),
|
||||
HistorySliceForTab: async (tabID: string, req: HistorySliceRequest) => {
|
||||
if (tabID === failedHistoryTabId) throw new Error(`/private/${tabID}/history.jsonl could not be read`);
|
||||
return historySliceFromMessages(tabID, historyFor(tabID), req);
|
||||
},
|
||||
HistoryCheckpointTurnsForTab: async () => [],
|
||||
StartTopicActivation: async (req: TopicActivationRequest) => {
|
||||
const target = Array.from(tabsById.values()).find((tab) => tab.workspaceRoot === req.workspaceRoot && tab.topicId === req.topicId) ?? tabA;
|
||||
@@ -195,8 +202,20 @@ window.go = {
|
||||
return { requestId, tabId: target.id, meta: { ...target, active: true } };
|
||||
},
|
||||
SetActiveTab: async (tabID: string) => {
|
||||
if (tabID === failSetActiveTabId) throw new Error("source tab was already pruned");
|
||||
backendActiveId = tabID;
|
||||
},
|
||||
OpenTopicSession: async (_scope: string, _workspaceRoot: string, _topicID: string, sessionPath: string) => {
|
||||
const source = Array.from(tabsById.values()).find((tab) => tab.sessionPath === sessionPath) ?? tabA;
|
||||
const restored = tabMeta(`${source.id}-restored-${++restoredTabSeq}`, {
|
||||
...source,
|
||||
id: `${source.id}-restored-${restoredTabSeq}`,
|
||||
active: true,
|
||||
});
|
||||
tabsById.set(restored.id, restored);
|
||||
backendActiveId = restored.id;
|
||||
return restored;
|
||||
},
|
||||
ReplayPendingPrompts: async () => {},
|
||||
} as Partial<AppBindings> as AppBindings,
|
||||
},
|
||||
@@ -257,7 +276,7 @@ await act(async () => {
|
||||
await waitFor("C hydrates on its ready", () => hasHistory("tab-c") && controller?.state.hydrating === false);
|
||||
ok(!hasHistory("tab-b"), "only the last click's history is visible");
|
||||
|
||||
// ── terminal failed surfaces the hydrate-error UI ───────────────────────────
|
||||
// ── terminal failure restores the committed source surface ─────────────────
|
||||
await act(async () => {
|
||||
await controller?.activateTopic("project", tabA.workspaceRoot, tabA.topicId ?? "");
|
||||
await flushPromises();
|
||||
@@ -267,8 +286,27 @@ await act(async () => {
|
||||
emitActivation({ requestId: requestIdByTab.get("tab-a") ?? "", tabId: "tab-a", phase: "failed", error: "session failed to start" });
|
||||
await flushPromises();
|
||||
});
|
||||
eq(controller?.state.hydrating, false, "failed activation stops the hydrating surface");
|
||||
eq(controller?.state.hydrateError, "session failed to start", "failed activation surfaces the sanitized error");
|
||||
eq(controller?.activeTabId, "tab-c", "failed activation restores the previously committed source tab");
|
||||
eq(controller?.state.hydrating, false, "restored source is immediately usable");
|
||||
ok(hasHistory("tab-c"), "failed activation retains the source transcript");
|
||||
const failureNotice = controller?.state.items.findLast((item) => item.kind === "notice");
|
||||
ok(Boolean(failureNotice && failureNotice.kind === "notice" && !failureNotice.text.includes("session failed to start")), "failure notice is sanitized before it reaches the restored source");
|
||||
|
||||
// ── activation succeeds but target history fails: source still wins ─────────
|
||||
failedHistoryTabId = "tab-a";
|
||||
failSetActiveTabId = "tab-c";
|
||||
await act(async () => {
|
||||
await controller?.activateTopic("project", tabA.workspaceRoot, tabA.topicId ?? "");
|
||||
await flushPromises();
|
||||
emitActivation({ requestId: requestIdByTab.get("tab-a") ?? "", tabId: "tab-a", phase: "ready" });
|
||||
await flushPromises();
|
||||
});
|
||||
await waitFor("history failure rebinds the pruned committed source", () => controller?.activeTabId?.startsWith("tab-c-restored-") ?? false);
|
||||
ok(hasHistory("tab-c"), "target history failure retains the source controller transcript");
|
||||
const historyFailureNotice = controller?.state.items.findLast((item) => item.kind === "notice");
|
||||
ok(!(historyFailureNotice?.kind === "notice" && historyFailureNotice.text.includes("/private/")), "target history failure does not expose a local path");
|
||||
failedHistoryTabId = "";
|
||||
failSetActiveTabId = "";
|
||||
|
||||
// ── a terminal event that beats the ticket is stashed and replayed ──────────
|
||||
eagerActivationEvents = true;
|
||||
|
||||
@@ -15,9 +15,19 @@ const moreMenuSource = [
|
||||
assert.doesNotMatch(appSource, /t\("shortcuts\.cheatsheetTitle"\)|t\("topicBar\.command"\)/);
|
||||
|
||||
const taskSummaryControlIndex = moreMenuSource.indexOf('t("summary.session")');
|
||||
const workspaceToggleIndex = appSource.indexOf('<Tooltip label={effectiveWorkspacePanelRenderable ? t("rightDock.collapse") : t("rightDock.expand")}>');
|
||||
const workspaceToggleIndex = appSource.indexOf('<Tooltip label={surfaceWorkspacePanelRenderable ? t("rightDock.collapse") : t("rightDock.expand")}>');
|
||||
assert.ok(taskSummaryControlIndex >= 0, "topic bar renders the localized Session summary control");
|
||||
assert.ok(workspaceToggleIndex >= 0, "topic bar keeps the right-edge workspace toggle");
|
||||
assert.match(
|
||||
appSource,
|
||||
/const localWorkspaceDockBlocked = remoteSurfaceActive && \(rightDockMode === "files" \|\| rightDockMode === "changed"\);/,
|
||||
"remote sessions block local Files and Changes surfaces",
|
||||
);
|
||||
assert.match(
|
||||
appSource,
|
||||
/const surfaceWorkspacePanelRenderable = effectiveWorkspacePanelRenderable && !localWorkspaceDockBlocked;/,
|
||||
"the topic bar projects the workspace toggle through the active surface boundary",
|
||||
);
|
||||
assert.ok(!moreMenuSource.includes('aria-label="Session summary"'), "Session summary does not use a hard-coded English label");
|
||||
|
||||
process.stdout.write("topicbar controls: 2 contracts passed\n");
|
||||
process.stdout.write("topicbar controls: 4 contracts passed\n");
|
||||
@@ -0,0 +1,13 @@
|
||||
// Content-free geometry distilled from the field diagnostics. The original
|
||||
// exports are intentionally excluded: only anonymous turn windows required to
|
||||
// reproduce the unloaded-question transaction remain here.
|
||||
export const unloadedQuestionJumpReplay = {
|
||||
targetTurn: 0,
|
||||
requestedTurn: 1,
|
||||
totalTurns: 994,
|
||||
windows: [
|
||||
{ firstTurn: 561, lastTurn: 994, hasOlderHistory: true, rowCount: 434 },
|
||||
{ firstTurn: 148, lastTurn: 994, hasOlderHistory: true, rowCount: 847 },
|
||||
{ firstTurn: 1, lastTurn: 994, hasOlderHistory: false, rowCount: 994 },
|
||||
] as const,
|
||||
} as const;
|
||||
@@ -258,6 +258,11 @@ export async function createTranscriptHarness(options: TranscriptHarnessOptions
|
||||
),
|
||||
);
|
||||
});
|
||||
// Virtuoso and the lazy Markdown/live-region surfaces can schedule a
|
||||
// second commit after the initial act (especially on a loaded CI
|
||||
// runner). Drain one extra frame so render() promises a settled DOM to
|
||||
// callers that intentionally assert immediately after rendering.
|
||||
await flush();
|
||||
await flush();
|
||||
},
|
||||
flush,
|
||||
@@ -269,6 +274,11 @@ export async function createTranscriptHarness(options: TranscriptHarnessOptions
|
||||
await act(async () => current?.unmount());
|
||||
},
|
||||
close: async () => {
|
||||
// React.lazy Markdown chunks may resolve just after the last act() in a
|
||||
// block. Let those requests settle before tearing down Vite's SSR
|
||||
// module runner; otherwise the runner reports a transport disconnect
|
||||
// even though every assertion completed.
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
await server.close();
|
||||
},
|
||||
loadModule: <T,>(path: string) => server.ssrLoadModule(path) as Promise<T>,
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
// Run: tsx src/__tests__/transcript-geometry-environment.test.ts
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { JSDOM } from "jsdom";
|
||||
import {
|
||||
applyTypographyPreferences,
|
||||
createDefaultTypographyPreferences,
|
||||
onTypographyPreferencesChange,
|
||||
} from "../lib/typographyPreferences";
|
||||
import { readTranscriptGeometryEnvironment } from "../lib/transcriptGeometryEnvironment";
|
||||
|
||||
const dom = new JSDOM("<!doctype html><html><body><div class='transcript'></div></body></html>", { url: "http://localhost" });
|
||||
globalThis.window = dom.window as unknown as Window & typeof globalThis;
|
||||
globalThis.document = dom.window.document;
|
||||
globalThis.HTMLElement = dom.window.HTMLElement;
|
||||
globalThis.CustomEvent = dom.window.CustomEvent;
|
||||
globalThis.getComputedStyle = dom.window.getComputedStyle.bind(dom.window) as typeof getComputedStyle;
|
||||
Object.defineProperty(globalThis, "localStorage", { configurable: true, value: dom.window.localStorage });
|
||||
|
||||
const transcript = document.querySelector<HTMLElement>(".transcript")!;
|
||||
Object.defineProperty(transcript, "clientWidth", { configurable: true, value: 800 });
|
||||
document.documentElement.style.setProperty("--maxw", "960px");
|
||||
document.documentElement.style.setProperty("--transcript-inline-pad", "32px");
|
||||
transcript.style.fontFamily = "Test Conversation";
|
||||
transcript.style.fontSize = "14px";
|
||||
transcript.style.lineHeight = "22px";
|
||||
|
||||
const environment = readTranscriptGeometryEnvironment(transcript);
|
||||
assert.equal(environment.contentWidth, 736, "geometry uses the readable column after transcript padding");
|
||||
assert.match(environment.typographySignature, /Test Conversation/);
|
||||
|
||||
const baseline = createDefaultTypographyPreferences();
|
||||
applyTypographyPreferences(baseline);
|
||||
let changes = 0;
|
||||
const unsubscribe = onTypographyPreferencesChange(() => { changes += 1; });
|
||||
applyTypographyPreferences(baseline);
|
||||
assert.equal(changes, 0, "reapplying identical typography publishes no geometry churn");
|
||||
const changed = createDefaultTypographyPreferences();
|
||||
changed.conversation = { ...changed.conversation, followGlobal: false, fontSize: 18 };
|
||||
applyTypographyPreferences(changed);
|
||||
assert.equal(changes, 1, "a real typography change publishes one invalidation");
|
||||
unsubscribe();
|
||||
|
||||
console.log("transcript geometry environment tests passed");
|
||||
@@ -67,11 +67,12 @@ const keys = (rows: readonly { key: string }[]) => rows.map((row) => row.key).jo
|
||||
{
|
||||
const live: TranscriptLiveFlags = { id: "a2", hasAnswerText: false, hasReasoning: true };
|
||||
const { models, rows } = rowsFor([user("u1"), assistant("a2", { streaming: true })], live, true);
|
||||
check(keys(rows) === `${userRowKey("u1")},ph:a2,r:a2`, `running turn rows look as expected (got ${keys(rows)})`);
|
||||
const processKey = models[0].segments[0].key;
|
||||
check(keys(rows) === `${userRowKey("u1")},ph:${processKey},r:a2`, `running turn rows look as expected (got ${keys(rows)})`);
|
||||
const split = splitTranscriptLiveRows(models, rows, "a2", true);
|
||||
check(split.liveActive, "running turn marks the live region active");
|
||||
check(keys(split.historyRows) === userRowKey("u1"), "the active turn's user message stays in history");
|
||||
check(keys(split.liveRows) === "ph:a2,r:a2", "the active turn's process rows render in the live region");
|
||||
check(keys(split.liveRows) === `ph:${processKey},r:a2`, "the active turn's process rows render in the live region");
|
||||
}
|
||||
|
||||
// ── A later user message stays in visual order behind the streaming turn ─────
|
||||
@@ -83,9 +84,10 @@ const keys = (rows: readonly { key: string }[]) => rows.map((row) => row.key).jo
|
||||
true,
|
||||
);
|
||||
const split = splitTranscriptLiveRows(models, rows, "a1", true);
|
||||
const processKey = models[0].segments[0].key;
|
||||
check(keys(split.historyRows) === userRowKey("u1"), "only the streaming turn's user row stays in history");
|
||||
check(
|
||||
keys(split.liveRows) === `ph:a1,r:a1,a:a1,${userRowKey("u2")}`,
|
||||
keys(split.liveRows) === `ph:${processKey},r:a1,a:a1,${userRowKey("u2")}`,
|
||||
`rows after the live turn keep their order in the live region (got ${keys(split.liveRows)})`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
// - EAW-aware text estimation: full-width scripts wrap at roughly half the
|
||||
// character count, so CJK rows must estimate higher than the old
|
||||
// char-based formula; half-width text stays close to the old behavior.
|
||||
// - Measured-size cache: exact rowKey hit beats kind median beats static
|
||||
// prior; medians converge on recorded samples; clear() drops everything.
|
||||
// - Measured-size cache: exact row hits stay logical-row scoped; answer rows
|
||||
// may use a bounded same-layout prior for first-visit stability.
|
||||
|
||||
import {
|
||||
eastAsianWidthColumns,
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
type TranscriptRow,
|
||||
} from "../lib/transcriptRows";
|
||||
import { createTranscriptMeasuredSizes } from "../lib/transcriptMeasuredSizes";
|
||||
import { transcriptRowLayoutVariant } from "../lib/transcriptRowGeometry";
|
||||
import { initialState, reducer, type Item } from "../lib/useController";
|
||||
|
||||
let passed = 0;
|
||||
@@ -106,25 +107,99 @@ function fakeRow(key: string, kind: TranscriptRow["kind"], text: string): Transc
|
||||
}
|
||||
|
||||
function estimateFor(store: ReturnType<typeof createTranscriptMeasuredSizes>, row: TranscriptRow, width?: number) {
|
||||
return store.synthesize([row], width)[0];
|
||||
return estimatesFor(store, [row], width)[0];
|
||||
}
|
||||
|
||||
function estimatesFor(store: ReturnType<typeof createTranscriptMeasuredSizes>, rows: readonly TranscriptRow[], width?: number) {
|
||||
return store.synthesizeDetailed("test-session", rows, { contentWidth: width, typographySignature: "test-font" }).heightEstimates;
|
||||
}
|
||||
|
||||
function recordRow(store: ReturnType<typeof createTranscriptMeasuredSizes>, row: TranscriptRow, height: number, width?: number) {
|
||||
store.recordGeometry("test-session", {
|
||||
rowKey: String(row.key),
|
||||
kind: row.kind,
|
||||
layoutVariant: transcriptRowLayoutVariant(row),
|
||||
height,
|
||||
environment: { contentWidth: width, typographySignature: "test-font" },
|
||||
measurementVersion: transcriptRowMeasurementVersion(row),
|
||||
staticEstimate: estimateTranscriptRowSize(row, width),
|
||||
});
|
||||
}
|
||||
|
||||
{
|
||||
const store = createTranscriptMeasuredSizes();
|
||||
const row = fakeRow("r1", "answer", "hello");
|
||||
ok(estimateFor(store, row) === estimateTranscriptRowSize(row), "no data falls back to the static prior");
|
||||
store.record("r1", "answer", 432, undefined, transcriptRowMeasurementVersion(row));
|
||||
recordRow(store, row, 432);
|
||||
eq(estimateFor(store, row), 432, "exact rowKey measurement wins");
|
||||
store.record("r1", "answer", 0);
|
||||
store.record("r1", "answer", Number.NaN);
|
||||
recordRow(store, row, 0);
|
||||
recordRow(store, row, Number.NaN);
|
||||
eq(estimateFor(store, row), 432, "non-positive and NaN measurements are ignored");
|
||||
}
|
||||
|
||||
// ── Geometry-contract cache ────────────────────────────────────────────────
|
||||
|
||||
{
|
||||
const store = createTranscriptMeasuredSizes();
|
||||
const collapsed = fakeRow("reasoning", "reasoning", "hidden body");
|
||||
collapsed.layoutVariant = "reasoning-summary";
|
||||
const expanded = { ...collapsed, layoutVariant: "reasoning-expanded" as const };
|
||||
const environment = { contentWidth: 960, typographySignature: "font-a" };
|
||||
store.recordGeometry("session-a", {
|
||||
rowKey: "reasoning",
|
||||
kind: "reasoning",
|
||||
layoutVariant: "reasoning-expanded",
|
||||
height: 1_200,
|
||||
environment,
|
||||
measurementVersion: transcriptRowMeasurementVersion(expanded),
|
||||
staticEstimate: estimateTranscriptRowSize(expanded, environment.contentWidth),
|
||||
});
|
||||
const synthesized = store.synthesizeDetailed("session-a", [collapsed], environment);
|
||||
eq(synthesized.heightEstimates[0], 64, "expanded reasoning measurement cannot poison collapsed geometry");
|
||||
eq(synthesized.estimateSources[0], "static", "cross-state rejection reports the static source");
|
||||
}
|
||||
|
||||
{
|
||||
const store = createTranscriptMeasuredSizes();
|
||||
const row = fakeRow("answer", "answer", "wrapped answer");
|
||||
row.layoutVariant = "text-flow";
|
||||
const environment = { contentWidth: 960, typographySignature: "font-a" };
|
||||
store.recordGeometry("session-a", {
|
||||
rowKey: "answer",
|
||||
kind: "answer",
|
||||
layoutVariant: "text-flow",
|
||||
height: 320,
|
||||
environment,
|
||||
measurementVersion: transcriptRowMeasurementVersion(row),
|
||||
staticEstimate: estimateTranscriptRowSize(row, environment.contentWidth),
|
||||
});
|
||||
eq(store.synthesizeDetailed("session-a", [row], environment).heightEstimates[0], 320, "exact geometry matches all cache dimensions");
|
||||
ok(store.synthesizeDetailed("session-a", [row], { ...environment, contentWidth: 760 }).heightEstimates[0] !== 320, "content width rejects an exact sample");
|
||||
ok(store.synthesizeDetailed("session-a", [row], { ...environment, typographySignature: "font-b" }).heightEstimates[0] !== 320, "typography rejects an exact sample");
|
||||
}
|
||||
|
||||
{
|
||||
const store = createTranscriptMeasuredSizes({ maxSessions: 2, maxRowsPerSession: 2 });
|
||||
const rowA = fakeRow("a", "answer", "answer A");
|
||||
rowA.layoutVariant = "text-flow";
|
||||
const environment = { contentWidth: 960, typographySignature: "font-a" };
|
||||
store.recordGeometry("session-a", {
|
||||
rowKey: "a", kind: "answer", layoutVariant: "text-flow", height: 222,
|
||||
environment, measurementVersion: transcriptRowMeasurementVersion(rowA), staticEstimate: estimateTranscriptRowSize(rowA, 960),
|
||||
});
|
||||
store.synthesizeDetailed("session-b", [], environment);
|
||||
eq(store.synthesizeDetailed("session-a", [rowA], environment).heightEstimates[0], 222, "A to B to A restores safe geometry without a scroll position");
|
||||
store.synthesizeDetailed("session-c", [], environment);
|
||||
store.synthesizeDetailed("session-d", [], environment);
|
||||
ok(store.synthesizeDetailed("session-a", [rowA], environment).heightEstimates[0] !== 222, "session LRU evicts beyond the configured cap");
|
||||
ok(store.synthesizeDetailed("session-b", [rowA], environment).heightEstimates[0] !== 222, "session LRU remains bounded");
|
||||
}
|
||||
|
||||
{
|
||||
const store = createTranscriptMeasuredSizes();
|
||||
const before = fakeRow("late", "answer", "preview");
|
||||
const after = fakeRow("late", "answer", "resolved content");
|
||||
store.record("late", "answer", 777, 960, transcriptRowMeasurementVersion(before));
|
||||
recordRow(store, before, 777, 960);
|
||||
eq(estimateFor(store, before, 960), 777, "an exact sample matches its row content version");
|
||||
ok(estimateFor(store, after, 960) !== 777, "the same row key and width reject an old content version");
|
||||
}
|
||||
@@ -134,9 +209,9 @@ function estimateFor(store: ReturnType<typeof createTranscriptMeasuredSizes>, ro
|
||||
const changedBefore = fakeRow("changed", "answer", "preview");
|
||||
const changedAfter = fakeRow("changed", "answer", "resolved");
|
||||
const untouched = fakeRow("untouched", "answer", "stable");
|
||||
store.record("changed", "answer", 900, 960, transcriptRowMeasurementVersion(changedBefore));
|
||||
store.record("untouched", "answer", 240, 960, transcriptRowMeasurementVersion(untouched));
|
||||
const estimates = store.synthesize([changedAfter, untouched], 960);
|
||||
recordRow(store, changedBefore, 900, 960);
|
||||
recordRow(store, untouched, 240, 960);
|
||||
const estimates = estimatesFor(store, [changedAfter, untouched], 960);
|
||||
ok(estimates[0] !== 900, "patching one row invalidates its own exact sample");
|
||||
eq(estimates[1], 240, "patching one row preserves another row's exact sample");
|
||||
}
|
||||
@@ -146,8 +221,8 @@ function estimateFor(store: ReturnType<typeof createTranscriptMeasuredSizes>, ro
|
||||
const staleBefore = fakeRow("stale", "answer", "preview");
|
||||
const stale = fakeRow("stale", "answer", "resolved");
|
||||
const unseen = fakeRow("unseen", "answer", "fresh");
|
||||
store.record("stale", "answer", 999, 960, transcriptRowMeasurementVersion(staleBefore));
|
||||
const estimates = store.synthesize([stale, unseen], 960);
|
||||
recordRow(store, staleBefore, 999, 960);
|
||||
const estimates = estimatesFor(store, [stale, unseen], 960);
|
||||
ok(estimates[1] !== 999, "a stale-version sample is excluded from the kind median");
|
||||
}
|
||||
|
||||
@@ -158,7 +233,7 @@ function estimateFor(store: ReturnType<typeof createTranscriptMeasuredSizes>, ro
|
||||
const answer: Item = { kind: "assistant", id: "answer-1", text: "done", reasoning: "", streaming: false };
|
||||
const models = buildTurnModels([user, firstTool, secondTool, answer]);
|
||||
const folds: FoldMap = new Map([[
|
||||
"tool-1",
|
||||
models[0].segments[0].key,
|
||||
{ open: true, userOverridden: true, running: false },
|
||||
]]);
|
||||
const options = {
|
||||
@@ -193,7 +268,7 @@ function estimateFor(store: ReturnType<typeof createTranscriptMeasuredSizes>, ro
|
||||
const beforeRow = beforeRows.find((row) => row.kind === "answer")!;
|
||||
const store = createTranscriptMeasuredSizes();
|
||||
const beforeVersion = transcriptRowMeasurementVersion(beforeRow);
|
||||
store.record(String(beforeRow.key), beforeRow.kind, 88, 960, beforeVersion);
|
||||
recordRow(store, beforeRow, 88, 960);
|
||||
const after = reducer(before, {
|
||||
type: "history_items_patch",
|
||||
patches: { [preview.id]: { ...preview, text: "resolved ".repeat(2_000) } },
|
||||
@@ -214,11 +289,11 @@ function estimateFor(store: ReturnType<typeof createTranscriptMeasuredSizes>, ro
|
||||
|
||||
{
|
||||
const store = createTranscriptMeasuredSizes();
|
||||
store.record("a1", "answer", 100);
|
||||
store.record("a2", "answer", 300);
|
||||
store.record("a3", "answer", 200);
|
||||
recordRow(store, fakeRow("a1", "answer", "first"), 100);
|
||||
recordRow(store, fakeRow("a2", "answer", "second"), 300);
|
||||
recordRow(store, fakeRow("a3", "answer", "third"), 200);
|
||||
const unseen = fakeRow("a4", "answer", "some answer text that is long enough to matter");
|
||||
eq(estimateFor(store, unseen), 200, "unseen row of a sampled kind uses the kind median");
|
||||
eq(estimateFor(store, unseen), 200, "unseen answer rows use a same-layout bounded prior");
|
||||
const toolRow = fakeRow("t1", "tool", "");
|
||||
eq(estimateFor(store, toolRow), estimateTranscriptRowSize(toolRow), "unsampled kind still uses the static prior");
|
||||
}
|
||||
@@ -228,18 +303,18 @@ function estimateFor(store: ReturnType<typeof createTranscriptMeasuredSizes>, ro
|
||||
const first = fakeRow("a1", "answer", "first answer");
|
||||
const second = fakeRow("a2", "answer", "second answer");
|
||||
const unseen = fakeRow("a3", "answer", "unseen answer");
|
||||
store.record("a1", "answer", 291, 960, transcriptRowMeasurementVersion(first));
|
||||
store.record("a1", "answer", 632, 960, transcriptRowMeasurementVersion(first));
|
||||
store.record("a2", "answer", 400, 960, transcriptRowMeasurementVersion(second));
|
||||
recordRow(store, first, 291, 960);
|
||||
recordRow(store, first, 632, 960);
|
||||
recordRow(store, second, 400, 960);
|
||||
eq(estimateFor(store, first, 960), 632, "a later real measurement replaces the stale estimate for the same row");
|
||||
eq(estimateFor(store, second, 960), 400, "a second row keeps its own latest measurement");
|
||||
eq(estimateFor(store, unseen, 960), 516, "kind fallback uses one latest sample per row instead of duplicate observations");
|
||||
eq(estimateFor(store, unseen, 960), 516, "answer prior uses one latest sample per logical row");
|
||||
}
|
||||
|
||||
{
|
||||
const store = createTranscriptMeasuredSizes();
|
||||
const row = fakeRow("width-sensitive", "answer", "wrapped answer");
|
||||
store.record("width-sensitive", "answer", 632, 960, transcriptRowMeasurementVersion(row));
|
||||
recordRow(store, row, 632, 960);
|
||||
eq(estimateFor(store, row, 960), 632, "an exact measurement is reused at the measured width");
|
||||
ok(
|
||||
estimateFor(store, row, 760) === estimateTranscriptRowSize(row),
|
||||
@@ -250,20 +325,12 @@ function estimateFor(store: ReturnType<typeof createTranscriptMeasuredSizes>, ro
|
||||
{
|
||||
const store = createTranscriptMeasuredSizes();
|
||||
const rows = [fakeRow("r1", "user", "hi"), fakeRow("r2", "answer", "hello there")];
|
||||
store.record("r1", "user", 64, undefined, transcriptRowMeasurementVersion(rows[0]));
|
||||
const estimates = store.synthesize(rows);
|
||||
recordRow(store, rows[0], 64);
|
||||
const estimates = estimatesFor(store, rows);
|
||||
eq(estimates.length, 2, "synthesize stays aligned with the row array");
|
||||
eq(estimates[0], 64, "synthesize uses the exact measurement where present");
|
||||
ok(estimates[1] > 0, "synthesize falls back cleanly for unmeasured rows");
|
||||
}
|
||||
|
||||
{
|
||||
const store = createTranscriptMeasuredSizes();
|
||||
store.record("r1", "answer", 500);
|
||||
store.clear();
|
||||
const row = fakeRow("r1", "answer", "hello");
|
||||
ok(estimateFor(store, row) === estimateTranscriptRowSize(row), "clear() drops measurements and medians");
|
||||
}
|
||||
|
||||
process.stdout.write(`\n${passed} passed, ${failed} failed\n`);
|
||||
if (failed > 0) process.exit(1);
|
||||
@@ -0,0 +1,16 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const root = join(import.meta.dirname, "..", "..");
|
||||
const arbiter = readFileSync(join(root, "src/lib/useTranscriptScrollArbiter.ts"), "utf8");
|
||||
const styles = readFileSync(join(root, "src/styles.css"), "utf8");
|
||||
|
||||
assert.equal(arbiter.includes("manualMeasurementFreezeRef"), false, "manual transcript scrolling has no measurement freeze state");
|
||||
assert.equal(arbiter.includes("upwardManualGesture"), false, "wheel/touch reading does not freeze recycled rows");
|
||||
assert.equal(
|
||||
styles.includes("\n height: max(0px, calc(var(--transcript-row-estimate) - 32px))"),
|
||||
true,
|
||||
"pending Markdown uses only its bounded parser fallback height",
|
||||
);
|
||||
process.stdout.write("transcript measurement contract passed\n");
|
||||
@@ -3,10 +3,12 @@
|
||||
import { deepEqual, equal } from "node:assert/strict";
|
||||
import { JSDOM } from "jsdom";
|
||||
import {
|
||||
hasPendingTranscriptGeometry,
|
||||
isNativeVerticalScrollbarPointer,
|
||||
measureTranscriptVirtuosoItem,
|
||||
} from "../lib/transcriptNativeScrollbar";
|
||||
import { noteTranscriptRowMeasurement, setTranscriptScrollDiagnosticSink } from "../lib/transcriptScrollProbe";
|
||||
import { advanceViewportPagePermit, grantsNativeScrollbarPagePermit } from "../lib/useTranscriptNavigationSurface";
|
||||
|
||||
let passed = 0;
|
||||
function check(actual: unknown, expected: unknown, label: string) {
|
||||
@@ -41,6 +43,17 @@ process.stdout.write("\ntranscript native scrollbar\n");
|
||||
check(isNativeVerticalScrollbarPointer(transcript, { button: 0, clientX: 1095 }), true, "left-button in the right native gutter starts the lock");
|
||||
check(isNativeVerticalScrollbarPointer(transcript, { button: 0, clientX: 1085 }), false, "left-button in chat content does not start the lock");
|
||||
check(isNativeVerticalScrollbarPointer(transcript, { button: 1, clientX: 1095 }), false, "middle-button autoscroll is not classified as thumb dragging");
|
||||
check(grantsNativeScrollbarPagePermit(400, 300, true, false), true, "an observed upward native drag grants one page permit");
|
||||
check(grantsNativeScrollbarPagePermit(300, 200, true, true), false, "the same native drag cannot grant a second page permit");
|
||||
check(grantsNativeScrollbarPagePermit(400, 300, false, false), false, "scroll movement without a native drag grants no permit");
|
||||
let viewportPermit = advanceViewportPagePermit(0, 0);
|
||||
check(viewportPermit, 1, "one upward gesture grants a viewport page permit");
|
||||
viewportPermit = advanceViewportPagePermit(viewportPermit, 1);
|
||||
check(viewportPermit, 2, "startReached consumes the granted viewport page permit");
|
||||
check(advanceViewportPagePermit(viewportPermit, 0), 2, "wheel burst events cannot queue another page while the request is active");
|
||||
viewportPermit = advanceViewportPagePermit(viewportPermit, 2);
|
||||
check(advanceViewportPagePermit(viewportPermit, 1), 0, "request completion clears burst permits before prepend reaches the start");
|
||||
check(advanceViewportPagePermit(advanceViewportPagePermit(viewportPermit, 0), 1), 2, "a later user gesture can request one new page");
|
||||
|
||||
Object.defineProperty(transcript, "scrollHeight", { configurable: true, value: 600 });
|
||||
check(isNativeVerticalScrollbarPointer(transcript, { button: 0, clientX: 1095 }), false, "an empty native gutter without overflow does not start the lock");
|
||||
@@ -57,9 +70,26 @@ row.getBoundingClientRect = () => ({
|
||||
toJSON: () => ({}),
|
||||
});
|
||||
check(measureTranscriptVirtuosoItem(row, "offsetHeight", false), 640, "ordinary wheel path keeps real dynamic measurement");
|
||||
check(measureTranscriptVirtuosoItem(row, "offsetHeight", true), 160, "native thumb drag keeps the existing Virtuoso size");
|
||||
check(measureTranscriptVirtuosoItem(row, "offsetHeight", true), 640, "completed rows keep their measured height during native thumb drag");
|
||||
row.dataset.transcriptEstimate = "180";
|
||||
check(measureTranscriptVirtuosoItem(row, "offsetHeight", true), 640, "completed rows never regress to a logical estimate");
|
||||
delete row.dataset.knownSize;
|
||||
check(measureTranscriptVirtuosoItem(row, "offsetHeight", true), 640, "completed rows do not fall back to an estimate when known size is absent");
|
||||
row.dataset.knownSize = "160";
|
||||
delete row.dataset.transcriptEstimate;
|
||||
check(measureTranscriptVirtuosoItem(row, "offsetHeight", false), 640, "real measurement resumes after thumb release");
|
||||
|
||||
const pendingMarkdown = dom.window.document.createElement("div");
|
||||
pendingMarkdown.dataset.transcriptGeometryPending = "true";
|
||||
row.dataset.staticEstimate = "157";
|
||||
row.appendChild(pendingMarkdown);
|
||||
check(hasPendingTranscriptGeometry(row), true, "a lazy Markdown fallback marks transient row geometry");
|
||||
check(measureTranscriptVirtuosoItem(row, "offsetHeight", true), 160, "native thumb drag freezes only pending row geometry at its last measured size");
|
||||
check(measureTranscriptVirtuosoItem(row, "offsetHeight", false), 157, "pending Markdown keeps the state-aware initial seed");
|
||||
pendingMarkdown.remove();
|
||||
check(hasPendingTranscriptGeometry(row), false, "resolved Markdown releases transient geometry");
|
||||
check(measureTranscriptVirtuosoItem(row, "offsetHeight", false), 640, "resolved Markdown resumes browser measurement");
|
||||
|
||||
const measurementEvents: Array<{ type: string; fields: Record<string, unknown> }> = [];
|
||||
setTranscriptScrollDiagnosticSink((type, fields) => measurementEvents.push({ type, fields }));
|
||||
noteTranscriptRowMeasurement(row, "offsetHeight", 640);
|
||||
|
||||
@@ -7,8 +7,11 @@
|
||||
// actually mounted in the viewport, not just that a jump was dispatched.
|
||||
|
||||
import { createTranscriptHarness } from "./transcript-dom-harness";
|
||||
import { unloadedQuestionJumpReplay } from "./transcript-diagnostic-replay.fixtures";
|
||||
import { QUESTION_JUMP_MAX_MARKERS } from "../components/QuestionJumpBar";
|
||||
import type { Item } from "../lib/useController";
|
||||
import type { TranscriptScrollWriteRecord } from "../lib/transcriptScrollProbe";
|
||||
import { settleQuestionJumpSurfaceState } from "../lib/useTranscriptQuestionNavigation";
|
||||
import { act } from "react";
|
||||
|
||||
let passed = 0;
|
||||
@@ -95,6 +98,13 @@ function rowOffsetOf(container: HTMLElement, descendant: HTMLElement): number |
|
||||
|
||||
console.log("\ntranscript question jump landing");
|
||||
|
||||
let newestSurface: { token: number } | null = { token: 3 };
|
||||
newestSurface = settleQuestionJumpSurfaceState(newestSurface, 1, null);
|
||||
newestSurface = settleQuestionJumpSurfaceState(newestSurface, 2, null);
|
||||
ok(newestSurface?.token === 3, "A→B completions cannot release C's question-jump surface");
|
||||
newestSurface = settleQuestionJumpSurfaceState(newestSurface, 3, null);
|
||||
ok(newestSurface === null, "only C's own terminal releases the latest surface");
|
||||
|
||||
// ── Physical rail clicks land on the selected question ──────────────────────
|
||||
// First, middle, and tail positions, with back-and-forth jumps in between:
|
||||
// a stale firstItemIndex offset reappears whenever the viewport moves, so the
|
||||
@@ -113,6 +123,9 @@ console.log("\ntranscript question jump landing");
|
||||
const clientY = railClientY(targetIndex, 40);
|
||||
await act(async () => {
|
||||
jumpScroll.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, cancelable: true, button: 0, clientY }));
|
||||
if (targetIndex === targetIndices[0]) {
|
||||
ok(Boolean(harness.container.querySelector("[data-question-jump-mask='true']")), "an already-loaded target uses the same masked surface transaction");
|
||||
}
|
||||
jumpScroll.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true, button: 0, clientY, detail: 1 }));
|
||||
dispatchScroll(el);
|
||||
});
|
||||
@@ -238,26 +251,58 @@ console.log("\ntranscript question jump landing");
|
||||
}
|
||||
}
|
||||
|
||||
// ── A one-turn remainder auto-loads without restoring the old fold button ───
|
||||
// ── Auto-fill runs only after a stable, genuinely short first page ──────────
|
||||
{
|
||||
const harness = await createTranscriptHarness({ viewportHeight: 200, rowHeight: 100 });
|
||||
// Keep the viewport deliberately larger than every estimate Virtuoso may
|
||||
// use before its first measured row. This exercises the auto-fill branch
|
||||
// itself instead of depending on estimate tuning in the virtualizer.
|
||||
const harness = await createTranscriptHarness({ viewportHeight: 100_000, rowHeight: 100 });
|
||||
try {
|
||||
HTMLElement.prototype.scrollIntoView = () => {};
|
||||
let loads = 0;
|
||||
await harness.render(historyTurns(2, 61), {
|
||||
const triggers: string[] = [];
|
||||
await harness.render(historyTurns(61, 61), {
|
||||
running: false,
|
||||
questionNavigator: true,
|
||||
hasOlderHistory: true,
|
||||
historyStartTurn: 2,
|
||||
historyStartTurn: 61,
|
||||
historyTotalTurns: 61,
|
||||
surfaceCommitToken: "navigation-1-short-surface",
|
||||
onSurfacePaintReady: () => {},
|
||||
onLoadOlderHistory: async (_targetTurn, trigger) => {
|
||||
loads += 1;
|
||||
triggers.push(trigger ?? "");
|
||||
return true;
|
||||
},
|
||||
});
|
||||
await harness.waitFor(() => loads > 0, "a short stable first page to auto-fill");
|
||||
ok(triggers[0] === "auto-fill", "short-page loading is labeled as auto-fill");
|
||||
ok(harness.container.querySelectorAll(".jump-item").length === 61, "the first page renders a marker for every session question");
|
||||
ok(!harness.container.querySelector(".transcript__older"), "the ordinary show-earlier fold button is absent");
|
||||
} finally {
|
||||
await harness.unmount();
|
||||
await harness.close();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Initial Virtuoso startReached cannot cascade a full first page ──────────
|
||||
{
|
||||
const harness = await createTranscriptHarness({ viewportHeight: 200, rowHeight: 100 });
|
||||
try {
|
||||
let loads = 0;
|
||||
await harness.render(historyTurns(1, 120), {
|
||||
running: false,
|
||||
questionNavigator: true,
|
||||
hasOlderHistory: true,
|
||||
historyStartTurn: 1,
|
||||
historyTotalTurns: 120,
|
||||
onLoadOlderHistory: async () => {
|
||||
loads += 1;
|
||||
return true;
|
||||
},
|
||||
});
|
||||
await harness.waitFor(() => loads > 0, "the final missing history turn to auto-load");
|
||||
ok(harness.container.querySelectorAll(".jump-item").length === 61, "the first page renders a marker for every session question");
|
||||
ok(!harness.container.querySelector(".transcript__older"), "the ordinary show-earlier fold button is absent");
|
||||
await harness.settle();
|
||||
ok(loads === 0, "mounting a scrollable 120-turn page requests no older history without user intent");
|
||||
} finally {
|
||||
await harness.unmount();
|
||||
await harness.close();
|
||||
@@ -347,6 +392,174 @@ console.log("\ntranscript question jump landing");
|
||||
}
|
||||
}
|
||||
|
||||
// ── Field replay: 434 → 847 → 994 stays masked and lands once ──────────────
|
||||
{
|
||||
const harness = await createTranscriptHarness({ viewportHeight: 200, rowHeight: 100 });
|
||||
try {
|
||||
HTMLElement.prototype.scrollIntoView = () => {};
|
||||
const replay = unloadedQuestionJumpReplay;
|
||||
const requestedTurns: Array<number | undefined> = [];
|
||||
const writes: TranscriptScrollWriteRecord[] = [];
|
||||
harness.dom.window.__REASONIX_TRANSCRIPT_SCROLL_WRITE__ = (write) => writes.push(write);
|
||||
const resolveLoads: Array<(loaded: boolean) => void> = [];
|
||||
const onLoadOlderHistory = (targetTurn?: number) => {
|
||||
requestedTurns.push(targetTurn);
|
||||
return new Promise<boolean>((resolve) => { resolveLoads.push(resolve); });
|
||||
};
|
||||
const renderWindow = async (index: number, loadingOlderHistory: boolean) => {
|
||||
const historyWindow = replay.windows[index];
|
||||
await harness.render(historyTurns(historyWindow.firstTurn, historyWindow.lastTurn), {
|
||||
running: false,
|
||||
questionNavigator: true,
|
||||
hasOlderHistory: historyWindow.hasOlderHistory,
|
||||
loadingOlderHistory,
|
||||
historyStartTurn: historyWindow.firstTurn,
|
||||
historyTotalTurns: replay.totalTurns,
|
||||
onLoadOlderHistory,
|
||||
});
|
||||
ok(
|
||||
Number(harness.scrollElement().dataset.transcriptRowCount) === historyWindow.rowCount,
|
||||
`field replay commits its ${historyWindow.rowCount}-row history window`,
|
||||
);
|
||||
};
|
||||
|
||||
await renderWindow(0, false);
|
||||
await harness.settle();
|
||||
const jumpScroll = stubRailGeometry(harness.container, QUESTION_JUMP_MAX_MARKERS);
|
||||
await act(async () => {
|
||||
jumpScroll.dispatchEvent(new MouseEvent("mousedown", {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
button: 0,
|
||||
clientY: railClientY(replay.targetTurn, replay.totalTurns),
|
||||
}));
|
||||
});
|
||||
await harness.waitFor(
|
||||
() => requestedTurns.includes(replay.requestedTurn),
|
||||
"the field replay target page request",
|
||||
);
|
||||
await harness.flush();
|
||||
ok(requestedTurns.length === 1, "the pending-jump effect cannot duplicate the in-flight request");
|
||||
ok(Boolean(harness.container.querySelector("[data-question-jump-mask='true']")), "the unloaded target is masked before history mutates");
|
||||
ok(writes.filter((write) => write.owner === "jump").length === 0, "the 434-row window emits no intermediate jump");
|
||||
|
||||
await renderWindow(1, true);
|
||||
ok(Boolean(harness.container.querySelector("[data-question-jump-mask='true']")), "the 847-row intermediate window remains masked");
|
||||
ok(writes.filter((write) => write.owner === "jump").length === 0, "the intermediate prepend emits no target jump");
|
||||
|
||||
await act(async () => resolveLoads[0]?.(true));
|
||||
await renderWindow(1, false);
|
||||
await harness.waitFor(
|
||||
() => requestedTurns.length === 2 && requestedTurns[1] === replay.requestedTurn,
|
||||
"the second targeted page request",
|
||||
);
|
||||
await renderWindow(1, true);
|
||||
ok(Boolean(harness.container.querySelector("[data-question-jump-mask='true']")), "the second request keeps the surface masked");
|
||||
|
||||
await act(async () => resolveLoads[1]?.(true));
|
||||
await renderWindow(2, false);
|
||||
await harness.waitFor(
|
||||
() => !harness.container.querySelector("[data-question-jump-mask='true']"),
|
||||
"the final question-jump paint commit",
|
||||
);
|
||||
const jumpWrites = writes.filter((write) => write.owner === "jump");
|
||||
ok(requestedTurns.length === 2, "the replay performs only the required targeted requests");
|
||||
ok(jumpWrites.length === 1, "the 434→847→994 replay emits exactly one final jump");
|
||||
ok(Boolean(harness.container.querySelector("#question-anchor-history-u1")), "the final jump mounts the requested question");
|
||||
} finally {
|
||||
harness.dom.window.__REASONIX_TRANSCRIPT_SCROLL_WRITE__ = undefined;
|
||||
await harness.unmount();
|
||||
await harness.close();
|
||||
}
|
||||
}
|
||||
|
||||
// ── A targeted jump waits behind an existing history request ───────────────
|
||||
{
|
||||
const harness = await createTranscriptHarness({ viewportHeight: 200, rowHeight: 100 });
|
||||
try {
|
||||
let attempts = 0;
|
||||
let resolveLoad: ((loaded: boolean) => void) | undefined;
|
||||
const onLoadOlderHistory = () => {
|
||||
attempts += 1;
|
||||
return new Promise<boolean>((resolve) => { resolveLoad = resolve; });
|
||||
};
|
||||
const render = (loadingOlderHistory: boolean) => harness.render(historyTurns(81, 100), {
|
||||
running: false,
|
||||
questionNavigator: true,
|
||||
hasOlderHistory: true,
|
||||
loadingOlderHistory,
|
||||
historyStartTurn: 81,
|
||||
historyTotalTurns: 100,
|
||||
onLoadOlderHistory,
|
||||
});
|
||||
|
||||
await render(true);
|
||||
await harness.settle();
|
||||
const jumpScroll = stubRailGeometry(harness.container, 100);
|
||||
await act(async () => {
|
||||
jumpScroll.dispatchEvent(new MouseEvent("mousedown", {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
button: 0,
|
||||
clientY: railClientY(10, 100),
|
||||
}));
|
||||
});
|
||||
await harness.flush();
|
||||
ok(Boolean(harness.container.querySelector("[data-question-jump-mask='true']")), "an unloaded jump waits behind an existing history request without dropping its mask");
|
||||
ok(attempts === 0, "an existing history request prevents a duplicate targeted request");
|
||||
|
||||
await render(false);
|
||||
await harness.waitFor(() => attempts === 1, "the queued targeted request after existing history settles");
|
||||
await act(async () => resolveLoad?.(false));
|
||||
await harness.waitFor(
|
||||
() => !harness.container.querySelector("[data-question-jump-mask='true']"),
|
||||
"the queued targeted request terminal",
|
||||
);
|
||||
} finally {
|
||||
await harness.unmount();
|
||||
await harness.close();
|
||||
}
|
||||
}
|
||||
|
||||
// ── A rejected targeted request cannot strand the opaque surface ───────────
|
||||
{
|
||||
const harness = await createTranscriptHarness({ viewportHeight: 200, rowHeight: 100 });
|
||||
try {
|
||||
let attempts = 0;
|
||||
await harness.render(historyTurns(81, 100), {
|
||||
running: false,
|
||||
questionNavigator: true,
|
||||
hasOlderHistory: true,
|
||||
historyStartTurn: 81,
|
||||
historyTotalTurns: 100,
|
||||
onLoadOlderHistory: async () => {
|
||||
attempts += 1;
|
||||
return false;
|
||||
},
|
||||
});
|
||||
await harness.settle();
|
||||
const jumpScroll = stubRailGeometry(harness.container, 100);
|
||||
await act(async () => {
|
||||
jumpScroll.dispatchEvent(new MouseEvent("mousedown", {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
button: 0,
|
||||
clientY: railClientY(10, 100),
|
||||
}));
|
||||
});
|
||||
await harness.waitFor(() => attempts === 1, "the rejected targeted request");
|
||||
await harness.waitFor(
|
||||
() => !harness.container.querySelector("[data-question-jump-mask='true']"),
|
||||
"the rejected targeted request terminal",
|
||||
);
|
||||
ok(attempts === 1, "a rejected targeted request is not retried as a paging cascade");
|
||||
ok(!harness.container.querySelector("[data-question-jump-mask='true']"), "a rejected request cannot leave permanent loading");
|
||||
} finally {
|
||||
await harness.unmount();
|
||||
await harness.close();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Failed automatic loading stops and exposes a compact retry state ─────────
|
||||
{
|
||||
const harness = await createTranscriptHarness({ viewportHeight: 200, rowHeight: 100 });
|
||||
|
||||
@@ -84,7 +84,9 @@ const virtuosoHandle = {
|
||||
lastScrollByTop = options?.top ?? 0;
|
||||
scrollElement.scrollTop += lastScrollByTop;
|
||||
},
|
||||
scrollTo: () => {},
|
||||
scrollTo: (options?: { top?: number }) => {
|
||||
scrollElement.scrollTop = options?.top ?? scrollElement.scrollTop;
|
||||
},
|
||||
scrollToIndex: () => {},
|
||||
getState: () => {},
|
||||
} as unknown as VirtuosoHandle;
|
||||
@@ -211,6 +213,121 @@ await flushFrames();
|
||||
check(scrollByCalls === 0 && scrollWrites.length === 0,
|
||||
"selection ownership cancels a pending reader transaction");
|
||||
|
||||
// A question jump owns the whole masked paging/landing transaction, not only
|
||||
// the final indexed write. Entering it must invalidate a queued reader
|
||||
// correction, and a generic scrollend from the indexed placement must not
|
||||
// release ownership before the matching paint terminal.
|
||||
await act(async () => arbiter?.reset());
|
||||
scrollExtent = 5_000;
|
||||
scrollElement.scrollTop = 2_000;
|
||||
rowElement.getBoundingClientRect = () => rectAt(20);
|
||||
await act(async () => arbiter?.deliverScroll());
|
||||
await act(async () => arbiter?.releaseTailFollow());
|
||||
await act(async () => arbiter?.onWheelIntent({
|
||||
ctrlKey: false,
|
||||
deltaMode: 0,
|
||||
deltaX: 0,
|
||||
deltaY: 133.33,
|
||||
target: scrollElement,
|
||||
} as React.WheelEvent<HTMLElement>));
|
||||
scrollExtent = 4_000;
|
||||
scrollElement.scrollTop = 1_000;
|
||||
rowElement.getBoundingClientRect = () => rectAt(1_020);
|
||||
await act(async () => arbiter?.deliverScroll());
|
||||
await act(async () => arbiter?.beginQuestionJump(77));
|
||||
scrollWrites.length = 0;
|
||||
scrollByCalls = 0;
|
||||
scrollExtent = 5_000;
|
||||
await act(async () => arbiter?.followGrowingTail());
|
||||
await flushFrames();
|
||||
check(scrollWrites.length === 0 && scrollByCalls === 0,
|
||||
"question-jump ownership cancels queued reader and tail writers");
|
||||
await act(async () => arbiter?.finishProgrammaticScroll());
|
||||
check(String(arbiter?.modeRef.current) === "restoring",
|
||||
"generic scrollend cannot release a masked question jump");
|
||||
await act(async () => arbiter?.finishQuestionJump(76));
|
||||
check(String(arbiter?.modeRef.current) === "restoring",
|
||||
"a stale question-jump terminal cannot release the current transaction");
|
||||
await act(async () => arbiter?.finishQuestionJump(77));
|
||||
check(String(arbiter?.modeRef.current) === "manual",
|
||||
"the matching paint terminal releases question-jump ownership");
|
||||
|
||||
// A downward wheel at the physical bottom must not arm reader-extent
|
||||
// recovery. A later extent rebound would otherwise snap the viewport upward
|
||||
// and fight the user's wheel input.
|
||||
await act(async () => arbiter?.reset());
|
||||
scrollExtent = 2_000;
|
||||
Object.defineProperty(scrollElement, "clientHeight", { configurable: true, value: 725 });
|
||||
scrollElement.scrollTop = 1_275;
|
||||
await act(async () => arbiter?.deliverScroll());
|
||||
await act(async () => arbiter?.releaseTailFollow());
|
||||
scrollWrites.length = 0;
|
||||
scrollByCalls = 0;
|
||||
await act(async () => arbiter?.onWheelIntent({
|
||||
ctrlKey: false,
|
||||
deltaMode: 0,
|
||||
deltaX: 0,
|
||||
deltaY: 120,
|
||||
target: scrollElement,
|
||||
} as React.WheelEvent<HTMLElement>));
|
||||
scrollElement.scrollTop = 1_100;
|
||||
await act(async () => arbiter?.followGrowingTail());
|
||||
await flushFrames();
|
||||
check(scrollByCalls === 0 && scrollWrites.length === 0,
|
||||
"near-bottom downward wheel does not arm the reader-extent guard");
|
||||
|
||||
// A replacement native-thumb gesture owns a new pointer transaction. A late
|
||||
// pointerup from the displaced gesture must not release or demote it.
|
||||
await act(async () => arbiter?.reset());
|
||||
scrollExtent = 5_000;
|
||||
Object.defineProperties(scrollElement, {
|
||||
offsetWidth: { configurable: true, value: 800 },
|
||||
clientWidth: { configurable: true, value: 780 },
|
||||
clientLeft: { configurable: true, value: 0 },
|
||||
});
|
||||
scrollElement.getBoundingClientRect = () => rectAt(0);
|
||||
scrollElement.scrollTop = 1_000;
|
||||
const thumbPointer = (pointerId: number) => ({
|
||||
button: 0,
|
||||
clientX: 795,
|
||||
nativeEvent: { button: 0, clientX: 795, pointerId },
|
||||
}) as unknown as React.PointerEvent<HTMLElement>;
|
||||
await act(async () => {
|
||||
arbiter?.onPointerDownIntent(thumbPointer(1));
|
||||
arbiter?.onPointerDownIntent(thumbPointer(2));
|
||||
});
|
||||
const staleRelease = new dom.window.Event("pointerup", { bubbles: true });
|
||||
Object.defineProperty(staleRelease, "pointerId", { value: 1 });
|
||||
await act(async () => dom.window.dispatchEvent(staleRelease));
|
||||
check(scrollElement.dataset.nativeScrollbarDrag === "true",
|
||||
"a stale pointerup cannot release the replacement native thumb");
|
||||
check(String(arbiter?.modeRef.current) === "native-thumb",
|
||||
"the replacement thumb retains explicit scroll ownership");
|
||||
|
||||
// A stationary release at the physical bottom is insufficient: the same
|
||||
// pointer transaction must have observed forward native progress.
|
||||
scrollElement.scrollTop = 4_275;
|
||||
await act(async () => arbiter?.deliverScroll(scrollElement));
|
||||
const activeRelease = new dom.window.Event("pointerup", { bubbles: true });
|
||||
Object.defineProperty(activeRelease, "pointerId", { value: 2 });
|
||||
await act(async () => dom.window.dispatchEvent(activeRelease));
|
||||
check(String(arbiter?.modeRef.current) === "tail-follow",
|
||||
"the active thumb may commit tail ownership after forward native progress");
|
||||
|
||||
await act(async () => arbiter?.onPointerDownIntent(thumbPointer(3)));
|
||||
const stationaryRelease = new dom.window.Event("pointerup", { bubbles: true });
|
||||
Object.defineProperty(stationaryRelease, "pointerId", { value: 3 });
|
||||
await act(async () => dom.window.dispatchEvent(stationaryRelease));
|
||||
check(String(arbiter?.modeRef.current) === "manual",
|
||||
"a stationary thumb at the bottom cannot claim tail ownership");
|
||||
|
||||
await act(async () => arbiter?.onPointerDownIntent(thumbPointer(4)));
|
||||
await act(async () => arbiter?.reset());
|
||||
check(scrollElement.dataset.nativeScrollbarDrag === undefined,
|
||||
"a generation reset clears the native-thumb DOM transaction");
|
||||
check(String(arbiter?.modeRef.current) === "tail-follow",
|
||||
"a generation reset cannot retain native-thumb ownership");
|
||||
|
||||
await act(async () => root.unmount());
|
||||
dom.window.close();
|
||||
|
||||
|
||||
@@ -6,9 +6,11 @@ import { createRoot } from "react-dom/client";
|
||||
import type { StateSnapshot, VirtuosoHandle } from "react-virtuoso";
|
||||
import { useTranscriptScrollArbiter, type TranscriptRecoveryTerminal } from "../lib/useTranscriptScrollArbiter";
|
||||
import { useTranscriptLayoutIntegrity } from "../lib/useTranscriptLayoutIntegrity";
|
||||
import { createTranscriptMeasuredSizes } from "../lib/transcriptMeasuredSizes";
|
||||
import type { TranscriptScrollWriteRecord } from "../lib/transcriptScrollProbe";
|
||||
import type { TranscriptRow } from "../lib/transcriptRows";
|
||||
import { buildTranscriptRows, buildTurnModels, EMPTY_FOLDS, transcriptRowMeasurementVersion, type TranscriptRow } from "../lib/transcriptRows";
|
||||
import type { Item } from "../lib/useController";
|
||||
import { installTranscriptRaceClock } from "./helpers/transcriptRaceClock";
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
@@ -50,42 +52,7 @@ globalThis.cancelAnimationFrame = cancelFrame;
|
||||
dom.window.requestAnimationFrame = requestFrame;
|
||||
dom.window.cancelAnimationFrame = cancelFrame;
|
||||
|
||||
let clockNow = 10_000;
|
||||
let nextTimer = 1;
|
||||
const timers = new Map<number, { dueAt: number; run: () => void }>();
|
||||
const originalDateNow = Date.now;
|
||||
const originalSetTimeout = dom.window.setTimeout;
|
||||
const originalClearTimeout = dom.window.clearTimeout;
|
||||
Date.now = () => clockNow;
|
||||
dom.window.setTimeout = ((handler: TimerHandler, timeout = 0, ...args: unknown[]) => {
|
||||
const id = nextTimer;
|
||||
nextTimer += 1;
|
||||
const run = typeof handler === "function"
|
||||
? () => handler(...args)
|
||||
: () => { throw new Error("string timer handlers are unsupported in this test"); };
|
||||
timers.set(id, { dueAt: clockNow + Math.max(0, timeout), run });
|
||||
return id;
|
||||
}) as typeof dom.window.setTimeout;
|
||||
dom.window.clearTimeout = ((id: number | undefined) => {
|
||||
if (id !== undefined) timers.delete(id);
|
||||
}) as typeof dom.window.clearTimeout;
|
||||
|
||||
async function advanceClock(milliseconds: number) {
|
||||
await act(async () => {
|
||||
const target = clockNow + milliseconds;
|
||||
while (true) {
|
||||
const next = [...timers.entries()]
|
||||
.filter(([, timer]) => timer.dueAt <= target)
|
||||
.sort(([leftID, left], [rightID, right]) => left.dueAt - right.dueAt || leftID - rightID)[0];
|
||||
if (!next) break;
|
||||
const [id, timer] = next;
|
||||
timers.delete(id);
|
||||
clockNow = timer.dueAt;
|
||||
timer.run();
|
||||
}
|
||||
clockNow = target;
|
||||
});
|
||||
}
|
||||
const { advanceClock, restore: restoreClock } = installTranscriptRaceClock(dom.window as unknown as Window);
|
||||
|
||||
async function flushFrames() {
|
||||
const pending = [...frames.entries()];
|
||||
@@ -144,7 +111,7 @@ let integrity: ReturnType<typeof useTranscriptLayoutIntegrity> | undefined;
|
||||
function Probe({ surfaceKey, rows = baseRows, layoutWidth = 800 }: { surfaceKey: string; rows?: TranscriptRow[]; layoutWidth?: number }) {
|
||||
const scroll = useTranscriptScrollArbiter({
|
||||
onRecoveryTerminal: (terminal) => { terminals.push(terminal); },
|
||||
onItemMeasured: (rowKey, kind, height, width) => { rowMeasurements.push({ rowKey, kind, height, width }); },
|
||||
onItemMeasured: (rowKey, kind, _layoutVariant, height, width) => { rowMeasurements.push({ rowKey, kind, height, width }); },
|
||||
});
|
||||
const layout = useTranscriptLayoutIntegrity({
|
||||
surfaceKey,
|
||||
@@ -157,7 +124,6 @@ function Probe({ surfaceKey, rows = baseRows, layoutWidth = 800 }: { surfaceKey:
|
||||
submitRecoveryRequest: scroll.submitRecoveryRequest,
|
||||
retryRecoveryRequest: scroll.retryRecoveryRequest,
|
||||
lastGoodAnchorRef: scroll.lastGoodAnchorRef,
|
||||
captureStateSnapshot: scroll.captureStateSnapshot,
|
||||
layoutTransientRef: scroll.layoutTransientRef,
|
||||
layoutWidth,
|
||||
});
|
||||
@@ -209,6 +175,7 @@ await act(async () => {
|
||||
// the estimate Virtuoso started from, so the cache callback must receive the
|
||||
// returned DOM height instead.
|
||||
rowElement.dataset.rowKind = "answer";
|
||||
rowElement.dataset.transcriptLayoutVariant = "text-flow";
|
||||
rowElement.dataset.knownSize = "291";
|
||||
rowElement.getBoundingClientRect = () => ({ ...rectAt(200), height: 632, bottom: 832, width: 960, right: 960 });
|
||||
rowMeasurements.length = 0;
|
||||
@@ -674,8 +641,8 @@ await act(async () => arbiter?.setMode("manual", "question-navigation"));
|
||||
await act(async () => arbiter?.scrollToDataIndex(5));
|
||||
check(scrollToIndexCalls === 1, "question navigation emits one indexed jump after its explicit selection cleanup");
|
||||
|
||||
// ── T6: a snapshot captured before the keyed remount restores when the row
|
||||
// keys still match, and is discarded when they do not.
|
||||
// ── T6: surface switches reuse safe geometry through the LRU, never an old
|
||||
// Virtuoso scrollTop. The blank watchdog also discards its broken tree.
|
||||
stubSnapshot = {
|
||||
ranges: [{ startIndex: 0, endIndex: 0, size: 100 }, { startIndex: 1, endIndex: Infinity, size: 80 }],
|
||||
scrollTop: 420,
|
||||
@@ -687,16 +654,15 @@ check(integrity?.restoreSnapshot === undefined, "watchdog rebuild discards the s
|
||||
await act(async () => integrity?.handleItemsRendered(1));
|
||||
await flushFrames();
|
||||
|
||||
// Same-tab reveal (new surface, same rows): the snapshot applies and the
|
||||
// first-mount scrollToBottom is suppressed — it would yank the restored
|
||||
// view straight back to the tail.
|
||||
await switchSurface("surface-m");
|
||||
check(integrity?.restoreSnapshot === stubSnapshot, "same-row surface remount offers the captured snapshot");
|
||||
readyRef.current = false;
|
||||
// Same-tab reveal (new surface, same rows) is still a view reset. It opens at
|
||||
// the product-defined tail and does not restore an old reader position.
|
||||
const scrollToBottomBeforeSnapshot = scrollToBottomCalls;
|
||||
await switchSurface("surface-m");
|
||||
check(integrity?.restoreSnapshot === undefined, "same-row surface remount does not restore an old scrollTop");
|
||||
check(readyRef.current === false, "surface switch invalidates old readiness before incoming items render");
|
||||
await act(async () => integrity?.handleItemsRendered(1));
|
||||
await flushFrames();
|
||||
check(scrollToBottomCalls === scrollToBottomBeforeSnapshot, "a snapshot-restored mount does not jump to the bottom");
|
||||
check(scrollToBottomCalls === scrollToBottomBeforeSnapshot + 1, "same-row reveal follows normal tail positioning");
|
||||
|
||||
// ── T9: the incoming surface prepended older history since the capture;
|
||||
// changed data/totalCount must discard the snapshot per Virtuoso's contract.
|
||||
@@ -707,22 +673,86 @@ const prependedRows: TranscriptRow[] = [
|
||||
];
|
||||
await switchSurface("surface-n", prependedRows);
|
||||
check(integrity?.restoreSnapshot === undefined, "a prepended key sequence discards the captured snapshot");
|
||||
readyRef.current = false;
|
||||
await act(async () => integrity?.handleItemsRendered(1));
|
||||
await flushFrames();
|
||||
check(scrollToBottomCalls === scrollToBottomBeforeSnapshot + 1, "changed data falls back to normal first-mount positioning");
|
||||
check(scrollToBottomCalls === scrollToBottomBeforeSnapshot + 2, "changed data falls back to normal first-mount positioning");
|
||||
|
||||
// Different session (disjoint keys): the snapshot is discarded and the
|
||||
// first mount settles at the bottom as before.
|
||||
const foreignRows: TranscriptRow[] = [{ kind: "answer", key: "row-elsewhere", item: { ...item, id: "elsewhere" } }];
|
||||
await switchSurface("surface-l", foreignRows);
|
||||
check(integrity?.restoreSnapshot === undefined, "a disjoint key sequence discards the snapshot");
|
||||
readyRef.current = false;
|
||||
await act(async () => integrity?.handleItemsRendered(1));
|
||||
await flushFrames();
|
||||
check(scrollToBottomCalls === scrollToBottomBeforeSnapshot + 2, "a disjoint snapshot-less first mount settles at the bottom");
|
||||
check(scrollToBottomCalls === scrollToBottomBeforeSnapshot + 3, "a disjoint snapshot-less first mount settles at the bottom");
|
||||
stubSnapshot = null;
|
||||
|
||||
// A prepended turn may reuse the mounted process id while its content patches.
|
||||
const duplicateCurrent: Item[] = [
|
||||
{ kind: "user", id: "u-duplicate-current", text: "current" }, { kind: "phase", id: "duplicate-process-id", text: "working" },
|
||||
];
|
||||
const duplicateOptions = { folds: EMPTY_FOLDS, foldPreference: "auto" as const, hasOlderHistory: false, creationMode: false, turnForUser: () => undefined };
|
||||
const duplicateBeforeRows = buildTranscriptRows(buildTurnModels(duplicateCurrent), duplicateOptions);
|
||||
const duplicateAfterRows = buildTranscriptRows(buildTurnModels([
|
||||
{ kind: "user", id: "u-duplicate-older", text: "older" }, { kind: "phase", id: "duplicate-process-id", text: "older work" },
|
||||
{ kind: "assistant", id: "a-duplicate-older", text: "older answer", reasoning: "", streaming: false }, duplicateCurrent[0],
|
||||
{ ...duplicateCurrent[1], text: "working with a late patch" } as Item,
|
||||
{ kind: "assistant", id: "a-duplicate-current", text: "late outside answer", reasoning: "", streaming: false },
|
||||
]), duplicateOptions);
|
||||
const duplicateBeforeHeader = duplicateBeforeRows.find((row) => row.kind === "process-header")!;
|
||||
const duplicateAfterHeader = duplicateAfterRows.find((row) => row.kind === "process-header" && "segment" in row
|
||||
&& row.segment.processItems.some((item) => item.kind === "phase" && item.text.includes("late patch")))!;
|
||||
check(duplicateAfterHeader.key === duplicateBeforeHeader.key, "prepend plus outside-content patch preserves the mounted duplicate-process row key");
|
||||
const duplicateMeasurements = createTranscriptMeasuredSizes();
|
||||
const duplicateEnvironment = { contentWidth: 800, typographySignature: "race-test" };
|
||||
duplicateMeasurements.recordGeometry("duplicate-session", { rowKey: String(duplicateBeforeHeader.key), kind: duplicateBeforeHeader.kind,
|
||||
layoutVariant: duplicateBeforeHeader.layoutVariant, height: 144, environment: duplicateEnvironment,
|
||||
measurementVersion: transcriptRowMeasurementVersion(duplicateBeforeHeader) });
|
||||
check(duplicateMeasurements.synthesizeDetailed("duplicate-session", [duplicateBeforeHeader], duplicateEnvironment).estimateSources[0] === "exact", "the mounted duplicate-process row reuses its exact measured height before the patch");
|
||||
check(duplicateMeasurements.synthesizeDetailed("duplicate-session", [duplicateAfterHeader], duplicateEnvironment).estimateSources[0] !== "exact", "the late process patch invalidates only its stale measurement version");
|
||||
await switchSurface("surface-duplicate-process", duplicateBeforeRows);
|
||||
await act(async () => arbiter?.releaseTailFollow());
|
||||
scrollElement.scrollTop = 160; const duplicateResetKey = integrity?.resetKey;
|
||||
scrollWrites.length = 0; scrollByCalls = 0; scrollToCalls = 0; scrollToIndexCalls = 0;
|
||||
await act(async () => root.render(<Probe surfaceKey="surface-duplicate-process" rows={duplicateAfterRows} />));
|
||||
await flushFrames();
|
||||
check(integrity?.resetKey === duplicateResetKey, "prepend plus outside-content patch keeps the Virtuoso generation mounted");
|
||||
check(scrollElement.scrollTop === 160, "prepend plus outside-content patch preserves the reader viewport");
|
||||
check(scrollByCalls === 0 && scrollToCalls === 0 && scrollToIndexCalls === 0 && scrollWrites.length === 0,
|
||||
"prepend plus outside-content patch emits no recovery or direct scroll writes");
|
||||
|
||||
// Imported pages may also repeat user/assistant ids. The already mounted
|
||||
// current turn keeps its unsuffixed keys while older duplicates receive stable
|
||||
// identity hashes, so the prepend does not reset the reader's surface.
|
||||
const duplicateTurnCurrent: Item[] = [
|
||||
{ kind: "user", id: "duplicate-turn-user", text: "current", createdAt: 200 },
|
||||
{ kind: "assistant", id: "duplicate-turn-answer", text: "current answer", reasoning: "", streaming: false },
|
||||
];
|
||||
const duplicateTurnBeforeRows = buildTranscriptRows(buildTurnModels(duplicateTurnCurrent), duplicateOptions);
|
||||
const duplicateTurnAfterRows = buildTranscriptRows(buildTurnModels([
|
||||
{ kind: "user", id: "duplicate-turn-user", text: "older", createdAt: 100, historyTurn: 1 },
|
||||
{ kind: "assistant", id: "duplicate-turn-answer", text: "older answer", reasoning: "", streaming: false },
|
||||
...duplicateTurnCurrent,
|
||||
]), duplicateOptions);
|
||||
const duplicateTurnBeforeKeys = duplicateTurnBeforeRows.map((row) => row.key);
|
||||
const duplicateTurnCurrentKeys = duplicateTurnAfterRows.filter((row) =>
|
||||
(row.kind === "user" && row.item.text === "current")
|
||||
|| (row.kind === "answer" && row.item.text === "current answer")
|
||||
).map((row) => row.key);
|
||||
check(JSON.stringify(duplicateTurnCurrentKeys) === JSON.stringify(duplicateTurnBeforeKeys),
|
||||
"prepending duplicate turn ids preserves every mounted current-turn row key");
|
||||
check(duplicateTurnAfterRows.slice(0, 2).every((row) => String(row.key).includes("@") && !String(row.key).includes("#")),
|
||||
"older duplicate turn rows use immutable identity hashes instead of occurrence suffixes");
|
||||
await switchSurface("surface-duplicate-turn", duplicateTurnBeforeRows);
|
||||
await act(async () => arbiter?.releaseTailFollow());
|
||||
scrollElement.scrollTop = 180; const duplicateTurnResetKey = integrity?.resetKey;
|
||||
scrollWrites.length = 0; scrollByCalls = 0; scrollToCalls = 0; scrollToIndexCalls = 0;
|
||||
await act(async () => root.render(<Probe surfaceKey="surface-duplicate-turn" rows={duplicateTurnAfterRows} />));
|
||||
await flushFrames();
|
||||
check(integrity?.resetKey === duplicateTurnResetKey, "duplicate turn prepend keeps the Virtuoso generation mounted");
|
||||
check(scrollElement.scrollTop === 180, "duplicate turn prepend preserves the reader viewport");
|
||||
check(scrollByCalls === 0 && scrollToCalls === 0 && scrollToIndexCalls === 0 && scrollWrites.length === 0,
|
||||
"duplicate turn prepend emits no recovery or direct scroll writes");
|
||||
// A 10,000-row generation gets only one keyed reset and one bounded probe.
|
||||
const longRows: TranscriptRow[] = Array.from({ length: 10_000 }, (_, index) => ({
|
||||
kind: "answer", key: `long-${index}`,
|
||||
@@ -755,9 +785,7 @@ await triggerWatchdogRebuild();
|
||||
check(integrity?.resetKey !== nextGenerationResetBefore, "a changed 10,000-row generation receives a fresh hard-reset budget");
|
||||
|
||||
await act(async () => root.unmount());
|
||||
Date.now = originalDateNow;
|
||||
dom.window.setTimeout = originalSetTimeout;
|
||||
dom.window.clearTimeout = originalClearTimeout;
|
||||
restoreClock();
|
||||
dom.window.close();
|
||||
|
||||
if (failed > 0) {
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
// Run: tsx src/__tests__/transcript-row-geometry.test.ts
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
estimateTranscriptRowGeometry,
|
||||
resolveReasoningLayoutVariant,
|
||||
resolveToolCardDefaultOpen,
|
||||
type TranscriptGeometryEnvironment,
|
||||
} from "../lib/transcriptRowGeometry";
|
||||
import type { TranscriptRow } from "../lib/transcriptRows";
|
||||
import type { Item } from "../lib/useController";
|
||||
|
||||
const environment: TranscriptGeometryEnvironment = {
|
||||
contentWidth: 960,
|
||||
typographySignature: "conversation:14/22|code:12/19|metadata:12/19",
|
||||
};
|
||||
|
||||
function reasoningRow(text: string, layoutVariant: "reasoning-summary" | "reasoning-heading-only" | "reasoning-expanded"): TranscriptRow {
|
||||
return {
|
||||
kind: "reasoning",
|
||||
key: `r:${text.length}`,
|
||||
item: { kind: "assistant", id: `a:${text.length}`, text: "", reasoning: text, streaming: false },
|
||||
segmentKey: "segment",
|
||||
layoutVariant,
|
||||
};
|
||||
}
|
||||
|
||||
assert.equal(resolveReasoningLayoutVariant("summary", false), "reasoning-summary");
|
||||
assert.equal(resolveReasoningLayoutVariant("auto", false), "reasoning-summary");
|
||||
assert.equal(resolveReasoningLayoutVariant("auto", true), "reasoning-expanded");
|
||||
assert.equal(resolveReasoningLayoutVariant("expanded", false), "reasoning-expanded");
|
||||
assert.equal(resolveReasoningLayoutVariant("legacy-collapsed", false), "reasoning-heading-only");
|
||||
assert.equal(resolveReasoningLayoutVariant("hidden", false), null);
|
||||
assert.equal(resolveReasoningLayoutVariant("pending", false), null);
|
||||
|
||||
const shortCollapsed = estimateTranscriptRowGeometry(reasoningRow("十个字的折叠思考内容", "reasoning-summary"), environment);
|
||||
const hugeCollapsed = estimateTranscriptRowGeometry(reasoningRow("你".repeat(100_000), "reasoning-summary"), environment);
|
||||
assert.equal(shortCollapsed, hugeCollapsed, "collapsed reasoning geometry is independent of hidden full text");
|
||||
assert.equal(shortCollapsed, 64, "summary geometry matches the compact browser contract");
|
||||
assert.equal(
|
||||
estimateTranscriptRowGeometry(reasoningRow("你".repeat(100_000), "reasoning-heading-only"), environment),
|
||||
36,
|
||||
"legacy collapsed reasoning uses the heading-only contract",
|
||||
);
|
||||
assert.ok(
|
||||
estimateTranscriptRowGeometry(reasoningRow("你".repeat(100_000), "reasoning-expanded"), environment) > shortCollapsed,
|
||||
"expanded reasoning is the only variant that estimates the full body",
|
||||
);
|
||||
|
||||
const markdownAnswer = {
|
||||
kind: "answer" as const,
|
||||
key: "answer:markdown",
|
||||
item: {
|
||||
kind: "assistant" as const,
|
||||
id: "answer:markdown",
|
||||
streaming: false,
|
||||
reasoning: "",
|
||||
text: "### Geometry block\n\n折叠布局检查完成,answer 保持内容感知估高。\n\n- 中文换行\n- English wrapping",
|
||||
},
|
||||
layoutVariant: "text-flow" as const,
|
||||
};
|
||||
const codeAnswer = {
|
||||
...markdownAnswer,
|
||||
key: "answer:code",
|
||||
item: {
|
||||
...markdownAnswer.item,
|
||||
id: "answer:code",
|
||||
text: "### Geometry block\n\n折叠布局检查完成,answer 保持内容感知估高。\n\n```ts\nconst stable = true;\n```",
|
||||
},
|
||||
};
|
||||
assert.equal(estimateTranscriptRowGeometry(markdownAnswer, environment), 157, "structured Markdown follows its final browser geometry");
|
||||
assert.equal(estimateTranscriptRowGeometry(codeAnswer, environment), 152, "fenced Markdown uses the padded code-block geometry");
|
||||
|
||||
const runningTask: Extract<Item, { kind: "tool" }> = {
|
||||
kind: "tool",
|
||||
id: "task",
|
||||
name: "task",
|
||||
args: "{}",
|
||||
readOnly: false,
|
||||
status: "running",
|
||||
};
|
||||
assert.equal(resolveToolCardDefaultOpen(runningTask, 2, "summary"), true, "running nested tools default open");
|
||||
assert.equal(resolveToolCardDefaultOpen({ ...runningTask, status: "done" }, 2, "summary"), false, "settled nested tools default closed");
|
||||
assert.equal(resolveToolCardDefaultOpen({
|
||||
...runningTask,
|
||||
status: "running",
|
||||
subagentProgress: {
|
||||
phase: "reasoning",
|
||||
reasoning: "working",
|
||||
text: "",
|
||||
notice: "",
|
||||
lastActivityAt: 1,
|
||||
truncated: false,
|
||||
startedAt: 1,
|
||||
},
|
||||
}, 0, "auto"), true, "auto mode follows live subagent reasoning");
|
||||
|
||||
console.log("transcript row geometry tests passed");
|
||||
@@ -59,6 +59,64 @@ const fixture: Item[] = [
|
||||
{ kind: "assistant", id: "a3", text: "answer two", reasoning: "more", streaming: false },
|
||||
];
|
||||
|
||||
{
|
||||
const current: Item[] = [
|
||||
{ kind: "user", id: "u-current", text: "current" },
|
||||
{ kind: "phase", id: "duplicate-phase", text: "current work" },
|
||||
];
|
||||
const before = buildTurnModels(current)[0]?.segments[0]?.key;
|
||||
const after = buildTurnModels([
|
||||
{ kind: "user", id: "u-older", text: "older" },
|
||||
{ kind: "phase", id: "duplicate-phase", text: "older work" },
|
||||
...current,
|
||||
])[1]?.segments[0]?.key;
|
||||
eq(after, before, "prepending history with a duplicate raw id preserves the mounted segment key");
|
||||
}
|
||||
|
||||
{
|
||||
const reasoningOnly: Item[] = [
|
||||
{ kind: "user", id: "u-late-answer", text: "question" },
|
||||
{ kind: "assistant", id: "a-late-answer", text: "", reasoning: "thinking", streaming: false },
|
||||
];
|
||||
const before = buildTurnModels(reasoningOnly)[0]?.segments[0]?.key;
|
||||
const after = buildTurnModels([
|
||||
reasoningOnly[0],
|
||||
reasoningOnly[1],
|
||||
{ kind: "assistant", id: "a-late-answer-visible", text: "resolved answer", reasoning: "", streaming: false },
|
||||
])[0]?.segments[0]?.key;
|
||||
eq(after, before, "outside answer content does not remount the existing process segment");
|
||||
}
|
||||
|
||||
{
|
||||
const currentUser: Item = { kind: "user", id: "duplicate-user", text: "current", createdAt: 200 };
|
||||
const currentAnswer: Item = { kind: "assistant", id: "duplicate-answer", text: "current answer", reasoning: "", streaming: false };
|
||||
const options = {
|
||||
folds: EMPTY_FOLDS,
|
||||
foldPreference: "auto" as const,
|
||||
hasOlderHistory: false,
|
||||
creationMode: false,
|
||||
turnForUser: () => undefined,
|
||||
};
|
||||
const before = buildTranscriptRows(buildTurnModels([currentUser, currentAnswer]), options);
|
||||
const after = buildTranscriptRows(buildTurnModels([
|
||||
{ kind: "user", id: "duplicate-user", text: "older", createdAt: 100, historyTurn: 1 },
|
||||
{ kind: "assistant", id: "duplicate-answer", text: "older answer", reasoning: "", streaming: false },
|
||||
currentUser,
|
||||
currentAnswer,
|
||||
]), options);
|
||||
const beforeCurrent = before.filter((row) => row.kind === "user" || row.kind === "answer").map((row) => row.key).join(",");
|
||||
const afterCurrent = after.filter((row) =>
|
||||
(row.kind === "user" && row.item.text === "current")
|
||||
|| (row.kind === "answer" && row.item.text === "current answer")
|
||||
).map((row) => row.key).join(",");
|
||||
eq(afterCurrent, beforeCurrent, "prepending duplicate user/answer ids preserves the mounted current row keys");
|
||||
const olderKeys = after.filter((row) =>
|
||||
(row.kind === "user" && row.item.text === "older")
|
||||
|| (row.kind === "answer" && row.item.text === "older answer")
|
||||
).map((row) => row.key);
|
||||
ok(olderKeys.every((key) => key.includes("@") && !key.includes("#")), "duplicate history rows use immutable identity hashes instead of occurrence suffixes");
|
||||
}
|
||||
|
||||
const turnOf = new Map([
|
||||
["u1", 0],
|
||||
["u2", 1],
|
||||
@@ -84,7 +142,7 @@ const keys = (rows: TranscriptRow[]) => rows.map((row) => row.key).join(",");
|
||||
);
|
||||
eq(
|
||||
keys(rows),
|
||||
"u:u1,ph:a1,n:n1,a:a2,ta:u1,u:u2,ph:a3,a:a3,ta:u2",
|
||||
`u:u1,ph:${models[0].segments[0].key},n:n1,a:a2,ta:u1,u:u2,ph:${models[1].segments[0].key},a:a3,ta:u2`,
|
||||
"row keys derive from stable item ids",
|
||||
);
|
||||
}
|
||||
@@ -100,8 +158,9 @@ const keys = (rows: TranscriptRow[]) => rows.map((row) => row.key).join(",");
|
||||
// Expanding a fold inserts its process rows into the model; collapsing
|
||||
// removes them again. Read-only tools batch; the write tool stays a card.
|
||||
const models = buildTurnModels(fixture);
|
||||
const foldKey = models[0].segments[0].key;
|
||||
let folds = EMPTY_FOLDS;
|
||||
folds = foldMapWithToggle(folds, "a1", false);
|
||||
folds = foldMapWithToggle(folds, foldKey, false);
|
||||
const openRows = buildTranscriptRows(models, rowOptions(folds));
|
||||
eq(
|
||||
kinds(openRows),
|
||||
@@ -110,7 +169,7 @@ const keys = (rows: TranscriptRow[]) => rows.map((row) => row.key).join(",");
|
||||
);
|
||||
const batch = openRows.find((row) => row.kind === "tool-batch");
|
||||
eq(batch && "items" in batch ? batch.items.length : 0, 2, "consecutive completed read-only tools batch into one row");
|
||||
const collapsed = buildTranscriptRows(models, rowOptions(foldMapWithToggle(folds, "a1", true)));
|
||||
const collapsed = buildTranscriptRows(models, rowOptions(foldMapWithToggle(folds, foldKey, true)));
|
||||
eq(kinds(collapsed), "user,process-header,notice,answer,turn-actions,user,process-header,answer,turn-actions", "collapsing removes the process rows again");
|
||||
}
|
||||
|
||||
@@ -234,13 +293,14 @@ const keys = (rows: TranscriptRow[]) => rows.map((row) => row.key).join(",");
|
||||
eq(defaultFoldOpen(states[0], "auto"), true, "running folds default open");
|
||||
|
||||
const seeded = reconcileFoldEntries(EMPTY_FOLDS, states, "auto", false);
|
||||
ok(seeded?.get("a1")?.open === true, "reconcile seeds running folds open");
|
||||
const foldKey = states[0].key;
|
||||
ok(seeded?.get(foldKey)?.open === true, "reconcile seeds running folds open");
|
||||
|
||||
const settledModels = buildTurnModels(fixture.slice(0, 7), undefined, false);
|
||||
const settledStates = foldSegmentStates(settledModels);
|
||||
eq(settledStates[0].hasRunningWork, false, "settled turn clears the running flag");
|
||||
const closed = reconcileFoldEntries(seeded ?? EMPTY_FOLDS, settledStates, "auto", false);
|
||||
ok(closed?.get("a1")?.open === false, "completion auto-closes an untouched fold");
|
||||
ok(closed?.get(foldKey)?.open === false, "completion auto-closes an untouched fold");
|
||||
eq(reconcileFoldEntries(closed ?? EMPTY_FOLDS, settledStates, "auto", false), null, "steady state reconciles to no change");
|
||||
}
|
||||
|
||||
@@ -248,31 +308,33 @@ const keys = (rows: TranscriptRow[]) => rows.map((row) => row.key).join(",");
|
||||
// Expanded reasoning pins only reasoning-bearing folds across completion.
|
||||
const running = buildTurnModels(fixture.slice(0, 7), { id: "a2", hasAnswerText: true, hasReasoning: false, reasoningComplete: true }, true);
|
||||
const runningStates = foldSegmentStates(running, true);
|
||||
const foldKey = runningStates[0].key;
|
||||
eq(runningStates[0]?.keepReasoningExpanded, true, "expanded mode marks a reasoning-bearing fold as pinned");
|
||||
const seeded = reconcileFoldEntries(EMPTY_FOLDS, runningStates, "auto", false) ?? EMPTY_FOLDS;
|
||||
|
||||
const settledModels = buildTurnModels(fixture.slice(0, 7), undefined, false);
|
||||
const settledPinnedStates = foldSegmentStates(settledModels, true);
|
||||
const completed = reconcileFoldEntries(seeded, settledPinnedStates, "auto", false);
|
||||
ok(completed?.get("a1")?.open === true, "expanded reasoning keeps its parent fold open after completion");
|
||||
ok(completed?.get(foldKey)?.open === true, "expanded reasoning keeps its parent fold open after completion");
|
||||
|
||||
const manuallyCollapsed = foldMapWithToggle(seeded, "a1", true);
|
||||
const manuallyCollapsed = foldMapWithToggle(seeded, foldKey, true);
|
||||
const completedCollapsed = reconcileFoldEntries(manuallyCollapsed, settledPinnedStates, "auto", false);
|
||||
ok(completedCollapsed?.get("a1")?.open === false, "manual parent collapse still wins when expanded reasoning completes");
|
||||
ok(completedCollapsed?.get(foldKey)?.open === false, "manual parent collapse still wins when expanded reasoning completes");
|
||||
|
||||
const settledAutoStates = foldSegmentStates(settledModels, false);
|
||||
const backToAuto = reconcileFoldEntries(completed ?? EMPTY_FOLDS, settledAutoStates, "auto", false);
|
||||
ok(backToAuto?.get("a1")?.open === false, "leaving expanded reasoning re-applies the automatic parent fold policy");
|
||||
ok(backToAuto?.get(foldKey)?.open === false, "leaving expanded reasoning re-applies the automatic parent fold policy");
|
||||
}
|
||||
|
||||
{
|
||||
// User override survives completion; a fold with nothing outside never
|
||||
// auto-closes.
|
||||
const overridden: FoldMap = new Map([["a1", { open: true, userOverridden: true, running: true }]]);
|
||||
const settledModels = buildTurnModels(fixture.slice(0, 7), undefined, false);
|
||||
const states = foldSegmentStates(settledModels);
|
||||
const foldKey = states[0].key;
|
||||
const overridden: FoldMap = new Map([[foldKey, { open: true, userOverridden: true, running: true }]]);
|
||||
const next = reconcileFoldEntries(overridden, states, "auto", false);
|
||||
ok(next?.get("a1")?.open === true, "user-opened fold survives completion");
|
||||
ok(next?.get(foldKey)?.open === true, "user-opened fold survives completion");
|
||||
|
||||
const soloModels = buildTurnModels([
|
||||
{ kind: "user", id: "u5", text: "cancelled" },
|
||||
@@ -285,13 +347,14 @@ const keys = (rows: TranscriptRow[]) => rows.map((row) => row.key).join(",");
|
||||
|
||||
{
|
||||
// Preference switches apply to existing folds and clear manual overrides.
|
||||
const seeded: FoldMap = new Map([["a1", { open: true, userOverridden: true, running: false }]]);
|
||||
const models = buildTurnModels(fixture.slice(0, 7), undefined, false);
|
||||
const states = foldSegmentStates(models);
|
||||
const foldKey = states[0].key;
|
||||
const seeded: FoldMap = new Map([[foldKey, { open: true, userOverridden: true, running: false }]]);
|
||||
const expanded = reconcileFoldEntries(seeded, states, "expanded", true);
|
||||
ok(expanded?.get("a1")?.open === true && expanded.get("a1")?.userOverridden === false, "switching to expanded opens folds and clears overrides");
|
||||
ok(expanded?.get(foldKey)?.open === true && expanded.get(foldKey)?.userOverridden === false, "switching to expanded opens folds and clears overrides");
|
||||
const backToAuto = reconcileFoldEntries(expanded ?? EMPTY_FOLDS, states, "auto", true);
|
||||
ok(backToAuto?.get("a1")?.open === false, "switching back to auto re-closes completed folds");
|
||||
ok(backToAuto?.get(foldKey)?.open === false, "switching back to auto re-closes completed folds");
|
||||
|
||||
const pruned = reconcileFoldEntries(backToAuto ?? EMPTY_FOLDS, [], "auto", false);
|
||||
ok(pruned?.size === 0, "vanished segments are pruned from the fold map");
|
||||
@@ -309,6 +372,18 @@ const keys = (rows: TranscriptRow[]) => rows.map((row) => row.key).join(",");
|
||||
eq(historyEntryIdForRow({ kind: "older-history", key: "older-history" }), undefined, "the paging row has no entry");
|
||||
}
|
||||
|
||||
{
|
||||
const localOnly: Item[] = [
|
||||
{ kind: "user", id: "u1", text: "first" },
|
||||
{ kind: "tool", id: "__reasonix_local_only__", name: "__reasonix_local_only__", args: "", readOnly: false, status: "done", output: "partial one" },
|
||||
{ kind: "user", id: "u2", text: "second" },
|
||||
{ kind: "tool", id: "__reasonix_local_only__", name: "__reasonix_local_only__", args: "", readOnly: false, status: "done", output: "partial two" },
|
||||
];
|
||||
const rows = buildTranscriptRows(buildTurnModels(localOnly), rowOptions(EMPTY_FOLDS, "expanded"));
|
||||
const rowKeys = rows.map((row) => row.key);
|
||||
eq(new Set(rowKeys).size, rowKeys.length, "two local-only recoveries cannot share ph:/t: row keys");
|
||||
}
|
||||
|
||||
// ── Size estimates ────────────────────────────────────────────────────────────
|
||||
|
||||
{
|
||||
|
||||
@@ -143,17 +143,30 @@ const detailDiagnostics = createTranscriptScrollDiagnostics({
|
||||
detailDiagnostics.start();
|
||||
detailDiagnostics.record("row-measure", {
|
||||
rowIndex: 44,
|
||||
rowKind: "answer",
|
||||
estimatedSize: 1_800,
|
||||
previousSize: 1_800,
|
||||
measuredSize: 420,
|
||||
sizeDelta: -1_380,
|
||||
rowKind: "reasoning",
|
||||
layoutVariant: "reasoning-summary",
|
||||
estimateSource: "static",
|
||||
estimatedSize: 64,
|
||||
previousSize: 64,
|
||||
measuredSize: 64,
|
||||
sizeDelta: 0,
|
||||
contentRevision: 3,
|
||||
foldState: "closed",
|
||||
disclosureCount: 1,
|
||||
rowKey: "PRIVATE_ROW_KEY",
|
||||
transcriptText: "PRIVATE_TRANSCRIPT_CANARY",
|
||||
});
|
||||
detailDiagnostics.record("geometry-contract-violation", {
|
||||
rowIndex: 45,
|
||||
rowKind: "tool",
|
||||
layoutVariant: "tool-collapsed",
|
||||
estimateSource: "static",
|
||||
estimatedSize: 37,
|
||||
measuredSize: 55,
|
||||
sizeDelta: 18,
|
||||
relativeError: 18 / 37,
|
||||
rowKey: "PRIVATE_ROW_KEY_2",
|
||||
});
|
||||
detailDiagnostics.record("scroll-state", {
|
||||
source: "jump-bottom",
|
||||
previousMode: "manual",
|
||||
@@ -184,20 +197,38 @@ assert.deepEqual(rowMeasurement, {
|
||||
t: 0,
|
||||
type: "row-measure",
|
||||
rowIndex: 44,
|
||||
estimatedSize: 1_800,
|
||||
previousSize: 1_800,
|
||||
measuredSize: 420,
|
||||
sizeDelta: -1_380,
|
||||
estimatedSize: 64,
|
||||
previousSize: 64,
|
||||
measuredSize: 64,
|
||||
sizeDelta: 0,
|
||||
contentRevision: 3,
|
||||
disclosureCount: 1,
|
||||
rowKind: "answer",
|
||||
rowKind: "reasoning",
|
||||
layoutVariant: "reasoning-summary",
|
||||
estimateSource: "static",
|
||||
foldState: "closed",
|
||||
});
|
||||
const geometryViolation = detailPayload.events.find((event) => event.type === "geometry-contract-violation");
|
||||
assert.deepEqual(geometryViolation, {
|
||||
t: 0,
|
||||
type: "geometry-contract-violation",
|
||||
rowIndex: 45,
|
||||
estimatedSize: 37,
|
||||
measuredSize: 55,
|
||||
sizeDelta: 18,
|
||||
relativeError: 0.49,
|
||||
rowKind: "tool",
|
||||
layoutVariant: "tool-collapsed",
|
||||
estimateSource: "static",
|
||||
});
|
||||
const detailSerialized = JSON.stringify(detailPayload);
|
||||
assert.equal(detailSerialized.includes("PRIVATE_ROW_KEY"), false, "row measurements exclude stable row keys");
|
||||
assert.equal(detailSerialized.includes("PRIVATE_TRANSCRIPT_CANARY"), false, "row measurements exclude transcript text");
|
||||
assert.equal(detailSerialized.includes("PRIVATE_ROW_KEY_2"), false, "geometry violations exclude stable row keys");
|
||||
|
||||
assert.equal(isTranscriptScrollDiagnosticsBuild("test", false), true, "test builds expose diagnostics");
|
||||
assert.equal(isTranscriptScrollDiagnosticsBuild("preview", false), true, "preview builds expose diagnostics");
|
||||
assert.equal(isTranscriptScrollDiagnosticsBuild("canary", false), true, "canary builds expose diagnostics");
|
||||
assert.equal(isTranscriptScrollDiagnosticsBuild("stable", true), true, "development server exposes diagnostics");
|
||||
assert.equal(isTranscriptScrollDiagnosticsBuild("stable", false), false, "stable production hides diagnostics");
|
||||
|
||||
|
||||
@@ -8,7 +8,12 @@ import {
|
||||
type TranscriptScrollEvent,
|
||||
type TranscriptScrollState,
|
||||
} from "../lib/transcriptScrollArbiter";
|
||||
import { pinTranscriptScrollerToNativeTail, pinTranscriptTailAfterViewportShrink } from "../lib/transcriptScrollGeometry";
|
||||
import { pinTranscriptTailAfterViewportShrink } from "../lib/transcriptScrollGeometry";
|
||||
import {
|
||||
TRANSCRIPT_TAIL_REARM_MIN_HEIGHT_PX,
|
||||
transcriptTailSettleBudgetExhausted,
|
||||
transcriptTailShouldReaim,
|
||||
} from "../lib/transcriptTailSettle";
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
@@ -221,6 +226,12 @@ check(
|
||||
"delivered displacement and later growth both reconverge while tail-follow owns the viewport",
|
||||
);
|
||||
|
||||
check(transcriptTailSettleBudgetExhausted(0) === false, "tail settle may re-aim before its bounded budget is spent");
|
||||
check(transcriptTailSettleBudgetExhausted(8) === true, "tail settle stops at its bounded re-aim budget");
|
||||
check(transcriptTailShouldReaim(null, 1_000) === true, "a fresh tail settle always re-aims");
|
||||
check(transcriptTailShouldReaim(1_000, 1_000 + TRANSCRIPT_TAIL_REARM_MIN_HEIGHT_PX - 1) === false, "sub-threshold tail measurement jitter does not re-aim");
|
||||
check(transcriptTailShouldReaim(1_000, 1_000 + TRANSCRIPT_TAIL_REARM_MIN_HEIGHT_PX) === true, "real tail growth re-arms the settle writer");
|
||||
|
||||
const repeatedDisplacement = run([
|
||||
{ type: "SCROLL_DELIVERED", atBottom: true, scrollable: true },
|
||||
{ type: "SCROLL_DELIVERED", atBottom: false, scrollable: true },
|
||||
@@ -256,9 +267,9 @@ check(
|
||||
);
|
||||
|
||||
const wrapScroller = { scrollHeight: 500, scrollTop: 400, clientHeight: 80 };
|
||||
check(pinTranscriptScrollerToNativeTail(wrapScroller) === true, "a composer-wrap viewport shrink is off-bottom and gets pinned");
|
||||
check(wrapScroller.scrollTop === 420, "pin writes the native tail top");
|
||||
check(pinTranscriptScrollerToNativeTail(wrapScroller) === false, "an already-pinned scroller is left alone");
|
||||
check(pinTranscriptTailAfterViewportShrink(wrapScroller, { contentExtent: 500, viewportExtent: 100 }, true) === 420, "a composer-wrap shrink returns the native tail target");
|
||||
check(wrapScroller.scrollTop === 400, "geometry helper does not write the native scroll position");
|
||||
check(pinTranscriptTailAfterViewportShrink(wrapScroller, { contentExtent: 500, viewportExtent: 80 }, true) === null, "the same shrink revision does not schedule a second tail write");
|
||||
|
||||
const foldScroller = { scrollHeight: 500, scrollTop: 400, clientHeight: 80 };
|
||||
check(
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
// Run: tsx src/__tests__/transcript-scroll-writer.test.ts
|
||||
|
||||
import { equal } from "node:assert/strict";
|
||||
import { JSDOM } from "jsdom";
|
||||
import type { VirtuosoHandle } from "react-virtuoso";
|
||||
import { createTranscriptScrollWriter } from "../lib/transcriptScrollWriter";
|
||||
import type { TranscriptScrollMode } from "../lib/transcriptScrollArbiter";
|
||||
import type { TranscriptScrollWriteRecord } from "../lib/transcriptScrollProbe";
|
||||
|
||||
const dom = new JSDOM("<div id='scroll'></div>", { pretendToBeVisual: true });
|
||||
globalThis.window = dom.window as unknown as Window & typeof globalThis;
|
||||
const element = dom.window.document.getElementById("scroll") as HTMLDivElement;
|
||||
Object.defineProperties(element, {
|
||||
scrollTop: { configurable: true, writable: true, value: 200 },
|
||||
scrollHeight: { configurable: true, value: 2_000 },
|
||||
clientHeight: { configurable: true, value: 500 },
|
||||
});
|
||||
|
||||
const calls: string[] = [];
|
||||
const handle = {
|
||||
scrollTo: () => calls.push("scrollTo"),
|
||||
scrollBy: () => calls.push("scrollBy"),
|
||||
scrollToIndex: () => calls.push("scrollToIndex"),
|
||||
} as unknown as VirtuosoHandle;
|
||||
const generationRef = { current: 4 };
|
||||
const modeRef = { current: "manual" as TranscriptScrollMode };
|
||||
const records: TranscriptScrollWriteRecord[] = [];
|
||||
dom.window.__REASONIX_TRANSCRIPT_SCROLL_WRITE__ = (record) => records.push(record);
|
||||
const writer = createTranscriptScrollWriter({
|
||||
virtuosoRef: { current: handle },
|
||||
scrollRef: { current: element },
|
||||
modeRef,
|
||||
generationRef,
|
||||
});
|
||||
|
||||
equal(writer.write({
|
||||
owner: "reader-stability",
|
||||
operation: "scrollBy",
|
||||
top: 120,
|
||||
source: "reader-rebound",
|
||||
expectedGeneration: 4,
|
||||
geometryRevision: 9,
|
||||
}), true, "the current generation may write");
|
||||
equal(calls.join(","), "scrollBy", "the gateway emits the requested operation once");
|
||||
equal(records[0]?.generation, 4, "the diagnostic binds the write to its generation");
|
||||
equal(records[0]?.geometryRevision, 9, "the diagnostic binds the write to its geometry revision");
|
||||
equal(records[0]?.sequence, 1, "the gateway assigns a monotonic sequence");
|
||||
|
||||
equal(writer.write({
|
||||
owner: "recovery",
|
||||
operation: "scrollTo",
|
||||
top: 600,
|
||||
source: "stale-recovery",
|
||||
expectedGeneration: 3,
|
||||
geometryRevision: 9,
|
||||
}), false, "a stale generation cannot write to the replacement surface");
|
||||
equal(calls.length, 1, "a rejected stale write never reaches Virtuoso");
|
||||
|
||||
modeRef.current = "native-thumb";
|
||||
equal(writer.write({
|
||||
owner: "tail-follow",
|
||||
operation: "scrollTo",
|
||||
top: 1_500,
|
||||
source: "tail-settle",
|
||||
expectedGeneration: 4,
|
||||
geometryRevision: 10,
|
||||
}), false, "native-thumb ownership suppresses every imperative writer");
|
||||
equal(calls.length, 1, "the native thumb remains browser-owned");
|
||||
|
||||
dom.window.close();
|
||||
console.log("\ntranscript scroll writer tests passed");
|
||||
@@ -98,6 +98,14 @@ async function dispatchContextMenu(target: Element, clientX = 120, clientY = 80)
|
||||
return event;
|
||||
}
|
||||
|
||||
function transcriptActionHost(): HTMLElement | null {
|
||||
return document.querySelector<HTMLElement>('.transcript-selection-action[data-surface="transcript"]');
|
||||
}
|
||||
|
||||
function transcriptActionState(): string | undefined {
|
||||
return transcriptActionHost()?.dataset.state;
|
||||
}
|
||||
|
||||
console.log("\ntranscript selection menu");
|
||||
|
||||
{
|
||||
@@ -135,6 +143,28 @@ console.log("\ntranscript selection menu");
|
||||
await flushTimers();
|
||||
});
|
||||
|
||||
const stableActionHost = transcriptActionHost();
|
||||
eq(document.querySelectorAll('.transcript-selection-action[data-surface="transcript"]').length, 1, "transcript owns exactly one stable action host");
|
||||
eq(transcriptActionState(), "closed", "the stable action host starts closed");
|
||||
eq(stableActionHost?.getAttribute("aria-hidden"), "true", "the closed action host is hidden from assistive technology");
|
||||
eq(stableActionHost?.querySelector("button")?.getAttribute("tabindex"), "-1", "the closed action button leaves the tab order");
|
||||
eq((stableActionHost?.querySelector("button") as HTMLButtonElement | null)?.disabled, true, "the closed action button cannot be activated");
|
||||
|
||||
for (let cycle = 1; cycle <= 4; cycle += 1) {
|
||||
selectNodeText(msgBody.firstChild as Node);
|
||||
await act(async () => {
|
||||
msgBody.dispatchEvent(new window.MouseEvent("pointerup", { bubbles: true, button: 0 }));
|
||||
await drainFrame();
|
||||
});
|
||||
eq(transcriptActionHost(), stableActionHost, `selection cycle ${cycle} reuses the stable action host`);
|
||||
eq(transcriptActionState(), "open", `selection cycle ${cycle} opens the stable action host`);
|
||||
await act(async () => {
|
||||
document.dispatchEvent(new window.MouseEvent("pointerdown", { bubbles: true, button: 0 }));
|
||||
await flushTimers();
|
||||
});
|
||||
eq(transcriptActionState(), "closed", `selection cycle ${cycle} closes without unmounting the host`);
|
||||
}
|
||||
|
||||
// Message selection opens the menu and suppresses the (already dead) default.
|
||||
selectNodeText(msgBody.firstChild as Node);
|
||||
const openEvent = await dispatchContextMenu(msgBody);
|
||||
@@ -173,7 +203,7 @@ console.log("\ntranscript selection menu");
|
||||
});
|
||||
eq(additions[0], "assistant reply text", "Add to Chat forwards the exact selected text");
|
||||
eq(document.getSelection()?.isCollapsed, true, "Add to Chat clears the browser selection");
|
||||
eq(document.querySelector(".transcript-selection-action"), null, "Add to Chat closes the floating action");
|
||||
eq(transcriptActionState(), "closed", "Add to Chat closes the floating action without unmounting it");
|
||||
|
||||
// The shortcut is scoped to a live transcript selection, so it cannot steal
|
||||
// Cmd/Ctrl+L during normal app navigation.
|
||||
@@ -195,23 +225,23 @@ console.log("\ntranscript selection menu");
|
||||
msgBody.dispatchEvent(new window.MouseEvent("pointerup", { bubbles: true, button: 0 }));
|
||||
await drainFrame();
|
||||
});
|
||||
ok(document.querySelector(".transcript-selection-action") != null, "pointer selection re-exposes the floating action");
|
||||
eq(transcriptActionState(), "open", "pointer selection re-exposes the floating action");
|
||||
await act(async () => {
|
||||
document.dispatchEvent(new window.KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
|
||||
await flushTimers();
|
||||
});
|
||||
eq(document.querySelector(".transcript-selection-action"), null, "Escape dismisses the floating action");
|
||||
eq(transcriptActionState(), "closed", "Escape dismisses the floating action without unmounting it");
|
||||
eq(document.getSelection()?.isCollapsed, false, "Escape keeps the browser selection");
|
||||
await act(async () => {
|
||||
document.dispatchEvent(new window.KeyboardEvent("keyup", { key: "Escape", bubbles: true }));
|
||||
await drainFrame();
|
||||
});
|
||||
eq(document.querySelector(".transcript-selection-action"), null, "the Escape keyup does not re-open the dismissed action");
|
||||
eq(transcriptActionState(), "closed", "the Escape keyup does not re-open the dismissed action");
|
||||
await act(async () => {
|
||||
msgBody.dispatchEvent(new window.MouseEvent("pointerup", { bubbles: true, button: 0 }));
|
||||
await drainFrame();
|
||||
});
|
||||
ok(document.querySelector(".transcript-selection-action") != null, "a fresh pointer gesture re-opens the dismissed action");
|
||||
eq(transcriptActionState(), "open", "a fresh pointer gesture re-opens the dismissed action");
|
||||
|
||||
// A new left-click must release the previous browser range before the
|
||||
// WebView's selectionchange event arrives. This keeps a stale selection from
|
||||
@@ -222,7 +252,7 @@ console.log("\ntranscript selection menu");
|
||||
await flushTimers();
|
||||
});
|
||||
eq(document.getSelection()?.isCollapsed, true, "a new left-click clears the previous transcript selection");
|
||||
eq(document.querySelector(".transcript-selection-action"), null, "clearing the selection closes the floating action");
|
||||
eq(transcriptActionState(), "closed", "clearing the selection closes the floating action");
|
||||
selectNodeText(msgBody.firstChild as Node);
|
||||
await act(async () => {
|
||||
msgBody.dispatchEvent(new window.MouseEvent("pointerup", { bubbles: true, button: 0 }));
|
||||
@@ -265,7 +295,7 @@ console.log("\ntranscript selection menu");
|
||||
msgBody.dispatchEvent(new window.MouseEvent("pointerup", { bubbles: true, button: 0 }));
|
||||
await drainFrame();
|
||||
});
|
||||
ok(document.querySelector(".transcript-selection-action") != null, "selection opens the floating action before a tab switch");
|
||||
eq(transcriptActionState(), "open", "selection opens the floating action before a tab switch");
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<LocaleProvider>
|
||||
@@ -274,7 +304,7 @@ console.log("\ntranscript selection menu");
|
||||
);
|
||||
await flushTimers();
|
||||
});
|
||||
eq(document.querySelector(".transcript-selection-action"), null, "a tab switch discards the captured selection action");
|
||||
eq(transcriptActionState(), "closed", "a tab switch discards the captured selection action without unmounting the host");
|
||||
eq(document.getSelection()?.isCollapsed, true, "a tab switch clears the native transcript selection");
|
||||
await act(async () => {
|
||||
root.render(
|
||||
@@ -288,7 +318,7 @@ console.log("\ntranscript selection menu");
|
||||
document.dispatchEvent(new window.KeyboardEvent("keyup", { key: "Meta", bubbles: true }));
|
||||
await drainFrame();
|
||||
});
|
||||
eq(document.querySelector(".transcript-selection-action"), null, "keyup over a hydration placeholder cannot re-summon the action");
|
||||
eq(transcriptActionState(), "closed", "keyup over a hydration placeholder cannot re-summon the action");
|
||||
selectNodeText(msgBody.firstChild as Node);
|
||||
const disabledContextEvent = await dispatchContextMenu(msgBody);
|
||||
eq(disabledContextEvent.defaultPrevented, false, "disabled selection handling leaves the native context menu alone");
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
// Run: tsx src/__tests__/transcript-selection-rendering.test.ts
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const testDir = dirname(fileURLToPath(import.meta.url));
|
||||
const styles = readFileSync(resolve(testDir, "../components/TranscriptSelectionMenu.css"), "utf8");
|
||||
const transcriptSelectionSource = readFileSync(
|
||||
resolve(testDir, "../components/TranscriptSelectionMenu.tsx"),
|
||||
"utf8",
|
||||
);
|
||||
const terminalSource = readFileSync(resolve(testDir, "../components/TerminalPanel.tsx"), "utf8");
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
function ok(value: unknown, label: string) {
|
||||
if (value) {
|
||||
process.stdout.write(` PASS ${label}\n`);
|
||||
passed += 1;
|
||||
} else {
|
||||
process.stdout.write(` FAIL ${label}\n`);
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
|
||||
function ruleBody(selector: string): string {
|
||||
const start = styles.indexOf(`${selector} {`);
|
||||
if (start < 0) return "";
|
||||
const bodyStart = styles.indexOf("{", start) + 1;
|
||||
const bodyEnd = styles.indexOf("\n}", bodyStart);
|
||||
return bodyEnd < 0 ? "" : styles.slice(bodyStart, bodyEnd);
|
||||
}
|
||||
|
||||
console.log("\ntranscript selection rendering");
|
||||
|
||||
ok(
|
||||
transcriptSelectionSource.includes('data-surface="transcript"')
|
||||
&& transcriptSelectionSource.includes("data-state={actionOverlay.phase}")
|
||||
&& transcriptSelectionSource.includes("aria-hidden={actionOverlay.phase !== \"open\"}")
|
||||
&& transcriptSelectionSource.includes("disabled={actionOverlay.phase !== \"open\"}")
|
||||
&& transcriptSelectionSource.includes('tabIndex={actionOverlay.phase === "open" ? 0 : -1}'),
|
||||
"transcript selection action keeps one state-driven, accessibility-safe portal host",
|
||||
);
|
||||
|
||||
ok(
|
||||
transcriptSelectionSource.includes("actionOverlayStateRef.current.action")
|
||||
&& !transcriptSelectionSource.includes("{action && typeof document !== \"undefined\" && createPortal("),
|
||||
"selection state changes neither remount the portal nor reinstall listeners through an action dependency",
|
||||
);
|
||||
|
||||
const closedRule = ruleBody('.transcript-selection-action[data-surface="transcript"]');
|
||||
const openRule = ruleBody(
|
||||
'.transcript-selection-action[data-surface="transcript"][data-state="open"]',
|
||||
);
|
||||
const windowsRule = ruleBody(
|
||||
':root[data-platform="windows"] .transcript-selection-action[data-surface="transcript"]',
|
||||
);
|
||||
|
||||
ok(
|
||||
/visibility:\s*hidden;/.test(closedRule)
|
||||
&& /opacity:\s*0;/.test(closedRule)
|
||||
&& /pointer-events:\s*none;/.test(closedRule)
|
||||
&& /animation:\s*none;/.test(closedRule),
|
||||
"closed and positioning transcript actions stay mounted without painting or hit testing",
|
||||
);
|
||||
|
||||
ok(
|
||||
/visibility:\s*visible;/.test(openRule)
|
||||
&& /opacity:\s*1;/.test(openRule)
|
||||
&& /pointer-events:\s*auto;/.test(openRule)
|
||||
&& /animation:\s*menu-pop-in/.test(openRule),
|
||||
"non-Windows transcript actions retain the existing open animation",
|
||||
);
|
||||
|
||||
ok(
|
||||
/background-color:\s*Canvas;/.test(windowsRule)
|
||||
&& /background-image:\s*linear-gradient\(var\(--bg-elev\),\s*var\(--bg-elev\)\);/.test(windowsRule)
|
||||
&& /-webkit-backdrop-filter:\s*none;/.test(windowsRule)
|
||||
&& /backdrop-filter:\s*none;/.test(windowsRule)
|
||||
&& /animation:\s*none;/.test(windowsRule)
|
||||
&& /transition:\s*none;/.test(windowsRule)
|
||||
&& /transform:\s*none;/.test(windowsRule)
|
||||
&& /isolation:\s*isolate;/.test(windowsRule),
|
||||
"Windows transcript actions use an opaque non-transforming WebView2 paint path",
|
||||
);
|
||||
|
||||
ok(
|
||||
terminalSource.includes('className="transcript-selection-action"')
|
||||
&& !terminalSource.includes('data-surface="transcript"'),
|
||||
"the Windows transcript paint override does not affect terminal selection actions",
|
||||
);
|
||||
|
||||
console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
|
||||
if (failed > 0) process.exit(1);
|
||||
Loaded 100 of 697 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user