From bd9e3ad82a848aa0713b5dd5f89d13f7e4de2ad8 Mon Sep 17 00:00:00 2001 From: Open-Squilla <275096992+Open-Squilla@users.noreply.github.com> Date: Thu, 17 Sep 2026 06:32:48 +0800 Subject: [PATCH 1/4] Bound silent chat RPCs and verify Mac send recovery Add actionable browser credential recovery and regressions for native Mac idle return, same-session follow-up turns and guest authorization boundaries. Preserve server execution budgets and legacy synchronous maintenance waits. Adapt CLI response cleanup and unsubscribe ordering from PR #1515. Co-authored-by: ptterjgf <57247786+ptterjgf@users.noreply.github.com> --- .github/workflows/ci.yml | 12 +- .../test-desktop-window-background-flow.mjs | 132 +++++- .../e2e/auth-connection-gateway.py | 79 ++++ .../e2e/auth-connection-recovery.spec.ts | 209 +++++++++ .../e2e/chat-send-lifecycle.real.spec.ts | 46 ++ .../e2e/chat-send-lifecycle.spec.ts | 223 +++++++++ .../e2e/idle-chat-recovery.spec.ts | 118 +++++ opensquilla-webui/src/App.vue | 6 +- .../adapters/gateway/gatewayAccessV4.test.ts | 20 + .../src/adapters/gateway/gatewayAccessV4.ts | 3 + .../components/settings/SettingsDialog.vue | 3 +- .../settings/SetupConnectionPanel.test.ts | 47 ++ .../settings/SetupConnectionPanel.vue | 16 +- .../setup/useSetupCatalog.privacy.test.ts | 1 + .../composables/useProjectWorkspaces.test.ts | 1 + opensquilla-webui/src/locales/de.json | 2 + opensquilla-webui/src/locales/en.json | 2 + opensquilla-webui/src/locales/es.json | 2 + opensquilla-webui/src/locales/fr.json | 2 + opensquilla-webui/src/locales/ja.json | 2 + opensquilla-webui/src/locales/zh-Hans.json | 2 + .../src/modules/gatewayAccess.ts | 1 + src/opensquilla/cli/gateway_client.py | 166 ++++++- tests/test_ci/test_workflows.py | 4 + .../test_cli/test_gateway_client_keepalive.py | 443 ++++++++++++++++++ .../test_websocket_connection_stability.py | 130 ++++- 26 files changed, 1640 insertions(+), 32 deletions(-) create mode 100644 opensquilla-webui/e2e/auth-connection-gateway.py create mode 100644 opensquilla-webui/e2e/auth-connection-recovery.spec.ts create mode 100644 opensquilla-webui/e2e/chat-send-lifecycle.real.spec.ts create mode 100644 opensquilla-webui/e2e/chat-send-lifecycle.spec.ts create mode 100644 opensquilla-webui/e2e/idle-chat-recovery.spec.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 125dab28a4..2264301aae 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -787,8 +787,12 @@ jobs: run: >- npm run test:e2e -- assistant-activity.spec.ts + auth-connection-recovery.spec.ts + chat-send-lifecycle.spec.ts + chat-send-lifecycle.real.spec.ts composer-paste.spec.ts history-hydration.spec.ts + idle-chat-recovery.spec.ts provider-error-experience.spec.ts user-message-newlines.spec.ts session-created-card.spec.ts @@ -1731,10 +1735,14 @@ jobs: else unset OPENSQUILLA_REQUIRE_ELECTRON_FOREGROUND fi + local case_args=() + if [[ "${name}" == "window-background-flow" && "${RUNNER_OS}" == "macOS" ]]; then + case_args=(--connection-faults --flow-control --idle-send --background-ms=65000) + fi if [[ "${RUNNER_OS}" == "Linux" ]]; then - "${telemetry[@]}" xvfb-run -a node "${script}" 2>&1 | tee "${log_path}" + "${telemetry[@]}" xvfb-run -a node "${script}" "${case_args[@]}" 2>&1 | tee "${log_path}" else - "${telemetry[@]}" node "${script}" 2>&1 | tee "${log_path}" + "${telemetry[@]}" node "${script}" "${case_args[@]}" 2>&1 | tee "${log_path}" fi } diff --git a/desktop/electron/scripts/test-desktop-window-background-flow.mjs b/desktop/electron/scripts/test-desktop-window-background-flow.mjs index 8505d411c3..e31c3f7af6 100644 --- a/desktop/electron/scripts/test-desktop-window-background-flow.mjs +++ b/desktop/electron/scripts/test-desktop-window-background-flow.mjs @@ -1,5 +1,6 @@ import { strict as assert } from 'node:assert' import { mkdir, mkdtemp, open, readFile, realpath, rm, writeFile } from 'node:fs/promises' +import { createServer } from 'node:http' import { tmpdir } from 'node:os' import { dirname, join, resolve } from 'node:path' import { setTimeout as delay } from 'node:timers/promises' @@ -8,7 +9,9 @@ import { _electron as electron } from 'playwright' import { canAcceptWindowsElectronShutdownFallback, closeElectronWithDeadline, + closeHttpServerWithDeadline, desktopShutdownEvidenceSince, + trackHttpServerConnections, } from './e2e-shutdown-helpers.mjs' const scriptDir = dirname(fileURLToPath(import.meta.url)) @@ -17,6 +20,12 @@ const repoRoot = resolve(packageRoot, '../..') const ELECTRON_SHUTDOWN_TIMEOUT_MS = 15_000 const flowControl = process.argv.includes('--flow-control') const connectionFaults = process.argv.includes('--connection-faults') +const idleSend = process.argv.includes('--idle-send') +const backgroundOption = process.argv.find(value => value.startsWith('--background-ms=')) +const backgroundMs = backgroundOption ? Number(backgroundOption.split('=')[1]) : 65_000 +if (!Number.isInteger(backgroundMs) || backgroundMs < 5_000 || backgroundMs > 600_000) { + throw new Error('background-ms must be an integer between 5000 and 600000') +} const outageOption = process.argv.find(value => value.startsWith('--outage-ms=')) const outageMs = outageOption ? Number(outageOption.split('=')[1]) : 5_000 if (!Number.isInteger(outageMs) || outageMs < 5_000 || outageMs > 600_000) { @@ -71,9 +80,55 @@ let negotiatedFlow = false let warmRecoveryMs = null let continuityPage let continuityDiagnostics = null +let syntheticProvider +let acceptedIdleSends = 0 +let sentIdleMessages = 0 +let idleSendReceipt = null +let hiddenIdleMs = null +let idleRecoveryMs = null +const idleSendIds = new Set() const routedClients = new Set() const routedServers = new WeakMap() +async function startSyntheticProvider() { + let chatRequests = 0 + const server = createServer((request, response) => { + request.resume() + request.once('end', () => { + if (request.method !== 'POST' || request.url !== '/api/chat') { + response.writeHead(404) + response.end() + return + } + chatRequests++ + response.writeHead(200, { 'content-type': 'application/x-ndjson' }) + response.end(JSON.stringify({ + model: 'opensquilla-window-close-test-model', + created_at: '2026-01-01T00:00:00Z', + message: { role: 'assistant', content: 'Synthetic background recovery complete.' }, + done: true, + done_reason: 'stop', + prompt_eval_count: 8, + eval_count: 3, + }) + '\n') + }) + }) + const sockets = trackHttpServerConnections(server) + await new Promise((resolveListen, rejectListen) => { + server.once('error', rejectListen) + server.listen(0, '127.0.0.1', resolveListen) + }) + const address = server.address() + assert.ok(address && typeof address === 'object') + return { + baseUrl: `http://127.0.0.1:${address.port}`, + chatRequests: () => chatRequests, + close: () => closeHttpServerWithDeadline(server, sockets, { + label: 'background-flow synthetic provider shutdown', + }), + } +} + // Observe only lifecycle/element categories, never text, values, URLs or keys. // The ring is bounded and records transitions, not every DOM mutation/poll. async function installContinuityObservation(page, app) { @@ -170,6 +225,7 @@ try { await mkdir(isolatedHome, { recursive: true }) await mkdir(isolatedRoaming, { recursive: true }) await mkdir(isolatedLocal, { recursive: true }) + if (idleSend) syntheticProvider = await startSyntheticProvider() // Use a synthetic keyless profile so the lifecycle test reaches the Control // UI without reading developer credentials or requiring an external model. @@ -177,7 +233,7 @@ try { await writeFile(join(userDataDir, 'desktop-credential.json'), JSON.stringify({ provider: 'ollama', model: 'opensquilla-window-close-test-model', - baseUrl: 'http://127.0.0.1:11434', + baseUrl: syntheticProvider?.baseUrl || 'http://127.0.0.1:11434', apiKeyEnv: '', encryptedApiKey: '', modelRoutingMode: 'direct', @@ -188,7 +244,7 @@ try { searchApiKeyEnv: '', encryptedSearchApiKey: '', encryption: 'plain', - disableNetworkObservability: false, + disableNetworkObservability: idleSend, createdAt: now, updatedAt: now, }, null, 2), { mode: 0o600 }) @@ -213,6 +269,13 @@ try { OPENSQUILLA_AUTH_MODE: 'token', OPENSQUILLA_AUTH_TOKEN: 'synthetic-window-flow-operator-token', OPENSQUILLA_GATEWAY_WS_TRANSPORT_FLOW_ENABLED: flowControl ? 'true' : 'false', + ...(idleSend ? { + // Match the synthetic first-send gate's model budget so bundled tool + // schemas do not exhaust Ollama's small fallback context before HTTP. + OPENSQUILLA_LLM_CONTEXT_WINDOW_TOKENS: '131072', + NO_PROXY: '127.0.0.1,localhost,::1', + no_proxy: '127.0.0.1,localhost,::1', + } : {}), }, }) @@ -241,10 +304,25 @@ try { routedClients.delete(client) void client.close({ code, reason }) }) + client.onMessage(message => { + try { + const frame = JSON.parse(String(message)) + if (frame.type === 'req' && frame.method === 'chat.send') { + sentIdleMessages++ + idleSendIds.add(frame.id) + } + } catch { /* Non-JSON frames remain transparent. */ } + server.send(message) + }) server.onMessage(message => { try { const frame = JSON.parse(String(message)) if (frame?.policy?.transport_flow?.delivery_epoch) negotiatedFlow = true + if (frame?.type === 'res' && idleSendIds.has(frame.id)) { + idleSendIds.delete(frame.id) + idleSendReceipt = { ok: frame.ok, accepted: frame.payload?.accepted } + if (frame.ok === true && frame.payload?.accepted === true) acceptedIdleSends++ + } } catch { /* Non-JSON frames remain transparent. */ } client.send(message) }) @@ -269,7 +347,7 @@ try { ) // Fixture setup only: install interception before the measured connection. // No reload, refresh or navigation is permitted during fault recovery. - if (connectionFaults) { + if (connectionFaults || idleSend) { await installFaultRoute() await page.reload({ waitUntil: 'domcontentloaded' }) } @@ -290,7 +368,7 @@ try { // the developer's computer. The signal must preserve the live renderer. const composer = page.locator('.chat-textarea').first() await composer.waitFor({ state: 'visible', timeout: 60_000 }) - const draft = 'isolated stability draft - never send' + const draft = idleSend ? 'Synthetic background recovery request.' : 'isolated stability draft - never send' await composer.fill(draft) await composer.focus() const resumeUrl = page.url() @@ -358,6 +436,7 @@ try { const backgroundSupported = runtimeIsolation.platform === 'darwin' || runtimeIsolation.platform === 'win32' + assert.ok(!idleSend || backgroundSupported, 'idle-send requires native background-window support') assert.equal(preferences.canRunInBackground, backgroundSupported) assert.equal( preferences.mainWindowCloseBehavior, @@ -413,6 +492,24 @@ try { ) assert.equal(page.isClosed(), false) + if (idleSend) { + // Exercise real native hide/activate and an interrupted transport. This + // deliberately does not claim to suspend the physical host computer. + outage = true + for (const client of [...routedClients]) { + routedClients.delete(client) + await client.close({ code: 1013, reason: 'Synthetic hidden-window interruption' }) + await routedServers.get(client)?.close({ code: 1013, reason: 'Synthetic hidden-window interruption' }) + } + const hiddenStarted = Date.now() + console.log(JSON.stringify({ event: 'desktop_idle_send_phase', phase: 'hidden', backgroundMs })) + await delay(backgroundMs) + hiddenIdleMs = Date.now() - hiddenStarted + assert.equal(await composer.inputValue(), draft, 'hidden idle must retain the draft') + outage = false + } + + const revealStarted = Date.now() await desktopApp.evaluate(({ app }) => { app.emit('activate') }) @@ -436,6 +533,23 @@ try { 'revealing a hidden desktop window must preserve renderer state', ) + if (idleSend) { + const sendButton = page.locator('.chat-send-btn.btn--primary') + await waitFor(async () => !await sendButton.isDisabled(), 'send after native background return', 30_000) + idleRecoveryMs = Date.now() - revealStarted + assert.equal(await composer.inputValue(), draft, 'native return must retain the unsent draft') + assert.equal(await page.evaluate(() => window.__stabilityComposer === document.querySelector('.chat-textarea')), true) + await sendButton.click() + await waitFor(() => idleSendReceipt, 'real Gateway background-return admission') + assert.deepEqual(idleSendReceipt, { ok: true, accepted: true }, 'real Gateway must admit the send') + await waitFor(async () => ( + await page.locator('.msg-ai-text').last().textContent({ timeout: 1_000 }) + )?.includes('Synthetic background recovery complete.'), 'synthetic provider response') + assert.equal(sentIdleMessages, 1, 'background return must submit the draft exactly once') + assert.equal(syntheticProvider.chatRequests(), 1, 'accepted send must reach the local provider once') + assert.equal(await composer.inputValue(), '', 'accepted send must clear the draft') + } + await desktopApp.evaluate(({ app, BrowserWindow }) => { const window = BrowserWindow.getAllWindows().find((candidate) => ( candidate.webContents.getURL().startsWith('opensquilla-app://desktop/') @@ -586,6 +700,12 @@ try { reconnectAttempts, warmRecoveryMs, continuityDiagnostics, + idleSend, + backgroundMs: idleSend ? backgroundMs : null, + hiddenIdleMs, + idleRecoveryMs, + acceptedIdleSends, + syntheticProviderRequests: syntheticProvider?.chatRequests() ?? null, }, null, 2)) } flowSucceeded = true @@ -610,6 +730,9 @@ try { routedConnectionCount: routedClients.size, acceptedSockets, negotiatedFlow, + sentIdleMessages, + idleSendReceipt, + syntheticProviderRequests: syntheticProvider?.chatRequests() ?? null, continuityDiagnostics, windows, desktopLog, @@ -755,5 +878,6 @@ try { } else { await rm(isolationRoot, { recursive: true, force: true }).catch(() => {}) } + await syntheticProvider?.close() if (flowSucceeded && shutdownError) throw shutdownError } diff --git a/opensquilla-webui/e2e/auth-connection-gateway.py b/opensquilla-webui/e2e/auth-connection-gateway.py new file mode 100644 index 0000000000..be4a7df2d4 --- /dev/null +++ b/opensquilla-webui/e2e/auth-connection-gateway.py @@ -0,0 +1,79 @@ +"""Isolated real WebSocket auth boundary for browser credential recovery tests.""" + +from __future__ import annotations + +import asyncio +import json +import sys +from pathlib import Path + +import uvicorn +from starlette.applications import Starlette +from starlette.responses import JSONResponse +from starlette.routing import Route, WebSocketRoute + +from opensquilla.gateway.config import AuthConfig, GatewayConfig +from opensquilla.gateway.rpc import get_dispatcher +from opensquilla.gateway.websocket import ( + SubscriptionManager, + get_registry, + handle_ws_connection, +) +from opensquilla.session.manager import SessionManager +from opensquilla.session.storage import SessionStorage + +TOKEN = "synthetic-browser-gateway-token" + + +async def main() -> None: + origin, state_path, mode = sys.argv[1:] + state = Path(state_path) + state.mkdir(parents=True, exist_ok=True) + config = GatewayConfig( + host="127.0.0.1", + state_dir=str(state), + auth=AuthConfig(mode=mode, token=TOKEN), + ) + config.cors.allowed_origins = [origin] + storage = await SessionStorage.open(str(state / "sessions.db")) + manager = SessionManager(storage) + subscriptions = SubscriptionManager() + + async def websocket(ws) -> None: + await handle_ws_connection( + ws, + config, + dispatcher=get_dispatcher(), + session_manager=manager, + subscription_manager=subscriptions, + ) + + async def enable_token(_request): + config.auth = AuthConfig(mode="token", token=TOKEN) + # A configuration restart applies the new auth policy on fresh handshakes. + for connection in get_registry().all(): + await connection.close(code=1012, reason="synthetic_auth_configuration_restart") + return JSONResponse({"mode": "token"}) + + app = Starlette(routes=[ + WebSocketRoute("/ws", websocket), + Route("/enable-token", enable_token, methods=["POST"]), + ]) + server = uvicorn.Server(uvicorn.Config(app, host="127.0.0.1", port=0, log_level="warning")) + task = asyncio.create_task(server.serve()) + try: + while not server.started: + if task.done(): + await task + raise RuntimeError("Auth fixture stopped before becoming ready") + await asyncio.sleep(0.01) + port = server.servers[0].sockets[0].getsockname()[1] + print(json.dumps({"authFixturePort": port}), flush=True) + await task + finally: + server.should_exit = True + await storage.close() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/opensquilla-webui/e2e/auth-connection-recovery.spec.ts b/opensquilla-webui/e2e/auth-connection-recovery.spec.ts new file mode 100644 index 0000000000..d8f5cffb37 --- /dev/null +++ b/opensquilla-webui/e2e/auth-connection-recovery.spec.ts @@ -0,0 +1,209 @@ +import { expect, test, type Page, type TestInfo } from '@playwright/test' +import { spawn } from 'node:child_process' +import { once } from 'node:events' +import { mkdir, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' + +const TOKEN = 'synthetic-browser-gateway-token' + +async function realAuthGateway(testInfo: TestInfo, origin: string, mode: 'none' | 'token') { + const root = fileURLToPath(new URL('../..', import.meta.url)) + const output = testInfo.outputPath('auth-gateway') + await mkdir(output, { recursive: true }) + for (const directory of ['profile', 'appdata', 'local-appdata', 'logs', 'tmp']) { + await mkdir(join(output, directory), { recursive: true }) + } + const python = process.env.OPENSQUILLA_WEBUI_E2E_PYTHON || join( + root, '.venv', process.platform === 'win32' ? 'Scripts/python.exe' : 'bin/python', + ) + const child = spawn(python, [ + '-u', fileURLToPath(new URL('./auth-connection-gateway.py', import.meta.url)), + origin, output, mode, + ], { + cwd: output, + env: { + ...Object.fromEntries(['PATH', 'LANG', 'SYSTEMROOT', 'WINDIR'].flatMap( + key => process.env[key] === undefined ? [] : [[key, process.env[key]]], + )), + PYTHONPATH: join(root, 'src'), + PYTHONNOUSERSITE: '1', + OPENSQUILLA_STATE_DIR: output, + OPENSQUILLA_HOME: output, + OPENSQUILLA_LOG_DIR: join(output, 'logs'), + OPENSQUILLA_OPENROUTER_LIVE_PRICING: '0', + OPENSQUILLA_PRIVACY_DISABLE_NETWORK_OBSERVABILITY: 'true', + HOME: join(output, 'profile'), + USERPROFILE: join(output, 'profile'), + APPDATA: join(output, 'appdata'), + LOCALAPPDATA: join(output, 'local-appdata'), + XDG_CONFIG_HOME: join(output, 'profile', 'config'), + XDG_CACHE_HOME: join(output, 'profile', 'cache'), + XDG_DATA_HOME: join(output, 'profile', 'data'), + TMPDIR: join(output, 'tmp'), + TEMP: join(output, 'tmp'), + TMP: join(output, 'tmp'), + }, + stdio: ['ignore', 'pipe', 'pipe'], + }) + let log = '' + child.stdout.on('data', chunk => { log += String(chunk) }) + child.stderr.on('data', chunk => { log += String(chunk) }) + let spawnError: Error | undefined + child.on('error', error => { spawnError = error }) + const stop = async () => { + if (child.exitCode === null && child.signalCode === null && child.pid) { + const exited = once(child, 'exit') + async function waitForExit() { + let timer: ReturnType | undefined + try { + return await Promise.race([ + exited.then(() => true), + new Promise(resolve => { timer = setTimeout(() => resolve(false), 5_000) }), + ]) + } finally { clearTimeout(timer) } + } + child.kill('SIGTERM') + if (!await waitForExit()) { + child.kill('SIGKILL') + if (!await waitForExit()) throw new Error('Auth Gateway did not exit after SIGKILL') + } + } + await writeFile(join(output, 'gateway.log'), log) + } + try { + await expect.poll(() => { + if (spawnError) throw spawnError + if (child.exitCode !== null) throw new Error(`Auth Gateway stopped: ${log}`) + return /"authFixturePort":\s*(\d+)/.exec(log)?.[1] + }, { timeout: 15_000 }).toBeTruthy() + const port = /"authFixturePort":\s*(\d+)/.exec(log)![1] + return { wsUrl: `ws://127.0.0.1:${port}/ws`, httpUrl: `http://127.0.0.1:${port}`, stop } + } catch (error) { + await stop() + throw error + } +} + +async function prepare(page: Page, wsUrl: string, token = '') { + await page.addInitScript(({ endpoint, credential }) => { + localStorage.setItem('opensquilla-locale', 'en') + localStorage.setItem('opensquilla.wsUrl', endpoint) + if (credential) sessionStorage.setItem('opensquilla.wsToken', credential) + }, { endpoint: wsUrl, credential: token }) + await page.route('**/api/**', route => route.fulfill({ json: {} })) + await page.route('**/api/approvals', route => route.fulfill({ + json: { pending: [], mode: 'prompt', allowPatterns: [], denyPatterns: [] }, + })) +} + +function observe(page: Page) { + const hellos: Array<{ principal: { authenticated: boolean; authState: string }; guestSessionKey?: string }> = [] + const denied: Array<{ method: string; sessionKey: unknown; message: string }> = [] + let connections = 0 + page.on('websocket', socket => { + connections += 1 + const requests = new Map() + socket.on('framesent', ({ payload }) => { + const frame = JSON.parse(String(payload)) + if (frame.type === 'req' && frame.method !== 'connect') { + requests.set(frame.id, { + method: frame.method, + sessionKey: frame.params?.key ?? frame.params?.sessionKey ?? null, + }) + } + }) + socket.on('framereceived', ({ payload }) => { + const frame = JSON.parse(String(payload)) + if (frame.type === 'hello-ok') hellos.push(frame.auth) + if (frame.type === 'res' && frame.error?.code === 'UNAUTHORIZED') { + const request = requests.get(frame.id) + if (request) denied.push({ ...request, message: frame.error.message }) + } + }) + }) + return { hellos, denied, get connections() { return connections } } +} + +test('recovers a rejected browser token through the existing connection panel', async ({ page, baseURL }, testInfo) => { + const gateway = await realAuthGateway(testInfo, new URL(baseURL!).origin, 'token') + try { + await page.clock.install() + await prepare(page, gateway.wsUrl, 'synthetic-rejected-token') + const wire = observe(page) + await page.goto('/control/sessions') + const connection = page.getByRole('button', { name: /^(Manage gateway connection|Connection:)/ }) + await expect(connection).toHaveText('Token required') + await connection.click() + await expect(page).toHaveURL(/\/settings\/gateway#connection$/) + const token = page.locator('#conn-ws-token') + await expect(token).toBeFocused() + await expect(page.locator('.conn-status__reason')).toContainText('Automatic retries are paused') + await token.fill('synthetic-partial-token') + await page.clock.fastForward(120_000) + await page.evaluate(() => { + window.dispatchEvent(new Event('online')) + document.dispatchEvent(new Event('visibilitychange')) + }) + await page.clock.runFor(1_000) + expect(wire.connections).toBe(1) + await expect(token).toHaveValue('synthetic-partial-token') + await expect(token).toBeFocused() + await page.screenshot({ path: testInfo.outputPath('token-recovery.png'), animations: 'disabled' }) + + await token.fill(TOKEN) + await token.press('Enter') + await expect(page.locator('.conn-status__pill')).toHaveText('Connected') + await expect.poll(() => wire.hellos.at(-1)?.principal.authenticated).toBe(true) + expect(wire.connections).toBe(2) + await expect(page.locator('.conn-status__reason')).not.toContainText('Automatic retries are paused') + } finally { await gateway.stop() } +}) + +test('keeps an empty-token tab connected as a legal guest after enabling token auth', async ({ page, baseURL, request }, testInfo) => { + const gateway = await realAuthGateway(testInfo, new URL(baseURL!).origin, 'none') + try { + await prepare(page, gateway.wsUrl) + const wire = observe(page) + await page.goto('/control/sessions') + const connection = page.getByRole('button', { name: /^(Manage gateway connection|Connection:)/ }) + await expect(connection).toHaveText('Connected') + await expect.poll(() => wire.hellos.length).toBe(1) + expect(wire.hellos[0].principal.authState).toBe('authenticated') + expect(await page.evaluate(() => sessionStorage.getItem('opensquilla.wsToken'))).toBeNull() + + expect((await request.post(`${gateway.httpUrl}/enable-token`)).ok()).toBe(true) + await expect.poll(() => wire.hellos.length).toBe(2) + expect(wire.hellos[1].principal).toMatchObject({ authenticated: false, authState: 'guest' }) + await expect(connection).toHaveText('Connected') + await connection.click() + const token = page.locator('#conn-ws-token') + await expect(page.locator('.conn-status__pill')).toHaveText('Connected') + await expect(page.locator('.conn-status__reason')).not.toContainText('Authentication failed') + await expect(page.locator('.conn-optional')).toHaveText('optional') + const permissionLog = testInfo.outputPath('guest-permission-responses.json') + await writeFile(permissionLog, JSON.stringify(wire.denied, null, 2)) + await testInfo.attach('guest-permission-responses', { + path: permissionLog, contentType: 'application/json', + }) + await token.fill(TOKEN) + await token.press('Enter') + await expect.poll(() => wire.hellos.at(-1)?.principal.authenticated).toBe(true) + expect(wire.connections).toBe(3) + } finally { await gateway.stop() } +}) + +test('does not describe an origin-policy close as a missing token', async ({ page }) => { + await page.clock.install() + await prepare(page, 'ws://synthetic-policy.invalid/ws') + await page.routeWebSocket('ws://synthetic-policy.invalid/ws', socket => { + socket.close({ code: 1008, reason: 'origin_policy_rejected' }) + }) + await page.goto('/control/sessions') + await page.getByRole('button', { name: /^(Manage gateway connection|Connection:)/ }).click() + const token = page.locator('#conn-ws-token') + await expect(token).toBeVisible() + await expect(page.locator('.conn-status__pill')).not.toHaveText('Token required') + await expect(page.locator('.conn-status__reason')).not.toContainText('Authentication failed') + await expect(token).not.toBeFocused() +}) diff --git a/opensquilla-webui/e2e/chat-send-lifecycle.real.spec.ts b/opensquilla-webui/e2e/chat-send-lifecycle.real.spec.ts new file mode 100644 index 0000000000..e9353db206 --- /dev/null +++ b/opensquilla-webui/e2e/chat-send-lifecycle.real.spec.ts @@ -0,0 +1,46 @@ +import { expect, test } from './real-gateway.fixture' + +test('real Gateway accepts the next chat turn without changing sessions', async ({ + page, + isolatedRealGateway, +}) => { + test.setTimeout(90_000) + const rendererErrors: string[] = [] + page.on('pageerror', error => rendererErrors.push(error.message)) + await page.addInitScript(() => localStorage.setItem('opensquilla-locale', 'en')) + await page.goto(`${isolatedRealGateway.controlUrl}chat/new`) + await expect(page.locator('.conn-pill.connected')).toBeVisible({ timeout: 20_000 }) + + const input = page.locator('.chat-textarea') + const send = page.locator('.chat-send-btn[aria-label="Send"]') + const stop = page.getByRole('button', { name: 'Stop current response' }) + const callNumbers = async () => (await isolatedRealGateway.readProviderCalls()) + .map(call => call.callNumber) + + await input.fill('Complete the first synthetic chat turn.') + await send.click() + await expect.poll(callNumbers, { timeout: 30_000 }).toEqual([1]) + await expect(stop).toBeVisible() + await input.fill('Continue with a second synthetic chat turn.') + await isolatedRealGateway.releaseFirstTask() + await expect(page.locator('.msg-ai')).toContainText( + 'Task one completed after the lifecycle checks.', + { timeout: 30_000 }, + ) + await expect(stop).toHaveCount(0) + await expect(send).toBeEnabled() + await expect(input).toHaveValue('Continue with a second synthetic chat turn.') + const sessionUrl = page.url() + + await send.click() + await expect.poll(callNumbers, { timeout: 30_000 }).toEqual([1, 2]) + await expect(stop).toBeVisible() + expect(page.url()).toBe(sessionUrl) + expect((await isolatedRealGateway.readProviderCalls())[1]) + .toMatchObject({ firstReplyInAssistantHistory: true }) + await isolatedRealGateway.releaseSecondTask() + await expect(page.locator('.msg-ai').filter({ hasText: 'Task two completed after Goal removal.' })) + .toBeVisible({ timeout: 30_000 }) + await expect(send).toBeEnabled() + expect(rendererErrors).toEqual([]) +}) diff --git a/opensquilla-webui/e2e/chat-send-lifecycle.spec.ts b/opensquilla-webui/e2e/chat-send-lifecycle.spec.ts new file mode 100644 index 0000000000..4d97cf456d --- /dev/null +++ b/opensquilla-webui/e2e/chat-send-lifecycle.spec.ts @@ -0,0 +1,223 @@ +import { expect, type Page, type WebSocketRoute } from '@playwright/test' +import { test } from './support/native-desktop-fixture' +import { helloOkResponse } from './support/gateway-fixture' +import { + chatHistoryPayload, + sessionMessagesHydratePayload, + sessionMessagesSnapshotPayload, + sessionMessagesSubscribePayload, +} from './support/session-read-fixtures' + +const SESSION = 'agent:main:webchat:e2e-send-lifecycle' +const GENERATION = 'send-lifecycle-generation' +const REJECTION = 'Synthetic admission failed. Please retry.' +type TerminalStatus = 'succeeded' | 'failed' | 'cancelled' +type Request = { id: string; method: string; params?: Record; type: string } + +async function installGateway(page: Page, holdFirstSend = false) { + const sends: Array> = [] + const aborts: Array> = [] + const history: Array> = [] + let socket: WebSocketRoute + let sequence = 0 + let activeTask: { task_id: string; status: string } | null = null + let lastTask: { task_id: string; status: string } | null = null + let heldSend: Request | null = null + let connectionCount = 0 + + const response = (id: string, payload: unknown) => socket.send(JSON.stringify({ + type: 'res', id, ok: true, payload, + })) + const emit = (event: string, payload: Record) => socket.send(JSON.stringify({ + type: 'event', event, payload: { + key: SESSION, session_key: SESSION, stream_generation: GENERATION, + stream_seq: ++sequence, ...payload, + }, + })) + const metadata = () => ({ + stream_generation: GENERATION, + current_stream_seq: sequence, + run_status: activeTask ? 'running' : 'idle', + active_task: activeTask, + last_task: lastTask, + tasks: activeTask ? [activeTask] : [], + }) + const finish = (status: TerminalStatus) => { + if (!activeTask) throw new Error('No accepted task to finish') + const taskId = activeTask.task_id + activeTask = null + lastTask = { task_id: taskId, status } + if (status === 'succeeded') { + const text = 'Synthetic response completed.' + history.push({ role: 'assistant', text, id: `answer-${taskId}`, turn_id: taskId }) + emit('session.event.text_delta', { task_id: taskId, text }) + emit('session.event.done', { task_id: taskId, reason: 'completed', text_snapshot: text }) + } + emit(`task.${status}`, { + task_id: taskId, status, + terminal_reason: status === 'cancelled' ? 'user_abort' : status, + terminal_message: status === 'failed' ? 'Synthetic provider failure.' : undefined, + }) + emit('sessions.changed', { reason: 'task_terminal', ...metadata() }) + } + + await page.addInitScript(() => localStorage.setItem('opensquilla-locale', 'en')) + await page.route('**/api/**', route => route.fulfill({ json: {} })) + await page.route('**/api/approvals', route => route.fulfill({ + json: { pending: [], mode: 'prompt', allowPatterns: [], denyPatterns: [] }, + })) + await page.routeWebSocket(/\/ws$/, ws => { + socket = ws + connectionCount += 1 + emit('connect.challenge', {}) + ws.onMessage(raw => { + const frame = JSON.parse(String(raw)) as Request + if (frame.type === 'ping') { + ws.send(JSON.stringify({ type: 'pong' })) + return + } + if (frame.type !== 'req') return + if (frame.method === 'connect') { + ws.send(helloOkResponse({ + features: { methods: [ + 'sessions.messages.subscribe', 'sessions.messages.hydrate', + 'sessions.messages.snapshot', 'sessions.messages.unsubscribe', + ] }, + auth: { + principal: { isOwner: true }, + runModePolicy: { allowedRunModes: ['safe', 'full'], defaultRunMode: 'full' }, + }, + })) + return + } + if (frame.method === 'chat.send') { + sends.push({ ...frame.params }) + if (holdFirstSend && sends.length === 1) { + heldSend = frame + return + } + activeTask = { task_id: `task-send-${sends.length}`, status: 'running' } + history.push({ + role: 'user', text: frame.params?.message, id: `user-${sends.length}`, + client_message_id: frame.params?.clientMessageId, + turn_id: activeTask.task_id, + }) + response(frame.id, { accepted: true, session: SESSION, task_id: activeTask.task_id }) + emit('task.running', { task_id: activeTask.task_id }) + return + } + if (frame.method === 'chat.abort') { + aborts.push({ ...frame.params }) + response(frame.id, { aborted: true, key: SESSION }) + finish('cancelled') + return + } + const payloads: Record = { + 'agents.list': { agents: [] }, + 'commands.list_for_surface': { commands: [] }, + 'config.get': { + squilla_router: { enabled: false, tiers: {} }, permissions: {}, skills: {}, + }, + 'models.routing.get': { mode: 'direct' }, + 'sandbox.run_mode.preference.get': { runMode: 'full', source: 'config' }, + 'sessions.list': { + sessions: [{ + key: SESSION, title: 'Synthetic send lifecycle', sessionKind: 'chat', + surface: 'webchat', conversationKind: 'direct', effectiveAgentId: 'main', + updatedAt: 100, messageCount: history.length, status: 'ok', + runStatus: activeTask ? 'running' : 'idle', + }], count: 1, ts: 1_800_000_000, has_more: false, + }, + 'chat.history': chatHistoryPayload(history), + 'sessions.messages.subscribe': sessionMessagesSubscribePayload(SESSION, metadata()), + 'sessions.messages.hydrate': sessionMessagesHydratePayload(SESSION, metadata()), + 'sessions.messages.snapshot': sessionMessagesSnapshotPayload(SESSION, { + ...metadata(), task_id: activeTask?.task_id ?? null, + }), + 'sessions.messages.unsubscribe': { subscribed: false }, + 'sessions.subscribe': { subscribed: true }, + 'usage.status': { sessions: [] }, + } + response(frame.id, payloads[frame.method] ?? {}) + }) + }) + + return { + sends, aborts, finish, + connectionCount: () => connectionCount, + rejectHeldSend: () => { + if (!heldSend) throw new Error('No pending send to reject') + socket.send(JSON.stringify({ + type: 'res', id: heldSend.id, ok: false, + error: { code: 'SYNTHETIC_ADMISSION_FAILURE', message: REJECTION, accepted: false, retryable: true }, + })) + heldSend = null + }, + } +} + +async function openChat(page: Page) { + const root = process.env.OPENSQUILLA_E2E_NATIVE_DESKTOP === '1' + ? 'opensquilla-app://desktop/' : '/control/' + await page.goto(`${root}chat?session=${encodeURIComponent(SESSION)}`) + await expect(page.locator('.conn-pill.connected')).toBeVisible() + await expect(page.locator('.chat-send-btn[aria-label="Send"]')).toBeEnabled() +} + +for (const status of ['succeeded', 'failed', 'cancelled'] as const) { + test(`sends again in the same session after a ${status} task`, async ({ page }) => { + const gateway = await installGateway(page) + await openChat(page) + const originalUrl = page.url() + const input = page.locator('.chat-textarea') + const send = page.locator('.chat-send-btn[aria-label="Send"]') + const stop = page.getByRole('button', { name: 'Stop current response' }) + + await input.fill('Synthetic first request.') + await send.click() + await expect.poll(() => gateway.sends.length).toBe(1) + await expect(stop).toBeVisible() + await input.fill('Synthetic next request without changing projects.') + if (status === 'cancelled') { + await stop.click() + await expect.poll(() => gateway.aborts.length).toBe(1) + expect(gateway.aborts[0]).toMatchObject({ sessionKey: SESSION, taskId: 'task-send-1' }) + } else gateway.finish(status) + + await expect(stop).toHaveCount(0) + await expect(send).toBeEnabled() + await expect(input).toHaveValue('Synthetic next request without changing projects.') + await send.click() + await expect.poll(() => gateway.sends.length).toBe(2) + expect(gateway.sends[1]).toMatchObject({ + sessionKey: SESSION, message: 'Synthetic next request without changing projects.', + }) + expect(page.url()).toBe(originalUrl) + expect(gateway.connectionCount()).toBe(1) + await expect(stop).toBeVisible() + gateway.finish('succeeded') + await expect(send).toBeEnabled() + }) +} + +test('pending admission is visible and a rejected send restores an editable retry', async ({ page }) => { + const gateway = await installGateway(page, true) + await openChat(page) + const input = page.locator('.chat-textarea') + const send = page.locator('.chat-send-btn[aria-label="Send"]') + await input.fill('Synthetic request to retry.') + await send.click() + await expect.poll(() => gateway.sends.length).toBe(1) + await expect(page.getByRole('button', { name: 'Stop current response' })).toBeVisible() + await expect(page.locator('.msg-user')).toContainText('Synthetic request to retry.') + + gateway.rejectHeldSend() + await expect(page.locator('.chat-thread')).toContainText(REJECTION) + await expect(input).toHaveValue('Synthetic request to retry.') + await expect(send).toBeEnabled() + await send.click() + await expect.poll(() => gateway.sends.length).toBe(2) + expect(gateway.sends[1]?.message).toBe('Synthetic request to retry.') + gateway.finish('succeeded') + await expect(send).toBeEnabled() +}) diff --git a/opensquilla-webui/e2e/idle-chat-recovery.spec.ts b/opensquilla-webui/e2e/idle-chat-recovery.spec.ts new file mode 100644 index 0000000000..f9dc40731b --- /dev/null +++ b/opensquilla-webui/e2e/idle-chat-recovery.spec.ts @@ -0,0 +1,118 @@ +import { expect, test } from '@playwright/test' +import { helloOkResponse } from './support/gateway-fixture' +import { + chatHistoryPayload, + sessionMessagesHydratePayload, + sessionMessagesSnapshotPayload, + sessionMessagesSubscribePayload, +} from './support/session-read-fixtures' + +const SESSION = 'agent:main:webchat:e2e-idle-recovery' +const DRAFT = 'Synthetic follow-up preserved while the client is idle.' + +for (const halfOpen of [false, true]) { + test(`sends the retained draft after idle resume with a ${halfOpen ? 'half-open' : 'healthy'} connection`, async ({ page }) => { + let connectionCount = 0 + let loseFirstConnection = false + const subscriptions: number[] = [] + const sends: Array> = [] + + // A suspended renderer's timers resume after a large elapsed-time jump. + // This is a deterministic lifecycle regression, not physical sleep proof. + await page.clock.install({ time: new Date('2026-01-01T00:00:00Z') }) + await page.addInitScript(() => localStorage.setItem('opensquilla-locale', 'en')) + await page.route('**/api/**', route => route.fulfill({ json: {} })) + await page.route('**/api/approvals', route => route.fulfill({ + json: { pending: [], mode: 'prompt', allowPatterns: [], denyPatterns: [] }, + })) + await page.routeWebSocket(/\/ws$/, socket => { + const connection = ++connectionCount + socket.send(JSON.stringify({ type: 'event', event: 'connect.challenge', payload: {} })) + socket.onMessage(raw => { + const frame = JSON.parse(String(raw)) + // Leave the socket physically open but drop all traffic on its peer. + if (connection === 1 && loseFirstConnection) return + if (frame.type === 'ping') { + socket.send(JSON.stringify({ type: 'pong', nonce: frame.nonce })) + return + } + if (frame.type !== 'req') return + if (frame.method === 'connect') { + socket.send(helloOkResponse({ + server: { conn_id: `idle-connection-${connection}` }, + policy: { transport_probe_nonce: true }, + })) + return + } + if (frame.method === 'sessions.messages.subscribe') subscriptions.push(connection) + if (frame.method === 'chat.send') sends.push(frame.params) + const payloads: Record = { + 'agents.list': { agents: [] }, + 'commands.list_for_surface': { commands: [] }, + 'config.get': { + squilla_router: { enabled: false, tiers: {} }, permissions: {}, skills: {}, + }, + 'models.routing.get': { mode: 'direct' }, + 'sessions.list': { + sessions: [{ + key: SESSION, title: 'Synthetic idle recovery', sessionKind: 'chat', + surface: 'webchat', conversationKind: 'direct', effectiveAgentId: 'main', + updatedAt: 100, messageCount: 0, status: 'ok', runStatus: 'idle', + }], count: 1, ts: 1_800_000_000, has_more: false, + }, + 'chat.history': chatHistoryPayload([]), + 'sessions.messages.subscribe': sessionMessagesSubscribePayload(SESSION), + 'sessions.messages.snapshot': sessionMessagesSnapshotPayload(SESSION), + 'sessions.messages.hydrate': sessionMessagesHydratePayload(SESSION), + 'sessions.messages.unsubscribe': { subscribed: false }, + 'sessions.subscribe': { subscribed: true }, + 'usage.status': { sessions: [] }, + 'chat.send': { accepted: true, session: SESSION, task_id: 'idle-follow-up' }, + } + socket.send(JSON.stringify({ + type: 'res', id: frame.id, ok: true, payload: payloads[frame.method] ?? {}, + })) + }) + }) + + await page.goto(`/control/chat?session=${encodeURIComponent(SESSION)}`) + const input = page.locator('.chat-textarea') + const send = page.locator('.chat-send-btn[aria-label="Send"]') + await expect(send).toBeEnabled() + await input.fill(DRAFT) + const initialUrl = page.url() + await page.evaluate(() => { + Object.defineProperty(window, '__idleRecoveryComposer', { + value: document.querySelector('.chat-textarea'), + }) + }) + + loseFirstConnection = halfOpen + await page.clock.fastForward(2 * 60 * 60 * 1_000) + await page.evaluate(() => document.dispatchEvent(new Event('resume'))) + // Let real network messages interleave with every virtual timer step. + // Product probe/retry deadlines remain unchanged. + for (let elapsed = 0; elapsed < 65_000; elapsed += 1_000) { + await page.clock.runFor(1_000) + if (halfOpen && connectionCount > 1 && await send.isEnabled()) break + if (!halfOpen && elapsed >= 6_000) break + } + + expect(connectionCount).toBe(halfOpen ? 2 : 1) + expect(subscriptions).toContain(connectionCount) + expect(sends).toHaveLength(0) + expect(page.url()).toBe(initialUrl) + expect(await page.evaluate(() => ( + (window as unknown as { __idleRecoveryComposer: Element }).__idleRecoveryComposer + === document.querySelector('.chat-textarea') + ))).toBe(true) + await expect(input).toHaveValue(DRAFT) + await expect(send).toBeEnabled() + await input.press('Enter') + await expect.poll(() => sends.length).toBe(1) + expect(sends[0]).toMatchObject({ sessionKey: SESSION, message: DRAFT }) + await expect(input).toHaveValue('') + await page.clock.runFor(2_000) + expect(sends).toHaveLength(1) + }) +} diff --git a/opensquilla-webui/src/App.vue b/opensquilla-webui/src/App.vue index 5699a38e5e..2513c2fb70 100644 --- a/opensquilla-webui/src/App.vue +++ b/opensquilla-webui/src/App.vue @@ -631,9 +631,9 @@ const effectiveConnectionState = computed(() => effectiveChatConnectionState( appStore.chatLivePhase, isChatRoute.value, )) -const connectionStateLabel = computed(() => t( - `chrome.connectionState.${effectiveConnectionState.value}`, -)) +const connectionStateLabel = computed(() => getPlatform().id === 'web' && gatewayAccess.requiresCredential + ? t('setup.connection.tokenRequired') + : t(`chrome.connectionState.${effectiveConnectionState.value}`)) const router = useRouter() // afterEach only fires on navigation, so a same-route language switch needs an diff --git a/opensquilla-webui/src/adapters/gateway/gatewayAccessV4.test.ts b/opensquilla-webui/src/adapters/gateway/gatewayAccessV4.test.ts index e0caec259a..f7c4e8490e 100644 --- a/opensquilla-webui/src/adapters/gateway/gatewayAccessV4.test.ts +++ b/opensquilla-webui/src/adapters/gateway/gatewayAccessV4.test.ts @@ -97,4 +97,24 @@ describe('createV4GatewayAccess', () => { expect(access.runModePolicy).toBeNull() expect(access.streamIdleTimeoutMs).toBeNull() }) + + it.each(['authentication_failed', 'authentication_mismatch'])( + 'makes an explicit credential rejection actionable: %s', error => { + const raw = source() + raw.error = error + expect(createV4GatewayAccess(raw).requiresCredential).toBe(true) + }, + ) + + it('does not request credentials for guests, scope denials, or transport policy failures', () => { + const raw = source() + const access = createV4GatewayAccess(raw) + raw.state = 'connected' + raw.auth = { principal: { authState: 'guest', authenticated: false } } + expect(access.requiresCredential).toBe(false) + for (const error of ['UNAUTHORIZED', '1008', 'protocol_rejected', 'Connection closed']) { + raw.error = error + expect(access.requiresCredential).toBe(false) + } + }) }) diff --git a/opensquilla-webui/src/adapters/gateway/gatewayAccessV4.ts b/opensquilla-webui/src/adapters/gateway/gatewayAccessV4.ts index 0c040dbdac..561fcf0b31 100644 --- a/opensquilla-webui/src/adapters/gateway/gatewayAccessV4.ts +++ b/opensquilla-webui/src/adapters/gateway/gatewayAccessV4.ts @@ -87,6 +87,9 @@ export function createV4GatewayAccess(source: GatewayAccessSource): GatewayAcces get connectionError() { return source.error }, + get requiresCredential() { + return source.error === 'authentication_failed' || source.error === 'authentication_mismatch' + }, get isAvailable() { return source.state === 'connected' }, diff --git a/opensquilla-webui/src/components/settings/SettingsDialog.vue b/opensquilla-webui/src/components/settings/SettingsDialog.vue index 4d35bcc2f0..bf4bfb2d69 100644 --- a/opensquilla-webui/src/components/settings/SettingsDialog.vue +++ b/opensquilla-webui/src/components/settings/SettingsDialog.vue @@ -527,7 +527,8 @@ function focusCompositeHash(): boolean { const target = document.getElementById(targetId) if (!target) return false target.scrollIntoView({ block: 'start', inline: 'nearest', behavior: 'auto' }) - target.focus({ preventScroll: true }) + const initialFocus = target.querySelector('[data-settings-initial-focus]') + ;(initialFocus ?? target).focus({ preventScroll: true }) return true } diff --git a/opensquilla-webui/src/components/settings/SetupConnectionPanel.test.ts b/opensquilla-webui/src/components/settings/SetupConnectionPanel.test.ts index 3633f9df1a..cbfcf0764c 100644 --- a/opensquilla-webui/src/components/settings/SetupConnectionPanel.test.ts +++ b/opensquilla-webui/src/components/settings/SetupConnectionPanel.test.ts @@ -21,10 +21,12 @@ afterEach(() => { async function mountPanel(options: { managed?: boolean availability?: GatewayAvailability + requiresCredential?: boolean } = {}) { const gatewayAccess = reactive({ availability: options.availability ?? 'unavailable', connectionError: null as string | null, + requiresCredential: options.requiresCredential ?? false, isAvailable: options.availability === 'available', isLocalOwner: false, isAuthenticated: false, @@ -136,4 +138,49 @@ describe('SetupConnectionPanel', () => { })) expect(gatewayAccess.disconnect).not.toHaveBeenCalled() }) + + it('focuses credential recovery on entry and submits without a preliminary disconnect', async () => { + const { el, gatewayAccess } = await mountPanel({ requiresCredential: true }) + const credential = el.querySelector('#conn-ws-token')! + expect(el.querySelector('.conn-status__pill')?.textContent).toBe('Token required') + expect(el.textContent).toContain('Automatic retries are paused') + expect(document.activeElement).toBe(credential) + expect(el.querySelector('.conn-optional')).toBeNull() + + credential.value = ' synthetic-replacement-token ' + credential.dispatchEvent(new Event('input')) + credential.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })) + await nextTick() + expect(gatewayAccess.connect).toHaveBeenCalledExactlyOnceWith({ + endpoint: 'ws://saved-gateway.example/ws', + credential: 'synthetic-replacement-token', + }) + expect(gatewayAccess.disconnect).not.toHaveBeenCalled() + }) + + it('keeps a partially entered token and focus stable while showing a failed attempt', async () => { + const { el, gatewayAccess } = await mountPanel() + const credential = el.querySelector('#conn-ws-token')! + credential.value = 'synthetic-partial-token' + credential.dispatchEvent(new Event('input')) + gatewayAccess.requiresCredential = true + await nextTick() + expect(document.activeElement).toBe(credential) + + const endpoint = el.querySelector('#conn-ws-url')! + endpoint.focus() + gatewayAccess.connectionError = 'authentication_mismatch' + await nextTick() + expect(document.activeElement).toBe(endpoint) + expect(credential.value).toBe('synthetic-partial-token') + expect(gatewayAccess.connect).not.toHaveBeenCalled() + expect(gatewayAccess.disconnect).not.toHaveBeenCalled() + }) + + it('keeps Desktop credential recovery under the managed runtime controls', async () => { + const { el } = await mountPanel({ managed: true, requiresCredential: true }) + expect(el.querySelectorAll('input')).toHaveLength(0) + expect(el.textContent).not.toContain('Token required') + expect(el.textContent).not.toContain('Automatic retries are paused') + }) }) diff --git a/opensquilla-webui/src/components/settings/SetupConnectionPanel.vue b/opensquilla-webui/src/components/settings/SetupConnectionPanel.vue index a09f4d3fc9..1cf821cfbc 100644 --- a/opensquilla-webui/src/components/settings/SetupConnectionPanel.vue +++ b/opensquilla-webui/src/components/settings/SetupConnectionPanel.vue @@ -1,5 +1,5 @@