diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index eb4e3a0..316a1cb 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -56,7 +56,9 @@ jobs: echo "VERSION=$version" >> "$GITHUB_ENV" # Prereleases ship only as GitHub release tarballs. # Stable versions also publish to npm. - case "$version" in + # Only a hyphen before build metadata (+) marks a prerelease. + version_without_build="${version%%+*}" + case "$version_without_build" in *-*) echo "PRERELEASE=true" >> "$GITHUB_ENV" ;; *) echo "PRERELEASE=false" >> "$GITHUB_ENV" ;; esac @@ -129,6 +131,9 @@ jobs: release_args=() if [ "$PRERELEASE" = "true" ]; then release_args=(--prerelease --latest=false) + tarball_name="$(basename "$tarball")" + printf '\n## Install\n\n```sh\ncd /data && npm i https://github.com/%s/releases/download/%s/%s\n```\n\nInstall in your Node-RED user directory (`/data` for Docker or `~/.node-red` locally), then restart Node-RED.\n' \ + "$GITHUB_REPOSITORY" "$TAG" "$tarball_name" >> release-notes.md fi if gh release view "$TAG" >/dev/null 2>&1; then gh release edit "$TAG" --title "$TAG" --notes-file release-notes.md "${release_args[@]}" diff --git a/CHANGELOG.md b/CHANGELOG.md index 646da19..4376ba6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,27 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Fixed + +- Recover dashboard editor jumps when the last-focused editor tab has closed or + stopped responding. Track editor presence, clear owned focus and presence on + pagehide, and avoid reloading a live editor when acknowledgement times out. + Remove transient reveal claims after handling each request. Prevent duplicate + cards when a late owner races a surviving tab, and refresh presence on + receipt. +- Isolate dashboard reveals to the last-focused editor tab (#128). +- Skip scrolling to the top when opening proposal deep-links (#128). +- Flush the first OAuth client `lastUsedAt` update immediately so Last used + survives a restart (#128). +- Restore the landed-after-withdraw badge style and expose `landedAfterWithdraw` + in proposal list and detail API responses (#128). +- Keep dashboard h2 focus-visible styling subtle (#128). +- Recognize stable versions with `+build` metadata correctly when detecting + prereleases in `release.yml` (#128). +- Include a generic install URL and install command in prerelease notes (#128). + ## [0.2.0-beta.13] - 2026-11-10 - Label the "Opened from dashboard" card action Go to flow for flow, tab and diff --git a/README.md b/README.md index a03fc47..b3dbd00 100644 --- a/README.md +++ b/README.md @@ -38,11 +38,14 @@ Configuration). Stable releases use npm as shown above. From 0.2.0-beta.12 onward, pre-releases are available only as GitHub Release assets, not on npm. To - install this beta, use its tarball URL in your user directory (`/data` is - typical for Docker; otherwise use `~/.node-red`): + install a pre-release, pick the one you want from + [GitHub Releases](https://github.com/com55/node-red-ai-access/releases) and + use its exact install command from the release notes. For example, replace + `X.Y.Z` below with the full version, including its pre-release suffix + (`/data` is typical for Docker; otherwise use `~/.node-red`): ```bash - cd /data && npm i https://github.com/com55/node-red-ai-access/releases/download/v0.2.0-beta.13/com55-node-red-ai-access-0.2.0-beta.13.tgz + cd /data && npm i https://github.com/com55/node-red-ai-access/releases/download/vX.Y.Z/com55-node-red-ai-access-X.Y.Z.tgz ``` 2. Restart Node-RED. diff --git a/docs/releasing.md b/docs/releasing.md index 87365b8..f995dc3 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -6,13 +6,19 @@ production build and tests. It creates or updates the GitHub release for that tag, with the CHANGELOG section as notes and the `npm pack` tarball attached. Stable versions (`PRERELEASE=false`) also publish to npm. From 0.2.0-beta.12 onward, pre-releases (`PRERELEASE=true`) ship only as GitHub pre-release assets -and never publish to npm. +and never publish to npm. Only a `-` before any `+` build metadata marks a +prerelease, so `0.2.0+build` is stable while `0.2.0-beta.13+build` is a +prerelease. Install stable releases with `npm install @com55/node-red-ai-access` in the -Node-RED user directory. For betas, install the GitHub Release tarball instead: +Node-RED user directory. For pre-releases, pick the one you want from +[GitHub Releases](https://github.com/com55/node-red-ai-access/releases) and use +the exact install command in its release notes. The workflow appends this +command using that tag and its attached tarball. In this template, replace +`X.Y.Z` with the full version, including its pre-release suffix: ```sh -cd /data && npm i https://github.com/com55/node-red-ai-access/releases/download/v0.2.0-beta.13/com55-node-red-ai-access-0.2.0-beta.13.tgz +cd /data && npm i https://github.com/com55/node-red-ai-access/releases/download/vX.Y.Z/com55-node-red-ai-access-X.Y.Z.tgz ``` `/data` is the typical Docker userDir; use `~/.node-red` for a typical local diff --git a/src/plugin/editor-sidebar.ts b/src/plugin/editor-sidebar.ts index 520f6cb..c68526e 100644 --- a/src/plugin/editor-sidebar.ts +++ b/src/plugin/editor-sidebar.ts @@ -600,6 +600,85 @@ export function editorRevealListenerScript(): string { var scope = window.location ? window.location.pathname.replace(/\\/$/, '') + '/' : '/'; var key = 'node-red-ai-access:reveal:' + scope; var ch; + var tabId = Date.now() + '-' + Math.random().toString(36).slice(2); + var focusKey = key + ':focus'; + var presenceKey = key + ':presence:' + tabId; + var presenceTimer; + function recordPresence() { + try { localStorage.setItem(presenceKey, String(Date.now())); } catch (e) {} + } + function startPresence() { + recordPresence(); + if (!presenceTimer && typeof window.setInterval === 'function') presenceTimer = window.setInterval(recordPresence, 5000); + } + function leave() { + if (typeof window.clearInterval === 'function') window.clearInterval(presenceTimer); + presenceTimer = null; + try { + localStorage.removeItem(presenceKey); + var focus = JSON.parse(localStorage.getItem(focusKey) || 'null'); + if (focus && focus.tabId === tabId) localStorage.removeItem(focusKey); + } catch (e) {} + } + startPresence(); + if (typeof window.addEventListener === 'function') { + window.addEventListener('pagehide', leave); + window.addEventListener('unload', leave); + window.addEventListener('pageshow', function () { + startPresence(); + try { + if (typeof document !== 'undefined' && document.visibilityState === 'visible' && + typeof document.hasFocus === 'function' && document.hasFocus()) recordFocus(); + } catch (e) {} + }); + } + function recordFocus() { + recordPresence(); + try { localStorage.setItem(focusKey, JSON.stringify({ tabId: tabId, at: Date.now() })); } catch (e) {} + } + if (typeof window.addEventListener === 'function') window.addEventListener('focus', recordFocus); + if (typeof document !== 'undefined') { + if (typeof document.addEventListener === 'function') document.addEventListener('visibilitychange', function () { + if (document.visibilityState === 'visible') recordFocus(); + }); + try { if (typeof document.hasFocus === 'function' && document.hasFocus()) recordFocus(); } catch (e) {} + } + function claim(m, work) { + var claimKey = key + ':claim:' + m.reqId; + function elect(focused) { + try { + // Completed requests stay deduplicated after their transient claim is removed. + var ack = JSON.parse(localStorage.getItem(key + ':ack') || 'null'); + if (ack && ack.reqId === m.reqId) return; + var owner = JSON.parse(localStorage.getItem(claimKey) || 'null'); + if (owner && Date.now() - owner.at <= 35000) return; + localStorage.setItem(claimKey, JSON.stringify({ tabId: tabId, at: Date.now() })); + function finish() { + try { + var ack = JSON.parse(localStorage.getItem(key + ':ack') || 'null'); + if (ack && ack.reqId === m.reqId) return; + var winner = JSON.parse(localStorage.getItem(claimKey) || 'null'); + if (!winner || winner.tabId !== tabId) return; + try { work(); } finally { localStorage.removeItem(claimKey); } + } catch (e) {} + } + if (!focused && typeof window.setTimeout === 'function') window.setTimeout(finish, 50); + else finish(); + } catch (e) { + // Without shared storage, only an editor that currently has focus may act. + try { if (typeof document !== 'undefined' && typeof document.hasFocus === 'function' && document.hasFocus()) work(); } catch (ignored) {} + } + } + try { + var focus = JSON.parse(localStorage.getItem(focusKey) || 'null'); + if (!focus || typeof focus.tabId !== 'string' || typeof focus.at !== 'number' || !isFinite(focus.at)) focus = null; + // Give the last-focused editor a head start, but never let a closed or + // unresponsive owner prevent surviving editors from acknowledging. + if (focus && focus.tabId !== tabId && typeof window.setTimeout === 'function') { + window.setTimeout(function () { elect(false); }, 100); + } else elect(!!focus); + } catch (e) { elect(false); } + } var seen = Object.create(null); var latestTime = 0; var latestRequest; @@ -654,18 +733,22 @@ export function editorRevealListenerScript(): string { } function receive(m, fromStartup) { if (!valid(m) || seen[m.reqId] || m.sentAt < latestTime) return; - Object.keys(seen).forEach(function (id) { if (Date.now() - seen[id] > 30000) delete seen[id]; }); - seen[m.reqId] = Date.now(); - latestTime = m.sentAt; - latestRequest = m.reqId; - // Acknowledge liveness before any editor API or card work can block or fail. - var ack = { type: 'reveal-ack', reqId: m.reqId, alive: true }; - if (ch) { try { ch.postMessage(ack); } catch (e) {} } - try { localStorage.setItem(key + ':ack', JSON.stringify(ack)); } catch (e) {} - // Preserve live reveal behavior. On startup Node-RED handles the URL hash itself. - if (!fromStartup) reveal(m); - showCard(m); - try { if (RED.sidebar && typeof RED.sidebar.show === 'function') RED.sidebar.show('ai-access'); } catch (e) { /* sidebar may be unavailable */ } + recordPresence(); + claim(m, function () { + if (!valid(m) || seen[m.reqId] || m.sentAt < latestTime) return; + Object.keys(seen).forEach(function (id) { if (Date.now() - seen[id] > 30000) delete seen[id]; }); + seen[m.reqId] = Date.now(); + latestTime = m.sentAt; + latestRequest = m.reqId; + // Acknowledge liveness before any editor API or card work can block or fail. + var ack = { type: 'reveal-ack', reqId: m.reqId, alive: true }; + if (ch) { try { ch.postMessage(ack); } catch (e) {} } + try { localStorage.setItem(key + ':ack', JSON.stringify(ack)); } catch (e) {} + // Preserve live reveal behavior. On startup Node-RED handles the URL hash itself. + if (!fromStartup) reveal(m); + showCard(m); + try { if (RED.sidebar && typeof RED.sidebar.show === 'function') RED.sidebar.show('ai-access'); } catch (e) { /* sidebar may be unavailable */ } + }); } try { if (typeof BroadcastChannel !== 'undefined') { diff --git a/src/plugin/oauth.ts b/src/plugin/oauth.ts index cacccaa..a26d015 100644 --- a/src/plugin/oauth.ts +++ b/src/plugin/oauth.ts @@ -227,10 +227,14 @@ export class OAuthStore implements OAuthRegisteredClientsStore { } } private touchTimer: ReturnType | undefined; - /** Coalesces lastUsedAt-only changes into one write per five minutes; token changes still persist immediately. */ - touch(): void { + /** Persists first use immediately; later lastUsedAt changes coalesce over five minutes. */ + touch(immediate = false): void { if (this.disposed) return; this.dirty = true; + if (immediate) { + this.flush(); + return; + } if (this.touchTimer) return; this.touchTimer = setTimeout(() => { this.touchTimer = undefined; @@ -626,8 +630,9 @@ export class LocalOAuthProvider implements OAuthServerProvider { throw new InvalidTokenError('Invalid access token'); const client = this.clientsStore.data.clients.find(c => c.client_id === item.clientId); if (!client) throw new InvalidTokenError('Invalid client'); + const firstUse = client.lastUsedAt === undefined; client.lastUsedAt = Date.now(); - this.clientsStore.touch(); + this.clientsStore.touch(firstUse); return { token, clientId: item.clientId, diff --git a/src/plugin/page-client.ts b/src/plugin/page-client.ts index 1cd6f04..0f18686 100644 --- a/src/plugin/page-client.ts +++ b/src/plugin/page-client.ts @@ -841,6 +841,8 @@ function pageClientScriptPartATabs(): string { } } selectedLocationHash = String(location.hash || ''); + // The deep-link focus owns scrolling until it consumes the proposal hash. + if (target === 'proposals' && readProposalHashIdFrom(location.hash)) return; window.scrollTo(0, 0); // Run after history traversal's scroll restoration and fragment scrolling. if (window.requestAnimationFrame) { @@ -2673,7 +2675,7 @@ function pageClientScriptPartB2CardsList(): string { return ` function renderLandedAfterWithdraw(p) { return p.status === 'withdrawn' && p.landedAfterWithdraw - ? 'Landed after withdraw ' : ''; + ? 'Landed after withdraw ' : ''; } function renderCompactProposal(p) { var title = titleForProposalCard(p); @@ -3878,7 +3880,7 @@ export function pageClientEditorJumpScript(): string { * Jump links to the editor (#flow/, #node/). A click first asks an * already-open editor tab (BroadcastChannel 'node-red-ai-access', answered by * the editor sidebar script) to reveal the item; without an ack within 1000 ms - * it opens the editor in a named window so later clicks reuse that tab. + * it opens a named window only when no live editor presence remains. */ var EDITOR_WINDOW_NAME = 'node-red-editor'; var REVEAL_CHANNEL = 'node-red-ai-access'; @@ -3937,6 +3939,23 @@ export function pageClientEditorJumpScript(): string { if (opened) return; timer = setTimeout(cleanup, 30000); opened = true; + // Heartbeats expire after crashes. Storage failures are inconclusive: avoid + // navigating a named window that could contain undeployed editor changes. + var liveEditor = false; + try { + var prefix = key + ':presence:'; + for (var i = 0; i < localStorage.length; i++) { + var presenceKey = localStorage.key(i); + if (!presenceKey || presenceKey.indexOf(prefix) !== 0) continue; + var at = Number(localStorage.getItem(presenceKey)); + if (isFinite(at) && at > 0 && Date.now() - at <= 120000) liveEditor = true; + else { localStorage.removeItem(presenceKey); i--; } + } + } catch (e) { liveEditor = true; } + if (liveEditor) { + if (typeof showToast === 'function') showToast('Switch to the Node-RED editor tab to open this item', true, { durationMs: 4000 }); + return; + } var hashAt = href.indexOf('#'); var base = hashAt < 0 ? href : href.slice(0, hashAt); var hash = hashAt < 0 ? '' : href.slice(hashAt); diff --git a/src/plugin/page-styles.ts b/src/plugin/page-styles.ts index 91fc1e1..a2a955a 100644 --- a/src/plugin/page-styles.ts +++ b/src/plugin/page-styles.ts @@ -192,6 +192,8 @@ section.panel > h2 { margin: 0 0 0.75rem; font-size: 1.65rem; } +section.panel > h2:focus { outline: none; } +section.panel > h2:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } .warning { background: var(--warn-bg); border: 1px solid var(--warn-border); @@ -1193,6 +1195,7 @@ dialog h3 { margin: 0 0 0.4rem; } .status-pill.rejected { background: #f4efe6; color: #6a4a14; } .status-pill.failed { background: #fdeceb; color: var(--danger); } .status-pill.withdrawn { background: #eef0f3; color: #4a5564; } +.status-pill.landed-after-withdraw { background: #fff3cd; color: #7a4b00; } .status-pill.expired { background: #f3ebe0; color: #8a5a00; } .status-pill.superseded { background: #ebe4f5; color: #5b3d8a; } .debug-toggle { diff --git a/src/plugin/proposal-present.ts b/src/plugin/proposal-present.ts index 407c87e..a24e904 100644 --- a/src/plugin/proposal-present.ts +++ b/src/plugin/proposal-present.ts @@ -102,6 +102,7 @@ export interface PresentedProposalApi { deployPending?: Proposal['deployPending']; deploymentAlert?: string; rejectedWhileDeployUnknown?: boolean; + landedAfterWithdraw?: boolean; /** Soft validation notes (missing config, unwired, …) — not blocking. */ warnings?: Proposal['warnings']; baseRevChanged?: boolean; @@ -774,6 +775,7 @@ export function presentProposalForApi( } if (full.deploymentAlert) lean.deploymentAlert = full.deploymentAlert; if (full.rejectedWhileDeployUnknown) lean.rejectedWhileDeployUnknown = true; + if (full.landedAfterWithdraw === true) lean.landedAfterWithdraw = true; if (full.deployPending) lean.deployPending = full.deployPending; if (full.aiConflictOverrides) lean.aiConflictOverrides = full.aiConflictOverrides; if (full.conflictChoice) lean.conflictChoice = full.conflictChoice; diff --git a/test/plugin/ai-access-sidebar.test.ts b/test/plugin/ai-access-sidebar.test.ts index a848e4e..277208b 100644 --- a/test/plugin/ai-access-sidebar.test.ts +++ b/test/plugin/ai-access-sidebar.test.ts @@ -429,21 +429,25 @@ describe('ai-access-sidebar.js served tab', () => { text: async () => JSON.stringify({ settings: {}, details: [] }), })); const { $, load } = runSidebar(fetcher); + const storage = new Map(); runInNewContext(editorRevealListenerScript(), { $, URLSearchParams, window: { location: { pathname: '/', search: '?aiAccessReveal=1730000000000-1' } }, localStorage: { - getItem: () => - JSON.stringify({ - type: 'reveal', - id: 'n1', - kind: 'node', - reqId: '1730000000000-1', - sentAt: Date.now(), - scope: '/', - }), - setItem: vi.fn(), + getItem: (key: string) => + key === 'node-red-ai-access:reveal:/' + ? JSON.stringify({ + type: 'reveal', + id: 'n1', + kind: 'node', + reqId: '1730000000000-1', + sentAt: Date.now(), + scope: '/', + }) + : storage.get(key), + setItem: (key: string, value: string) => storage.set(key, value), + removeItem: (key: string) => storage.delete(key), }, RED: { nodes: { node: () => ({ id: 'n1', type: 'inject' }) } }, }); @@ -657,11 +661,14 @@ describe('dashboard request card', () => { minimalApis?: boolean; activeFlow?: string; missingFlowApis?: boolean; + storage?: Map; + focused?: boolean; + heartbeat?: boolean; } = {} ) { const $ = createFakeJQuery(); $('
'); - const storage = new Map(); + const storage = options.storage ?? new Map(); const callbacks: Record void> = {}; const reveal = vi.fn(); const show = vi.fn(); @@ -682,7 +689,9 @@ describe('dashboard request card', () => { scope: '/', }); if (options.startup) storage.set('node-red-ai-access:reveal:/', JSON.stringify(message())); - const channel: { current: { onmessage?: (ev: unknown) => void } } = { current: {} }; + const channel: { + current: { onmessage?: (ev: unknown) => void; postMessage?: ReturnType }; + } = { current: {} }; class Channel { onmessage?: (ev: unknown) => void; constructor() { @@ -694,6 +703,13 @@ describe('dashboard request card', () => { $, Date, URLSearchParams, + document: { + hasFocus: () => options.focused !== false, + visibilityState: 'visible', + addEventListener: (event: string, fn: (ev: unknown) => void) => { + callbacks[event] = fn; + }, + }, setTimeout, window: { location: { @@ -701,6 +717,7 @@ describe('dashboard request card', () => { search: options.startupReqId ? '?aiAccessReveal=' + options.startupReqId : '', }, setTimeout, + ...(options.heartbeat ? { setInterval, clearInterval } : {}), addEventListener: (event: string, fn: (ev: unknown) => void) => { callbacks[event] = fn; }, @@ -709,6 +726,7 @@ describe('dashboard request card', () => { localStorage: { getItem: (key: string) => storage.get(key), setItem: (key: string, value: string) => storage.set(key, value), + removeItem: (key: string) => storage.delete(key), }, RED: { nodes: { @@ -751,9 +769,208 @@ describe('dashboard request card', () => { text, click, channel: () => channel.current, + pagehide: () => callbacks.pagehide!({}), + pageshow: () => callbacks.pageshow!({}), + focus: () => callbacks.focus!({}), + visible: () => callbacks.visibilitychange!({}), }; } + it.each([false, true])( + 'only the last-focused tab handles both transports (reverse=%s)', + reverse => { + const storage = new Map(); + const first = harness({ storage, channel: true }); + const second = harness({ storage, channel: true }); + first.focus(); + second.focus(); + const message = first.message(); + for (const tab of reverse ? [second, first] : [first, second]) { + tab.channel().onmessage!({ data: message }); + tab.send(message); + } + expect(first.show).not.toHaveBeenCalled(); + expect(first.reveal).not.toHaveBeenCalled(); + expect(first.text()).toBe(''); + expect(first.channel().postMessage).not.toHaveBeenCalled(); + expect(second.show).toHaveBeenCalledExactlyOnceWith('ai-access'); + expect(second.reveal).toHaveBeenCalledExactlyOnceWith('n1'); + expect(second.text()).toContain('Opened from dashboard'); + + first.visible(); + const next = first.message('n2', 'node', '1730000000000-2'); + second.send(next); + first.send(next); + expect(first.show).toHaveBeenCalledExactlyOnceWith('ai-access'); + expect(first.reveal).toHaveBeenCalledExactlyOnceWith('n2'); + expect(first.text()).toContain('Second'); + expect(second.show).toHaveBeenCalledTimes(1); + } + ); + + it('keeps presence fresh until pagehide stops the heartbeat', async () => { + vi.useFakeTimers(); + try { + const h = harness({ heartbeat: true }); + const key = [...h.storage.keys()].find(key => key.includes(':presence:'))!; + const initial = Number(h.storage.get(key)); + await vi.advanceTimersByTimeAsync(5000); + expect(Number(h.storage.get(key))).toBe(initial + 5000); + h.pagehide(); + await vi.advanceTimersByTimeAsync(5000); + expect(h.storage.has(key)).toBe(false); + } finally { + vi.useRealTimers(); + } + }); + + it('refreshes presence on receipt before a delayed claim completes', async () => { + vi.useFakeTimers(); + try { + const h = harness({ focused: false }); + const key = [...h.storage.keys()].find(key => key.includes(':presence:'))!; + await vi.advanceTimersByTimeAsync(4000); + h.send(); + expect(Number(h.storage.get(key))).toBe(Date.now()); + expect(h.reveal).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(50); + } finally { + vi.useRealTimers(); + } + }); + + it('does not reveal or show a second card when a late owner finishes between a survivor claim read and write', async () => { + vi.useFakeTimers(); + try { + const storage = new Map(); + const survivor = harness({ storage, focused: false, channel: true }); + const owner = harness({ storage, channel: true }); + const message = survivor.message(); + const claimKey = 'node-red-ai-access:reveal:/:claim:' + message.reqId; + const get = storage.get.bind(storage); + let delivered = false; + vi.spyOn(storage, 'get').mockImplementation(key => { + const value = get(key); + if (key === claimKey && !delivered) { + delivered = true; + // Both tabs read an empty claim before the late owner completes. + owner.send(message); + } + return value; + }); + await vi.advanceTimersByTimeAsync(10); + survivor.send(message); + await vi.advanceTimersByTimeAsync(100); + expect(delivered).toBe(true); + expect(owner.reveal).toHaveBeenCalledExactlyOnceWith('n1'); + expect(storage.has(claimKey)).toBe(true); + await vi.advanceTimersByTimeAsync(50); + expect(survivor.reveal).not.toHaveBeenCalled(); + expect(survivor.show).not.toHaveBeenCalled(); + expect(survivor.text()).toBe(''); + expect(survivor.channel().postMessage).not.toHaveBeenCalled(); + expect(owner.show).toHaveBeenCalledExactlyOnceWith('ai-access'); + expect(owner.text()).toContain('Opened from dashboard'); + expect(owner.channel().postMessage).toHaveBeenCalledTimes(1); + } finally { + vi.useRealTimers(); + } + }); + + it('lets a live last-focused owner win even after the survivor wait expires', async () => { + vi.useFakeTimers(); + try { + const storage = new Map(); + const first = harness({ storage, channel: true }); + const second = harness({ storage, channel: true }); + const message = first.message(); + first.send(message); + second.send(message); + await vi.advanceTimersByTimeAsync(200); + expect(first.reveal).not.toHaveBeenCalled(); + expect(second.reveal).toHaveBeenCalledExactlyOnceWith('n1'); + expect(second.channel().postMessage).toHaveBeenCalledTimes(1); + } finally { + vi.useRealTimers(); + } + }); + + it('clears only its owned focus and presence on pagehide, and restores presence on pageshow', () => { + const storage = new Map(); + const first = harness({ storage }); + const second = harness({ storage }); + const focusKey = 'node-red-ai-access:reveal:/:focus'; + const ownedFocus = storage.get(focusKey); + first.pagehide(); + expect(storage.get(focusKey)).toBe(ownedFocus); + expect([...storage.keys()].filter(key => key.includes(':presence:'))).toHaveLength(1); + second.pagehide(); + expect(storage.has(focusKey)).toBe(false); + expect([...storage.keys()].filter(key => key.includes(':presence:'))).toHaveLength(0); + second.pageshow(); + expect([...storage.keys()].filter(key => key.includes(':presence:'))).toHaveLength(1); + const restoredFocus = JSON.parse(storage.get(focusKey)!); + expect(restoredFocus.tabId).toBe(JSON.parse(ownedFocus!).tabId); + expect(restoredFocus.at).toBeGreaterThanOrEqual(JSON.parse(ownedFocus!).at); + }); + + it('recovers a stale focus owner, acknowledges once and removes the transient claim', async () => { + vi.useFakeTimers(); + try { + const storage = new Map(); + const first = harness({ storage, focused: false, channel: true }); + const second = harness({ storage, focused: false, channel: true }); + storage.set( + 'node-red-ai-access:reveal:/:focus', + JSON.stringify({ tabId: 'closed', at: Date.now() }) + ); + const message = first.message(); + first.send(message); + second.send(message); + await vi.advanceTimersByTimeAsync(150); + expect(first.reveal).toHaveBeenCalledExactlyOnceWith('n1'); + expect(first.channel().postMessage).toHaveBeenCalledExactlyOnceWith({ + type: 'reveal-ack', + reqId: message.reqId, + alive: true, + }); + expect(second.reveal).not.toHaveBeenCalled(); + expect(storage.has('node-red-ai-access:reveal:/:claim:' + message.reqId)).toBe(false); + second.send(message); + await vi.advanceTimersByTimeAsync(150); + expect(second.reveal).not.toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + } + }); + + it('claims a request in one tab when focus history is missing', async () => { + vi.useFakeTimers(); + try { + const storage = new Map(); + const first = harness({ storage, focused: false, channel: true }); + const second = harness({ storage, focused: false, channel: true }); + const message = first.message(); + for (const tab of [first, second]) { + tab.channel().onmessage!({ data: message }); + tab.send(message); + } + await vi.advanceTimersByTimeAsync(50); + expect(first.show).toHaveBeenCalledExactlyOnceWith('ai-access'); + expect(first.reveal).toHaveBeenCalledExactlyOnceWith('n1'); + expect(first.text()).toContain('Opened from dashboard'); + expect(second.show).not.toHaveBeenCalled(); + expect(second.reveal).not.toHaveBeenCalled(); + expect(second.text()).toBe(''); + expect(second.channel().postMessage).not.toHaveBeenCalled(); + second.focus(); + second.send(message); + expect(second.show).not.toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + } + }); + it('activates the sidebar, replaces the card, and reveals only once per request across both transports', () => { const h = harness({ channel: true }); const m = h.message(); @@ -789,6 +1006,26 @@ describe('dashboard request card', () => { expect(h.box().props.visible).toBe(true); }); + it.each(['flow', 'tab', 'subflow'])( + 'switches the card action from Go to flow to Go to node after a %s request', + kind => { + const h = harness(); + const card = h.box(); + const buttonLabels = () => + card.children.filter(child => child.tag === 'button').map(button => button.textContent); + + h.send(h.message('f1', kind)); + expect(buttonLabels()).toEqual(['Go to flow', '✕']); + + h.send(h.message('n2', 'node', '1730000000000-2')); + expect(h.box()).toBe(card); + expect(buttonLabels()).toEqual(['Go to node', '✕']); + expect(h.text()).toContain('Second'); + h.click('Go to node'); + expect(h.reveal).toHaveBeenLastCalledWith('n2'); + } + ); + it.each(['flow', 'tab', 'subflow'])( 'notifies for an already-active %s on arrival and button click', kind => { diff --git a/test/plugin/auto-refresh.test.ts b/test/plugin/auto-refresh.test.ts index 1da4489..322f45d 100644 --- a/test/plugin/auto-refresh.test.ts +++ b/test/plugin/auto-refresh.test.ts @@ -281,6 +281,10 @@ function jumpHarness(options: { storageOnly?: boolean } = {}) { removeEventListener() {}, }, localStorage: { + get length() { + return stored.size; + }, + key: (index: number) => [...stored.keys()][index] ?? null, setItem: (key: string, value: string) => stored.set(key, value), getItem: (key: string) => stored.get(key), removeItem: (key: string) => stored.delete(key), @@ -322,7 +326,10 @@ function jumpHarness(options: { storageOnly?: boolean } = {}) { return { ctx, opened, toasts, toastOptions, stored, storageEvents, click }; } -function editorHarness(known: Record) { +function editorHarness( + known: Record, + storage = new Map() +) { const revealed: string[] = []; const RED = { nodes: { @@ -334,8 +341,19 @@ function editorHarness(known: Record) { view: { reveal: (id: string) => revealed.push(`node:${id}`) }, workspaces: { show: (id: string) => revealed.push(`flow:${id}`) }, }; - const win: Record = { name: '', focus() {} }; + const win: Record = { + name: '', + focus() {}, + setTimeout, + location: { pathname: '/' }, + }; runInNewContext(editorRevealListenerScript(), { + document: { hasFocus: () => true }, + localStorage: { + getItem: (key: string) => storage.get(key), + setItem: (key: string, value: string) => storage.set(key, value), + removeItem: (key: string) => storage.delete(key), + }, RED, BroadcastChannel: FakeChannel, window: win, @@ -344,6 +362,49 @@ function editorHarness(known: Record) { } describe('dashboard → editor jump links', () => { + it('recovers from a closed focus owner without opening a live editor window', async () => { + vi.useFakeTimers(); + try { + FakeChannel.all = []; + const h = jumpHarness(); + const ed = editorHarness({ n1: 'node' }, h.stored); + h.stored.set( + 'node-red-ai-access:reveal:/:focus', + JSON.stringify({ tabId: 'closed', at: Date.now() }) + ); + h.click('node', 'n1'); + await vi.advanceTimersByTimeAsync(1000); + expect(ed.revealed).toEqual(['node:n1']); + expect(h.opened).toEqual([]); + expect(h.toasts[0]).toContain('Sent to Node-RED editor'); + } finally { + vi.useRealTimers(); + } + }); + + it.each([true, false])( + 'guards the fallback according to live editor presence (%s)', + async live => { + vi.useFakeTimers(); + try { + FakeChannel.all = []; + const h = jumpHarness({ storageOnly: true }); + h.stored.set( + 'node-red-ai-access:reveal:/:presence:other', + String(Date.now() - (live ? 0 : 120001)) + ); + h.click('node', 'n1'); + await vi.advanceTimersByTimeAsync(1000); + expect(h.opened).toHaveLength(live ? 0 : 1); + expect(h.toasts[0]).toContain( + live ? 'Switch to the Node-RED editor tab' : 'Opened Node-RED editor in a new tab' + ); + } finally { + vi.useRealTimers(); + } + } + ); + it('renders #flow/ and #node/ links with escaping and a named target', () => { const h = jumpHarness(); const html = h.ctx.out.link!('node', 'a"b', ''); diff --git a/test/plugin/oauth-persistence.test.ts b/test/plugin/oauth-persistence.test.ts index 1b2ebe4..847b445 100644 --- a/test/plugin/oauth-persistence.test.ts +++ b/test/plugin/oauth-persistence.test.ts @@ -170,6 +170,26 @@ describe('OAuth store persistence', () => { readFileSync(join(resolveAiAccessPaths(userDir).root, 'oauth.json'), 'utf8') ); expect(saved.clients[0].lastUsedAt).toBe(store.data.clients[0]?.lastUsedAt); + // Legacy clients can have valid tokens without a recorded use timestamp. + delete store.data.clients[0]!.lastUsedAt; + store.persist(); + writes.count = 0; + await provider.verifyAccessToken(tokens.access_token); + expect(writes.count).toBe(1); + const firstUsedAt = Date.now(); + const reloaded = new OAuthStore(resolveAiAccessPaths(userDir), () => 20); + expect(reloaded.data.clients[0]?.lastUsedAt).toBe(firstUsedAt); + reloaded.dispose(); + await vi.advanceTimersByTimeAsync(1_000); + await provider.verifyAccessToken(tokens.access_token); + expect(writes.count).toBe(1); + expect(store.data.clients[0]?.lastUsedAt).toBe(Date.now()); + await vi.advanceTimersByTimeAsync(5 * 60_000); + expect(writes.count).toBe(2); + expect( + JSON.parse(readFileSync(join(resolveAiAccessPaths(userDir).root, 'oauth.json'), 'utf8')) + .clients[0].lastUsedAt + ).toBe(store.data.clients[0]?.lastUsedAt); delete store.data.clients[0]!.client_id_issued_at; delete store.data.clients[0]!.lastUsedAt; store.persist(); diff --git a/test/plugin/page-navigation.test.ts b/test/plugin/page-navigation.test.ts index 8d785c7..ad59fe8 100644 --- a/test/plugin/page-navigation.test.ts +++ b/test/plugin/page-navigation.test.ts @@ -49,6 +49,10 @@ describe('settings navigation', () => { expect(nav).toContain(`role="group" aria-labelledby="nav-group-${label}"`); } expect(html.match(/

/g)).toHaveLength(6); + expect(html).toContain('section.panel > h2:focus { outline: none; }'); + expect(html).toContain( + 'section.panel > h2:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }' + ); expect(html).toContain('grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr)'); expect(html).toContain('grid-template-columns: minmax(0, 1fr) minmax(0, 1fr)'); expect(nav).toContain('id="proposals-pending-badge"'); @@ -112,6 +116,70 @@ describe('settings navigation', () => { } ); + it.each(['hash', 'proposalId'])('preserves smooth proposal focus via %s', path => { + const script = pageClientScript(); + const tabs = script.slice( + script.indexOf(' // Hash helpers'), + script.indexOf(' var TTL_PRESET_15M') + ); + const focus = script.slice( + script.indexOf(' function readProposalHashId()'), + script.indexOf(' function bindDiffModeToggle(') + ); + const location = { + hash: path === 'hash' ? '#proposal=deep' : '#status', + pathname: '/ai-access', + search: '', + }; + const scrollTo = vi.fn(); + const scrollIntoView = vi.fn(); + const animationFrames: (() => void)[] = []; + const context = { + location, + history: { + replaceState(_a: unknown, _b: string, url: string) { + location.hash = url.slice(url.indexOf('#')); + }, + }, + document: { + querySelectorAll: () => [], + getElementById: () => ({ + hasAttribute: () => false, + querySelector: () => ({}), + classList: { add: vi.fn() }, + scrollIntoView, + }), + }, + window: { + scrollTo, + requestAnimationFrame(callback: () => void) { + animationFrames.push(callback); + }, + }, + proposalsSearchQuery: '', + cachedProposals: [], + }; + runInNewContext(tabs + focus, context); + runInNewContext( + path === 'hash' + ? 'selectTab(initialTabFromHash(location.hash), { skipHash: true });' + : "selectTab('proposals', { proposalId: 'deep' });", + context + ); + expect(runInNewContext('focusProposalFromHash()', context)).toBe(true); + expect(scrollIntoView).toHaveBeenCalledExactlyOnceWith({ behavior: 'smooth', block: 'start' }); + expect(location.hash).toBe('#proposals'); + expect(animationFrames).toHaveLength(0); + animationFrames.splice(0).forEach(callback => callback()); + expect(scrollTo).not.toHaveBeenCalled(); + + // Returning to the ordinary proposals tab still resets scrolling twice. + runInNewContext("selectTab('proposals')", context); + expect(scrollTo).toHaveBeenCalledExactlyOnceWith(0, 0); + animationFrames.splice(0).forEach(callback => callback()); + expect(scrollTo).toHaveBeenCalledTimes(2); + }); + it('routes clicks, hashes and back/forward through the actual client script', () => { const ids = ['status', 'links', 'settings', 'oauth', 'proposals', 'logs']; const buttons = ids.map(id => ({ @@ -249,12 +317,18 @@ describe('settings navigation', () => { } for (const hash of ['#proposal=first', '#proposal=second']) { location.hash = hash; + scrollTo.mockClear(); + scrollY = 700; const loads = context.loadProposals.mock.calls.length; context.readProposalHashId = () => hash.slice('#proposal='.length); listeners.popstate(); listeners.hashchange(); expect(context.loadProposals).toHaveBeenCalledTimes(loads + 1); expect(focusHeading).toHaveBeenLastCalledWith('proposals', { preventScroll: true }); + expect(scrollTo).not.toHaveBeenCalled(); + expect(animationFrames).toHaveLength(0); + flushAnimationFrames(); + expect(scrollY).toBe(700); } focusHeading.mockClear(); location.hash = '#oauth'; diff --git a/test/plugin/proposal-present.test.ts b/test/plugin/proposal-present.test.ts index b7b07ad..a51c35d 100644 --- a/test/plugin/proposal-present.test.ts +++ b/test/plugin/proposal-present.test.ts @@ -367,6 +367,17 @@ describe('presentProposal', () => { }); describe('presentProposalForApi', () => { + it('exposes landedAfterWithdraw only when true', () => { + const proposal = baseProposal({ status: 'withdrawn', landedAfterWithdraw: true }); + expect(presentProposalForApi(proposal).landedAfterWithdraw).toBe(true); + expect(proposal.landedAfterWithdraw).toBe(true); + + expect(presentProposalForApi(baseProposal())).not.toHaveProperty('landedAfterWithdraw'); + expect(presentProposalForApi(baseProposal({ landedAfterWithdraw: false }))).not.toHaveProperty( + 'landedAfterWithdraw' + ); + }); + it('parses include query and omits bodies by default', () => { expect([...parseIncludeQuery('payload,baseFlow')].sort()).toEqual(['baseFlow', 'payload']); expect(parseIncludeQuery(undefined).size).toBe(0); diff --git a/test/plugin/proposal-rebase-dashboard.test.ts b/test/plugin/proposal-rebase-dashboard.test.ts index 28d47c2..58f580c 100644 --- a/test/plugin/proposal-rebase-dashboard.test.ts +++ b/test/plugin/proposal-rebase-dashboard.test.ts @@ -3,6 +3,8 @@ import { runInNewContext } from 'node:vm'; import { describe, expect, it, vi } from 'vitest'; import { pageClientScript } from '../../src/plugin/page-client.js'; +import { presentProposalForApi } from '../../src/plugin/proposal-present.js'; +import type { Proposal } from '../../src/storage/proposals.js'; // Run the shipped browser code with a minimal DOM and no boot-time requests. function dashboard() { @@ -141,14 +143,31 @@ describe('dashboard auto-rebase review', () => { }); it('badges a withdrawn proposal whose deployment landed later', () => { - const html = dashboard().renderProposalCard({ + const proposal: Proposal = { id: 'p', + type: 'replace_flow', status: 'withdrawn', + expectedRev: 'rev-1', + createdBy: { kind: 'access-link', linkId: 'link-1' }, + createdAt: '2026-10-16T01:35:00.000Z', + updatedAt: '2026-10-16T01:35:00.000Z', landedAfterWithdraw: true, - payload: { flowId: 'tab', flow: {} }, - }); - expect(html).toContain('Landed after withdraw'); - expect(html).toContain('status-pill'); + payload: { flowId: 'tab', flow: { id: 'tab', type: 'tab', nodes: [] } }, + }; + const ui = dashboard(); + const html = ui.renderProposalCard(presentProposalForApi(proposal)); + expect(html).toContain( + 'Landed after withdraw' + ); + expect(html).toContain('withdrawn'); + for (const landedAfterWithdraw of [false, undefined]) { + const unflagged = { ...proposal }; + delete unflagged.landedAfterWithdraw; + if (landedAfterWithdraw !== undefined) unflagged.landedAfterWithdraw = landedAfterWithdraw; + expect(ui.renderProposalCard(presentProposalForApi(unflagged))).not.toContain( + 'status-pill landed-after-withdraw' + ); + } }); it('renders persisted late deployment alerts', () => {