Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@ The independently published `fsb-mcp-server` npm package keeps its own semver ch

## [Unreleased]

Nothing yet.
### Fixed

- **Dashboard QR pairing failures are visible again.** Scanning a pairing code that was expired, already used, or otherwise rejected reported nothing at all: the "Connecting..." state replaced the scan panel markup, so the error was written into a node that had already been removed from the DOM, and the dashboard then switched to the Paste Key tab. Every failure now renders its localized reason, keeps the user on the Scan tab, and restarts the camera so a regenerated code can be scanned directly.

## v0.9.91 — Version Metadata Alignment — 2026-07-14

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,26 @@ describe('DashboardPageComponent QR scanner lifecycle', () => {
};
}

// The scan success handler is the third argument html5-qrcode receives.
function decodeQR(payload: string): void {
const scanner = scannerInstances[scannerInstances.length - 1];
const onDecode = scanner.start.calls.mostRecent().args[2] as (text: string) => void;
onDecode(payload);
flushMicrotasks();
}

function scanErrorEl(fixture: ComponentFixture<DashboardPageComponent>): HTMLElement {
// Re-query rather than caching: a failed scan rebuilds the panel markup.
return fixture.nativeElement.querySelector('#dash-scan-error') as HTMLElement;
}

function rejectPairing(code: string): jasmine.Spy {
return spyOn(window, 'fetch').and.returnValue(Promise.resolve({
ok: false,
json: () => Promise.resolve({ code }),
} as Response));
}

it('waits for the QR library and starts the scanner exactly once', fakeAsync(() => {
const fixture = createFixture();
const script = qrScript();
Expand Down Expand Up @@ -166,6 +186,58 @@ describe('DashboardPageComponent QR scanner lifecycle', () => {
expect(error.textContent).toBe('Camera unavailable');
}));

it('surfaces a rejected pairing exchange and lets the user rescan', fakeAsync(() => {
const fixture = createFixture();
const scanTab = fixture.nativeElement.querySelector('#dash-tab-scan') as HTMLButtonElement;
const pasteTab = fixture.nativeElement.querySelector('#dash-tab-paste') as HTMLButtonElement;

installScanner();
qrScript().dispatchEvent(new Event('load'));
flushMicrotasks();
expect(scannerInstances).toHaveSize(1);

rejectPairing('pair_token_expired');
decodeQR(JSON.stringify({ t: 'expired-token' }));

expect(scanErrorEl(fixture).style.display).toBe('block');
expect(scanErrorEl(fixture).textContent).toBe('The pairing code has expired');
expect(scanTab.classList).toContain('active');
expect(pasteTab.classList).not.toContain('active');
expect(scannerInstances).toHaveSize(2);
}));

it('reports a malformed QR payload without leaking the parser message', fakeAsync(() => {
const fixture = createFixture();
const scanTab = fixture.nativeElement.querySelector('#dash-tab-scan') as HTMLButtonElement;
const fetchSpy = spyOn(window, 'fetch');

installScanner();
qrScript().dispatchEvent(new Event('load'));
flushMicrotasks();

decodeQR('not-json');

expect(fetchSpy).not.toHaveBeenCalled();
expect(scanErrorEl(fixture).style.display).toBe('block');
expect(scanErrorEl(fixture).textContent).toBe('Scan failed -- paste your key instead');
expect(scanTab.classList).toContain('active');
}));

it('reports a QR payload that carries no pairing token', fakeAsync(() => {
const fixture = createFixture();
const fetchSpy = spyOn(window, 'fetch');

installScanner();
qrScript().dispatchEvent(new Event('load'));
flushMicrotasks();

decodeQR(JSON.stringify({ s: 'https://example.test' }));

expect(fetchSpy).not.toHaveBeenCalled();
expect(scanErrorEl(fixture).textContent).toBe('QR code does not contain a pairing token');
expect(scannerInstances).toHaveSize(2);
}));

it('reuses in-flight CDN scripts across dashboard component instances', fakeAsync(() => {
const first = createFixture();
const script = qrScript();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2581,7 +2581,10 @@ export class DashboardPageComponent implements OnInit, AfterViewInit, OnDestroy
private handleScannedQR(decodedText: string): void {
try {
const data = JSON.parse(decodedText);
if (!data.t) throw new Error(this.dashboardCopy.qrMissingToken);
if (!data.t) {
this.failScan(this.dashboardCopy.qrMissingToken);
return;
}

if (this.tabScanContent) {
this.tabScanContent.innerHTML = '<p class="dash-scan-instruction">' + this.escapeHtml(this.dashboardCopy.connecting) + '</p>';
Expand All @@ -2591,6 +2594,10 @@ export class DashboardPageComponent implements OnInit, AfterViewInit, OnDestroy
if (data.s && data.s === location.origin) exchangeUrl = '/api/pair/exchange';
if (!data.s) exchangeUrl = '/api/pair/exchange';

// Once the exchange succeeds the scanner must never come back, even if the
// post-pairing wiring below throws.
let paired = false;

fetch(exchangeUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
Expand All @@ -2607,6 +2614,7 @@ export class DashboardPageComponent implements OnInit, AfterViewInit, OnDestroy
}
return resp.json();
}).then(result => {
paired = true;
this.storeSession(result.hashKey, result.sessionToken, result.expiresAt);
this.showDashboard();
// DEPRECATED v0.9.45rc1: superseded by OpenClaw / Claude Routines -- see PROJECT.md
Expand All @@ -2615,18 +2623,13 @@ export class DashboardPageComponent implements OnInit, AfterViewInit, OnDestroy
// DEPRECATED v0.9.45rc1: superseded by OpenClaw / Claude Routines -- see PROJECT.md
// this.startPolling();
}).catch((err: Error & { localizedMessage?: string }) => {
this.showScanError(err?.localizedMessage || this.dashboardCopy.scanFailed);
if (this.tabScanContent) {
this.tabScanContent.innerHTML =
'<p class="dash-scan-instruction">' + this.escapeHtml(this.dashboardCopy.pointCamera) + '</p>' +
'<div id="qr-reader" class="dash-qr-reader" aria-label="' + this.escapeAttr(this.dashboardCopy.qrViewfinder) + '"></div>' +
'<p id="dash-scan-error" class="dash-scan-error" style="display: none;"></p>';
}
this.switchTab('paste');
if (paired) return;
this.failScan(err?.localizedMessage || this.dashboardCopy.qrExchangeFailed);
});
} catch (err) {
this.showScanError(this.dashboardCopy.scanFailed);
this.switchTab('paste');
// A malformed payload carries an untranslated parser message, so report
// our own localized copy instead of err.message.
this.failScan(this.dashboardCopy.scanFailed);
}
}

Expand All @@ -2637,6 +2640,23 @@ export class DashboardPageComponent implements OnInit, AfterViewInit, OnDestroy
el.style.display = 'block';
}
}

private resetScanPanel(): void {
if (!this.tabScanContent) return;
this.tabScanContent.innerHTML =
'<p class="dash-scan-instruction">' + this.escapeHtml(this.dashboardCopy.pointCamera) + '</p>' +
'<div id="qr-reader" class="dash-qr-reader" aria-label="' + this.escapeAttr(this.dashboardCopy.qrViewfinder) + '"></div>' +
'<p id="dash-scan-error" class="dash-scan-error" style="display: none;"></p>';
this.scanError = this.el('dash-scan-error');
}

// Rebuild before showing: the connecting state replaces the panel markup, so
// writing the error first would target a node that is no longer in the DOM.
private failScan(message: string): void {
this.resetScanPanel();
this.showScanError(message);
void this.startQRScanner();
}
//
// ==================== DATA LOADING ====================
//
Expand Down
28 changes: 14 additions & 14 deletions showcase/angular/src/locale/messages.xlf
Original file line number Diff line number Diff line change
Expand Up @@ -4831,98 +4831,98 @@
<source>Last snapshot: <x id="snapshotTime" equiv-text="snapshotTime"/></source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/pages/dashboard/dashboard-page.component.ts</context>
<context context-type="linenumber">3994</context>
<context context-type="linenumber">4014</context>
</context-group>
</trans-unit>
<trans-unit id="dashboard.runtime.tooltip.state" datatype="html">
<source>State: <x id="streamState" equiv-text="state"/></source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/pages/dashboard/dashboard-page.component.ts</context>
<context context-type="linenumber">3998</context>
<context context-type="linenumber">4018</context>
</context-group>
</trans-unit>
<trans-unit id="dashboard.runtime.tooltip.reason" datatype="html">
<source>Reason: <x id="reason" equiv-text="reason"/></source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/pages/dashboard/dashboard-page.component.ts</context>
<context context-type="linenumber">4002</context>
<context context-type="linenumber">4022</context>
</context-group>
</trans-unit>
<trans-unit id="dashboard.runtime.tooltip.recoveringFor" datatype="html">
<source>Recovering for <x id="seconds" equiv-text="seconds"/> s</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/pages/dashboard/dashboard-page.component.ts</context>
<context context-type="linenumber">4006</context>
<context context-type="linenumber">4026</context>
</context-group>
</trans-unit>
<trans-unit id="dashboard.runtime.tooltip.lastFrame" datatype="html">
<source>last frame: <x id="seconds" equiv-text="frameSeconds"/> s ago</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/pages/dashboard/dashboard-page.component.ts</context>
<context context-type="linenumber">4012</context>
<context context-type="linenumber">4032</context>
</context-group>
</trans-unit>
<trans-unit id="dashboard.runtime.tooltip.mutations" datatype="html">
<source>mutations: <x id="count" equiv-text="this.mutationsAppliedTotal"/></source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/pages/dashboard/dashboard-page.component.ts</context>
<context context-type="linenumber">4013</context>
<context context-type="linenumber">4033</context>
</context-group>
</trans-unit>
<trans-unit id="dashboard.runtime.tooltip.applyFailures" datatype="html">
<source>apply failures: <x id="count" equiv-text="this.mutationApplyFailures"/></source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/pages/dashboard/dashboard-page.component.ts</context>
<context context-type="linenumber">4014</context>
<context context-type="linenumber">4034</context>
</context-group>
</trans-unit>
<trans-unit id="dashboard.runtime.tooltip.stale" datatype="html">
<source>stale: <x id="count" equiv-text="this.staleMutationCount"/></source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/pages/dashboard/dashboard-page.component.ts</context>
<context context-type="linenumber">4015</context>
<context context-type="linenumber">4035</context>
</context-group>
</trans-unit>
<trans-unit id="dashboard.runtime.tooltip.noData" datatype="html">
<source>No stream data</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/pages/dashboard/dashboard-page.component.ts</context>
<context context-type="linenumber">4016</context>
<context context-type="linenumber">4036</context>
</context-group>
</trans-unit>
<trans-unit id="dashboard.runtime.api.requestFailed" datatype="html">
<source>Request failed with status <x id="status" equiv-text="status"/></source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/pages/dashboard/dashboard-page.component.ts</context>
<context context-type="linenumber">4319</context>
<context context-type="linenumber">4339</context>
</context-group>
</trans-unit>
<trans-unit id="dashboard.runtime.task.etaMinutes" datatype="html">
<source>~<x id="minutes" equiv-text="amount"/> min remaining</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/pages/dashboard/dashboard-page.component.ts</context>
<context context-type="linenumber">4474</context>
<context context-type="linenumber">4494</context>
</context-group>
</trans-unit>
<trans-unit id="dashboard.runtime.task.etaSeconds" datatype="html">
<source>~<x id="seconds" equiv-text="amount"/> s remaining</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/pages/dashboard/dashboard-page.component.ts</context>
<context context-type="linenumber">4475</context>
<context context-type="linenumber">4495</context>
</context-group>
</trans-unit>
<trans-unit id="dashboard.runtime.task.runningFor" datatype="html">
<source>Running for <x id="duration" equiv-text="duration"/></source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/pages/dashboard/dashboard-page.component.ts</context>
<context context-type="linenumber">4549</context>
<context context-type="linenumber">4569</context>
</context-group>
</trans-unit>
<trans-unit id="dashboard.runtime.task.stoppedWithAction" datatype="html">
<source>Stopped by user -- was: <x id="action" equiv-text="action"/></source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/pages/dashboard/dashboard-page.component.ts</context>
<context context-type="linenumber">4554</context>
<context context-type="linenumber">4574</context>
</context-group>
</trans-unit>
<trans-unit id="home.hero.title" datatype="html">
Expand Down
13 changes: 13 additions & 0 deletions tests/dashboard-runtime-state.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -533,6 +533,19 @@ assert(angularDashboardTsSource.includes('this.translateTaskError(')
assert(angularDashboardTsSource.includes('this.pairingErrorMessage(body?.code)')
&& !angularDashboardTsSource.includes('body.error || this.dashboardCopy.qrExchangeFailed'),
'QR pairing failures use trusted codes instead of rendering server English');
// The connecting state replaces the scan panel markup, so a pairing error written
// before the rebuild lands on a detached node and is never seen.
assert(/failScan\(message: string\): void \{\s*this\.resetScanPanel\(\);\s*this\.showScanError\(message\);\s*void this\.startQRScanner\(\);/
.test(angularDashboardTsSource),
'failScan rebuilds the scan panel before showing the error, then restarts the scanner');
const handleScannedQRStart = angularDashboardTsSource.indexOf('private handleScannedQR');
const handleScannedQREnd = angularDashboardTsSource.indexOf('private showScanError');
assert(handleScannedQRStart >= 0 && handleScannedQREnd > handleScannedQRStart,
'Angular dashboard still defines handleScannedQR ahead of showScanError');
const handleScannedQRBody = angularDashboardTsSource.slice(handleScannedQRStart, handleScannedQREnd);
assert(!handleScannedQRBody.includes("switchTab('paste')")
&& (handleScannedQRBody.match(/this\.failScan\(/g) || []).length === 3,
'every QR scan failure reports through failScan and keeps the user on the Scan tab');
assert(angularDashboardTsSource.includes('inject(LOCALE_ID)')
&& angularDashboardTsSource.includes('Math.round(safe).toLocaleString(this.localeId)')
&& angularDashboardTsSource.includes('new Date(this.lastSnapshotTime).toLocaleTimeString(this.localeId)'),
Expand Down
Loading