diff --git a/docs/architecture.md b/docs/architecture.md index 2d34f20..e212590 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -141,6 +141,12 @@ for transaction-detail consumers. Captured workspace operations also check the session token, so callbacks cannot borrow a later reopening of the same ID. App supplies transient network admission separately from session validity: cached transaction reads and saved spending links work without live admission. +Graph lookup reports completion through its existing `submit` intention rather +than requiring callers to infer success from loaded evidence. Workspace opens +Graph after completion, capturing the originating workbench for a contextual +return. The global linked-lookup banner clears only the reference that completed; +rejected, unavailable, cancelled and failed lookups leave it pending. Operation +admission and background preemption remain with `useWorkspaceOperation`. Graph ancestry can reveal only saved parents while offline, without trying missing transactions. Inspector navigation checks saved parents and exact spend links across all saved transactions, independently of canvas membership; live diff --git a/docs/user-guide.md b/docs/user-guide.md index ab3b2d4..fdc0a6b 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -173,6 +173,12 @@ immediately, even offline. The **Previous** selector beside the input loads one or two levels of earlier transactions along with the lookup; it defaults to off and both levels share a 500-transaction budget. +Links ending in `#?s=txid`, `#?s=txid:index` or `#?s=address` show a linked-lookup +banner and clear the reference from the address bar. Open or unlock a workspace, +then click the reference to add it and open Graph. The active workspace chooses +the network. If the lookup cannot complete, the banner stays so you can retry in +that workspace or switch to another. Use its dismiss button to discard the link. + With a transaction selected, **Load previous txs** adds one earlier level. For an output, **Open creating tx** loads only the transaction that created it, and **Find spending txs** looks for transactions that spent it (or any output of a diff --git a/src/App/App.tsx b/src/App/App.tsx index 787bf9b..ac7843a 100644 --- a/src/App/App.tsx +++ b/src/App/App.tsx @@ -4,6 +4,7 @@ import { TransactionFetchProvider } from './Workspace/TransactionFetchProvider'; import { ExamplesDialog } from './Examples/ExamplesDialog'; import { BookOpen, FolderOpen, Info, Library, Plus } from 'lucide-react'; import { ToastNotification } from './Controls/ToastNotification'; +import { DeepLinkBanner } from './DeepLinkBanner'; import { Modal } from './Dialogs'; import { CreateWorkspaceDialog } from './Workspace/Dialogs/CreateWorkspaceDialog'; import { ImportWorkspaceDialog } from './Workspace/Dialogs/ImportWorkspaceDialog'; @@ -231,6 +232,7 @@ export default function App() { )} + ; + workspace: WorkspaceController; +} + +function compactReference(ref: string) { + return ref.length > 24 ? `${ref.slice(0, 10)}…${ref.slice(-8)}` : ref; +} + +function isAddressForNetwork(ref: string, network: Network) { + try { + addressToScriptHash(ref, network); + return true; + } catch { + return false; + } +} + +/** + * Shows a lookup reference carried by a #?s= URL fragment until it is added to + * a graph or dismissed. The fragment itself is consumed on arrival; this + * banner is the only remaining holder of the reference. + */ +export function DeepLinkBanner({ app, workspace }: DeepLinkBannerProps) { + const ref = app.deepLinkRef; + if (!ref) return null; + const addToGraph = async () => { + if (!app.activeWorkspace) { + app.setNotice( + 'Open or create a workspace first, then add the linked reference from the banner.', + ); + return; + } + const valid = + /^[0-9a-f]{64}(:\d+)?$/i.test(ref) || isAddressForNetwork(ref, app.activeWorkspace.network); + if (!valid) { + app.setNotice('The link does not contain a valid transaction ID, output, or address.'); + return; + } + if (await workspace.graph.lookup.submit(ref)) + app.setDeepLinkRef((pending) => (pending === ref ? undefined : pending)); + }; + return ( +
+ Linked lookup + + +
+ ); +} diff --git a/src/App/Workspace/Workbenches/Graph/Navigation/GraphLookupForm.tsx b/src/App/Workspace/Workbenches/Graph/Navigation/GraphLookupForm.tsx index 3233b2b..f8625ba 100644 --- a/src/App/Workspace/Workbenches/Graph/Navigation/GraphLookupForm.tsx +++ b/src/App/Workspace/Workbenches/Graph/Navigation/GraphLookupForm.tsx @@ -14,7 +14,7 @@ export interface GraphLookupFormProps { onQueryError: (message: string) => void; /** Resolves text that is already present in the workspace, so offline lookups stay enabled. */ resolveLoaded: (text: string) => string | undefined; - onSubmit: (text: string) => void | Promise; + onSubmit: (text: string) => Promise; } /** diff --git a/src/App/Workspace/Workbenches/Graph/Navigation/useGraphLookup.test.tsx b/src/App/Workspace/Workbenches/Graph/Navigation/useGraphLookup.test.tsx index abaa8f7..ca508ee 100644 --- a/src/App/Workspace/Workbenches/Graph/Navigation/useGraphLookup.test.tsx +++ b/src/App/Workspace/Workbenches/Graph/Navigation/useGraphLookup.test.tsx @@ -81,10 +81,12 @@ it.each([ ); }); expect(f.core.setOperation).not.toHaveBeenCalled(); + let completed = false; await act(async () => { - await f.result.current.lookup.submit(text); + completed = await f.result.current.lookup.submit(text); await background; }); + expect(completed).toBe(true); expect(backgroundSignal.aborted).toBe(true); expect(f.reveal).toHaveBeenCalledWith(expected); expect(f.result.current.state.resetToken).toBe(1); @@ -111,7 +113,9 @@ it('keeps a cached lookup from changing selection during another foreground oper }); }); }); - await act(() => f.result.current.lookup.submit(TX_FUNDING)); + await act(async () => { + expect(await f.result.current.lookup.submit(TX_FUNDING)).toBe(false); + }); expect(f.reveal).not.toHaveBeenCalled(); expect(f.observe).not.toHaveBeenCalled(); expect(f.result.current.state.resetToken).toBe(0); @@ -121,7 +125,9 @@ it('keeps a cached lookup from changing selection during another foreground oper it('still opens loaded evidence while live lookups are unavailable', async () => { const f = fixture(true, false); - await act(() => f.result.current.lookup.submit(TX_FUNDING)); + await act(async () => { + expect(await f.result.current.lookup.submit(TX_FUNDING)).toBe(true); + }); expect(f.reveal).toHaveBeenCalledWith(`tx:${TX_FUNDING}`); expect(f.read).not.toHaveBeenCalled(); }); @@ -140,13 +146,13 @@ it('drops a lookup if selection changes while automatic work is yielding', async { background: true }, ); }); - let lookup!: Promise; + let lookup!: Promise; await act(async () => { lookup = f.result.current.lookup.submit(TX_FUNDING); f.selection.generation.current++; release(); await background; - await lookup; + expect(await lookup).toBe(false); }); expect(f.reveal).not.toHaveBeenCalled(); expect(f.observe).not.toHaveBeenCalled(); diff --git a/src/App/Workspace/Workbenches/Graph/Navigation/useGraphLookup.ts b/src/App/Workspace/Workbenches/Graph/Navigation/useGraphLookup.ts index 70e8654..9a84700 100644 --- a/src/App/Workspace/Workbenches/Graph/Navigation/useGraphLookup.ts +++ b/src/App/Workspace/Workbenches/Graph/Navigation/useGraphLookup.ts @@ -44,11 +44,13 @@ export function useGraphLookup({ const { transactions: recordTransactions } = transactions.observe; const { run } = operation; - async function submit(text: string) { - if (!activeWorkspace || !text.trim()) return; + /** True only after an admitted lookup completes; failures use the operation's feedback. */ + async function submit(text: string): Promise { + if (!activeWorkspace || !text.trim()) return false; text = text.trim(); if (/^(bc1|tb1)/i.test(text)) text = text.toLowerCase(); const submittedGeneration = selectionGeneration.current; + let completed = false; await run(async (signal) => { if ( activeWorkspaceRef.current?.id !== activeWorkspace.id || @@ -56,22 +58,26 @@ export function useGraphLookup({ ) return; const existing = state.resolveLoaded(text); + if (!canLoadChainData && !existing) + throw new Error( + 'Cannot add this reference: it is not loaded in this workspace and live lookup is unavailable.', + ); if (existing && !existing.startsWith('addr:')) { recordTransactions(activeWorkspace.id, [], { promotionIds: [existing.split(':')[1]] }); reveal(existing); if (!canLoadChainData || !prefetchDepth) { state.clear(); + completed = true; return; } } if (!/^[0-9a-f]{64}(:\d+)?$/i.test(text)) { - if (!canLoadChainData && !existing) return; reveal(addressReference(text)); state.clear(); if (canLoadChainData) addressEvidence.startAddressHistoryLoad(text); + completed = true; return; } - if (!canLoadChainData) return; const generation = selectionGeneration.current; const [id, index] = text.split(':'); setOperation('Loading transaction…'); @@ -118,7 +124,9 @@ export function useGraphLookup({ setNotice(ancestryNotice(result)); } state.clear(); + completed = true; }); + return completed; } return { submit }; diff --git a/src/App/Workspace/useWorkspace.tsx b/src/App/Workspace/useWorkspace.tsx index 9513532..6540be3 100644 --- a/src/App/Workspace/useWorkspace.tsx +++ b/src/App/Workspace/useWorkspace.tsx @@ -624,6 +624,13 @@ export function useWorkspace(app: ReturnType) { return (next: WorkbenchMode, options?: WorkbenchSwitchOptions) => changeWorkbench(next, options, capturedOrigin); } + async function submitGraphLookup(text: string) { + const showGraph = workbenchActionSwitch(workbench); + const completed = await graphLookup.submit(text); + if (completed) + showGraph('graph', { interaction: workbench === 'graph' ? 'switch' : 'handoff' }); + return completed; + } // The factory only creates event handlers; refs are read when an action runs, not during render. // oxlint-disable-next-line react/refs // What Graph publishes to the other workbenches, assembled once. @@ -714,7 +721,7 @@ export function useWorkspace(app: ReturnType) { actions: graphActions, address: graphAddress, navigation: graphExpansion, - lookup: { ...lookup, submit: graphLookup.submit }, + lookup: { ...lookup, submit: submitGraphLookup }, flowInputs, scanTargets: connectionScanTargets, }, diff --git a/src/App/component-styles.ts b/src/App/component-styles.ts index 6b53fb4..3ea8263 100644 --- a/src/App/component-styles.ts +++ b/src/App/component-styles.ts @@ -24,3 +24,4 @@ import './Workspace/Workbenches/Wallet/wallet-workbench.css'; import './Controls/multi-select-filter.css'; import './Workspace/Workbenches/workbenches.css'; import './Workspace/Workbenches/Graph/EntitiesPanel/entity-browser.css'; +import './deeplink-banner.css'; diff --git a/src/App/deepLink.test.ts b/src/App/deepLink.test.ts new file mode 100644 index 0000000..5a1309b --- /dev/null +++ b/src/App/deepLink.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest'; +import { parseLookupFragment } from './deepLink'; + +const txid = 'a'.repeat(64); +// Public BIP173 test vector. +const address = 'bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4'; + +describe('parseLookupFragment', () => { + it('reads a transaction id from #?s=', () => { + expect(parseLookupFragment(`#?s=${txid}`)).toBe(txid); + }); + + it('reads an outpoint from #?s=', () => { + expect(parseLookupFragment(`#?s=${txid}:0`)).toBe(`${txid}:0`); + }); + + it('reads an address from #?s=', () => { + expect(parseLookupFragment(`#?s=${address}`)).toBe(address); + }); + + it('keeps other parameters and returns the first s value', () => { + expect(parseLookupFragment(`#?s=${txid}&x=1`)).toBe(txid); + expect(parseLookupFragment(`#?s=${txid}&s=${'b'.repeat(64)}`)).toBe(txid); + }); + + it('trims surrounding whitespace', () => { + expect(parseLookupFragment(`#?s=%20${txid}%20`)).toBe(txid); + }); + + it('ignores in-app anchors and non-lookup fragments', () => { + expect(parseLookupFragment('#main-workspace')).toBeUndefined(); + expect(parseLookupFragment('#graph-workspace')).toBeUndefined(); + expect(parseLookupFragment('#')).toBeUndefined(); + expect(parseLookupFragment('')).toBeUndefined(); + expect(parseLookupFragment('#?foo=bar')).toBeUndefined(); + expect(parseLookupFragment('#?s=')).toBeUndefined(); + expect(parseLookupFragment('#?s=%20%09')).toBeUndefined(); + }); +}); diff --git a/src/App/deepLink.ts b/src/App/deepLink.ts new file mode 100644 index 0000000..63fe388 --- /dev/null +++ b/src/App/deepLink.ts @@ -0,0 +1,9 @@ +/** Reads a lookup reference from a #?s= fragment, ignoring in-app hash anchors. */ +export function parseLookupFragment(hash: string): string | undefined { + if (!hash.startsWith('#?')) return undefined; + const params = new URLSearchParams(hash.slice(2)); + const value = params.get('s'); + if (value === null) return undefined; + const trimmed = value.trim(); + return trimmed ? trimmed : undefined; +} diff --git a/src/App/deeplink-banner.css b/src/App/deeplink-banner.css new file mode 100644 index 0000000..5754fb4 --- /dev/null +++ b/src/App/deeplink-banner.css @@ -0,0 +1,34 @@ +/* Global banner for #?s= deep-link references; the toast stays the feedback surface. */ +.deep-link-banner { + position: fixed; + top: 68px; + right: 16px; + z-index: 60; + display: flex; + align-items: center; + gap: 8px; + max-width: min(420px, calc(100vw - 32px)); + padding: 6px 8px; + background: var(--color-raised); + border: 1px solid var(--color-rule); + border-radius: var(--radius-sm); + font-size: var(--text-xs); +} +.deep-link-banner-label { + color: var(--color-muted); + white-space: nowrap; +} +.deep-link-banner-ref { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + min-height: 28px; + padding: 4px 8px; + color: var(--color-accent); + background: transparent; + border-color: transparent; +} +.deep-link-banner-ref:hover { + background: var(--color-hover); + border-color: var(--color-faint); +} diff --git a/src/App/useAppState.ts b/src/App/useAppState.ts index cae3edf..200210f 100644 --- a/src/App/useAppState.ts +++ b/src/App/useAppState.ts @@ -6,6 +6,7 @@ import { DEMO_MODE } from '../buildMode'; import { useWorkspaces } from './Workspace/useWorkspaces'; import type { SavedWorkspace } from '../Core/Workspace/Persistence'; import { appServices } from './appServices'; +import { parseLookupFragment } from './deepLink'; import { createFeedback, type Feedback, @@ -68,6 +69,20 @@ export function useAppState() { const [demoStartOpen, setDemoStartOpen] = useState(false); const startWorkspace = () => (DEMO_MODE ? setDemoStartOpen(true) : setCreate('empty')); const exampleNetworks = DEMO_MODE ? ['mainnet' as const] : discoveryError ? undefined : networks; + const [deepLinkRef, setDeepLinkRef] = useState(); + // A #?s= fragment is external navigation intent: consume it once and clear it + // from the address bar so the reference never re-enters history or logs. + useEffect(() => { + const consume = () => { + const ref = parseLookupFragment(window.location.hash); + if (ref === undefined) return; + window.history.replaceState(null, '', `${window.location.pathname}${window.location.search}`); + setDeepLinkRef(ref); + }; + consume(); + window.addEventListener('hashchange', consume); + return () => window.removeEventListener('hashchange', consume); + }, []); const [notice, setNoticeValue] = useState(); const [noticeSequence, setNoticeSequence] = useState(0); const setNotice = useCallback((feedback, options) => { @@ -184,6 +199,8 @@ export function useAppState() { setDeleteEntry, examplesOpen, setExamplesOpen, + deepLinkRef, + setDeepLinkRef, notice, setNotice, noticeSequence, diff --git a/tests/e2e/deeplink.spec.ts b/tests/e2e/deeplink.spec.ts new file mode 100644 index 0000000..d7aeda4 --- /dev/null +++ b/tests/e2e/deeplink.spec.ts @@ -0,0 +1,78 @@ +import { expect, test, type Page } from '@playwright/test'; +import { buildGraph } from '../../src/App/Workspace/GraphState/graphEvidence'; +import { createWorkspace } from '../../src/Core/Workspace/createWorkspace'; +import { createWorkspacePersistence } from '../../src/Core/Workspace/Persistence'; +import type { Workspace } from '../../src/Core/Workspace/workspace'; + +const linkedTxid = 'c'.repeat(64); +const password = 'deeplink-test-password'; + +function deeplinkWorkspace(): Workspace { + const w = createWorkspace('Deeplink fixture', 'testnet4'); + w.demo = false; + w.chainData.transactions[linkedTxid] = { + txid: linkedTxid, + vin: [{ coinbase: '00' }], + vout: [{ n: 0, value: 1, scriptPubKey: { type: 'witness_v0_keyhash' } }], + status: { kind: 'confirmed' as const, confirmations: 3 }, + }; + w.view.graphNodeIds = buildGraph(w).nodes.map((node) => node.id); + return w; +} + +async function seedSavedWorkspace(page: Page, workspace: Workspace) { + const exported = await createWorkspacePersistence().exportFile(workspace, password); + const envelope: unknown = JSON.parse(exported.contents); + await page.addInitScript( + ({ id, name, envelope }) => { + localStorage.setItem('chaingraph.tour.seen', '1'); + if (localStorage.getItem('chaingraph.encrypted-workspaces.v1') === null) + localStorage.setItem( + 'chaingraph.encrypted-workspaces.v1', + JSON.stringify([{ id, publicName: name, savedAt: new Date().toISOString(), envelope }]), + ); + }, + { id: workspace.id, name: workspace.name, envelope }, + ); +} + +async function unlockSeededWorkspace(page: Page, name: string) { + await page.locator('.saved-row').filter({ hasText: name }).click(); + const dialog = page.getByRole('dialog', { name: 'Unlock workspace' }); + await dialog.getByLabel('Password', { exact: true }).fill(password); + await dialog.getByRole('button', { name: 'Unlock workspace', exact: true }).click(); + await expect(page.getByTestId('graph-view')).toBeVisible(); +} + +test('a fragment reference adds to a workspace after unlock and clears the URL', async ({ + page, +}) => { + await seedSavedWorkspace(page, deeplinkWorkspace()); + await page.goto(`/#?s=${linkedTxid}`); + const banner = page.getByTestId('deep-link-banner'); + await expect(banner).toBeVisible(); + await expect(page).not.toHaveURL(/#/); + await unlockSeededWorkspace(page, 'Deeplink fixture'); + await banner.getByTestId('deep-link-banner-ref').click(); + await expect(banner).toHaveCount(0); + await expect(page.locator('.toast.error')).toHaveCount(0); +}); + +test('a fragment reference on the home screen asks for a workspace and stays', async ({ page }) => { + await seedSavedWorkspace(page, deeplinkWorkspace()); + await page.goto(`/#?s=${linkedTxid}`); + const banner = page.getByTestId('deep-link-banner'); + await expect(banner).toBeVisible(); + await banner.getByTestId('deep-link-banner-ref').click(); + await expect(page.locator('.toast').filter({ hasText: 'workspace' })).toBeVisible(); + await expect(banner).toBeVisible(); +}); + +test('a fragment reference can be dismissed without adding', async ({ page }) => { + await seedSavedWorkspace(page, deeplinkWorkspace()); + await page.goto(`/#?s=${linkedTxid}`); + const banner = page.getByTestId('deep-link-banner'); + await expect(banner).toBeVisible(); + await banner.getByRole('button', { name: 'Dismiss linked lookup' }).click(); + await expect(banner).toHaveCount(0); +}); diff --git a/tests/integration/workspace/deepLinkBanner.test.tsx b/tests/integration/workspace/deepLinkBanner.test.tsx new file mode 100644 index 0000000..ae6bcbc --- /dev/null +++ b/tests/integration/workspace/deepLinkBanner.test.tsx @@ -0,0 +1,411 @@ +// @vitest-environment jsdom +import { useLayoutEffect } from 'react'; +import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, expect, it, vi } from 'vitest'; +import { DeepLinkBanner } from '../../../src/App/DeepLinkBanner'; +import { ToastNotification } from '../../../src/App/Controls/ToastNotification'; +import { useAppState } from '../../../src/App/useAppState'; +import { useWorkspace } from '../../../src/App/Workspace/useWorkspace'; +import { appServices } from '../../../src/App/appServices'; +import { createWorkspace } from '../../../src/Core/Workspace/createWorkspace'; +import { parseVerboseTransaction } from '../../../src/Core/ChainData/verboseTransaction'; +import { + addressReference, + outpointReference, + transactionReference, +} from '../../../src/Core/Workspace/entityReferences'; +import type { WorkbenchMode } from '../../../src/App/Workspace/workbenchTypes'; +import { + PUBLIC_ZPUB, + RECEIVE_ADDRESS, + TX_FUNDING, + TX_SPENDING, + transactions, +} from '../../fixtures/bitcoin'; +import { installDomStubs } from '../../domStubs'; + +installDomStubs(); +const policy = vi.hoisted(() => ({ connected: true, demo: false })); +vi.mock('../../../src/buildMode', () => ({ + get DEMO_MODE() { + return policy.demo; + }, +})); +vi.mock('../../../src/App/useBackendNetworks', () => ({ + useBackendNetworks: () => ({ + networks: ['mainnet', 'testnet4'], + statuses: { + mainnet: { network: 'mainnet', connected: policy.connected }, + testnet4: { network: 'testnet4', connected: policy.connected }, + }, + discoveryError: '', + }), +})); +vi.mock('../../../src/App/appServices', async () => { + const { WorkspaceStore } = await import('../../../src/Core/Workspace/Session/WorkspaceStore'); + const persistence = { + initialError: '', + list: () => [], + assertCurrent: () => {}, + save: vi.fn(async () => []), + load: vi.fn(), + remove: vi.fn(), + readFile: vi.fn(), + exportFile: vi.fn(), + }; + return { appServices: { persistence, workspaceStore: new WorkspaceStore(persistence) } }; +}); + +let current: { app: ReturnType; workspace: ReturnType }; +function Harness() { + const app = useAppState(); + const workspace = useWorkspace(app); + useLayoutEffect(() => { + current = { app, workspace }; + }); + return ( + <> + + + + ); +} + +function response(result: unknown) { + return new Response(JSON.stringify({ result }), { + headers: { 'Content-Type': 'application/json' }, + }); +} +function rpcCall(init?: RequestInit) { + return JSON.parse(String(init?.body)) as { method: string; params: unknown[] }; +} + +beforeEach(() => { + policy.connected = true; + policy.demo = false; + vi.stubEnv('SSR', true); + window.history.replaceState(null, '', '/'); + localStorage.setItem('chaingraph.tour.seen', '1'); + vi.stubGlobal( + 'fetch', + vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => { + const call = rpcCall(init); + if (call.method === 'getrawtransaction') + return response(transactions[call.params[0] as keyof typeof transactions]); + if ( + call.method === 'blockchain.scripthash.get_history' || + call.method === 'blockchain.scripthash.listunspent' + ) + return response([]); + if (call.method === 'blockchain.scripthash.get_balance') + return response({ confirmed: 0, unconfirmed: 0 }); + throw new Error('Unexpected synthetic RPC request'); + }), + ); +}); +afterEach(async () => { + cleanup(); + for (const session of appServices.workspaceStore.getSnapshot().unlocked) await session.lock(); + vi.unstubAllGlobals(); + vi.unstubAllEnvs(); + vi.restoreAllMocks(); + localStorage.clear(); +}); + +function setup({ + cached = false, + workbench = 'graph', + wallet = false, +}: { + cached?: boolean; + workbench?: WorkbenchMode; + wallet?: boolean; +} = {}) { + const data = createWorkspace('Linked lookup fixture', 'mainnet'); + data.view.workbench = workbench; + if (cached) + data.chainData.transactions[TX_FUNDING] = parseVerboseTransaction(transactions[TX_FUNDING]); + if (wallet || workbench === 'wallet') + data.wallets.definitions = [ + { + id: '30000000-0000-4000-8000-000000000001', + name: 'Public vector wallet', + key: PUBLIC_ZPUB, + scriptType: 'p2wpkh', + color: '#27c4a7', + addresses: [], + scannedAt: '2026-10-01T10:00:00.000Z', + scanComplete: true, + }, + ]; + appServices.workspaceStore.open(data, 'public test password'); + const view = render(); + return { ...view, id: data.id }; +} +function receive(ref: string) { + act(() => { + window.history.pushState(null, '', `/#?s=${ref}`); + window.dispatchEvent(new HashChangeEvent('hashchange')); + }); + expect(window.location.hash).toBe(''); +} +async function clickReference() { + await act(async () => { + fireEvent.click(screen.getByTestId('deep-link-banner-ref')); + }); +} +function expectSelection(id: string, selected: string) { + expect(current.workspace.selection.selectedId).toBe(selected); + expect(appServices.workspaceStore.getUnlocked(id)!.data.view.graphNodeIds).toContain(selected); +} +function delayTransaction() { + let release!: (value: Response) => void; + const pending = new Promise((resolve) => { + release = resolve; + }); + const normalFetch = vi.mocked(fetch).getMockImplementation()!; + vi.mocked(fetch).mockImplementation((input, init) => + rpcCall(init).method === 'getrawtransaction' ? pending : normalFetch(input, init), + ); + return () => release(response(transactions[TX_FUNDING])); +} + +const references = [ + { kind: 'transaction', ref: TX_FUNDING, selected: transactionReference(TX_FUNDING) }, + { kind: 'output', ref: `${TX_FUNDING}:0`, selected: outpointReference(TX_FUNDING, 0) }, + { + kind: 'address', + ref: RECEIVE_ADDRESS.toUpperCase(), + selected: addressReference(RECEIVE_ADDRESS), + }, +]; + +it.each(references)( + 'dismisses after adding a new $kind through the lookup owner', + async ({ ref, selected }) => { + const view = setup(); + receive(ref); + await clickReference(); + expectSelection(view.id, selected); + expect(screen.queryByTestId('deep-link-banner')).toBeNull(); + if (!selected.startsWith('addr:')) + expect( + appServices.workspaceStore.getUnlocked(view.id)!.data.chainData.transactions[TX_FUNDING], + ).toBeDefined(); + }, +); + +it.each([false, true])('opens loaded references without live lookup (demo=%s)', async (demo) => { + policy.demo = demo; + policy.connected = false; + const view = setup({ cached: true }); + for (const { ref, selected } of references) { + receive(ref); + await clickReference(); + expectSelection(view.id, selected); + expect(screen.queryByTestId('deep-link-banner')).toBeNull(); + } + expect(fetch).not.toHaveBeenCalled(); +}); + +it.each(references)( + 'reports an unavailable $kind and allows retry after reconnection', + async ({ ref, selected }) => { + policy.connected = false; + const view = setup(); + receive(ref); + await clickReference(); + expect(screen.getByRole('alert').textContent).toBeTruthy(); + expect(screen.queryByTestId('deep-link-banner')).not.toBeNull(); + expect(current.workspace.selection.selectedId).toBeUndefined(); + expect(fetch).not.toHaveBeenCalled(); + policy.connected = true; + view.rerender(); + await clickReference(); + expectSelection(view.id, selected); + expect(screen.queryByTestId('deep-link-banner')).toBeNull(); + }, +); + +it('keeps a cached reference when foreground work declines the lookup, then retries', async () => { + const view = setup({ cached: true }); + receive(TX_FUNDING); + let release!: () => void; + let foreground!: Promise; + await act(async () => { + foreground = current.workspace.operation.run(async () => { + await new Promise((resolve) => { + release = resolve; + }); + }); + }); + try { + await clickReference(); + expect(current.workspace.selection.selectedId).toBeUndefined(); + expect(screen.queryByTestId('deep-link-banner')).not.toBeNull(); + } finally { + release(); + await act(() => foreground); + } + await clickReference(); + expectSelection(view.id, transactionReference(TX_FUNDING)); + expect(screen.queryByTestId('deep-link-banner')).toBeNull(); +}); + +it('preempts the default automatic wallet refresh and completes the linked lookup', async () => { + const normalFetch = vi.mocked(fetch).getMockImplementation()!; + let aborted = 0; + vi.mocked(fetch).mockImplementation((input, init) => { + if (rpcCall(init).method !== 'blockchain.scripthash.get_history') + return normalFetch(input, init); + return new Promise((_resolve, reject) => { + init!.signal!.addEventListener( + 'abort', + () => { + aborted++; + reject(new DOMException('Synthetic refresh aborted', 'AbortError')); + }, + { once: true }, + ); + }); + }); + const view = setup({ wallet: true }); + await waitFor(() => expect(current.workspace.wallet.discovery.refresh.loading).toBe(true)); + await waitFor(() => + expect( + vi + .mocked(fetch) + .mock.calls.some( + ([, init]) => rpcCall(init).method === 'blockchain.scripthash.get_history', + ), + ).toBe(true), + ); + expect(current.workspace.wallet.discovery.monitorActivity).toBe(true); + receive(TX_FUNDING); + await clickReference(); + expect(aborted).toBeGreaterThan(0); + expectSelection(view.id, transactionReference(TX_FUNDING)); + expect(screen.queryByTestId('deep-link-banner')).toBeNull(); +}); + +it('retains a newer fragment after an earlier network lookup completes', async () => { + const release = delayTransaction(); + const view = setup(); + receive(TX_FUNDING); + await clickReference(); + await waitFor(() => expect(fetch).toHaveBeenCalled()); + receive(TX_SPENDING); + await act(async () => { + release(); + }); + await waitFor(() => expectSelection(view.id, transactionReference(TX_FUNDING))); + expect(current.app.deepLinkRef).toBe(TX_SPENDING); + expect(screen.queryByTestId('deep-link-banner')).not.toBeNull(); +}); + +it.each(['cancel', 'switch workspace'] as const)( + 'retains the reference after a pending lookup is interrupted: %s', + async (action) => { + const release = delayTransaction(); + const view = setup(); + receive(TX_FUNDING); + await clickReference(); + await waitFor(() => expect(fetch).toHaveBeenCalled()); + act(() => { + if (action === 'cancel') current.workspace.operation.cancel(); + else + appServices.workspaceStore.open( + createWorkspace('Other workspace', 'testnet4'), + 'public test password', + ); + }); + await act(async () => { + release(); + }); + await waitFor(() => expect(current.workspace.operation.isActive()).toBe(false)); + expect( + appServices.workspaceStore.getUnlocked(view.id)!.data.chainData.transactions[TX_FUNDING], + ).toBeUndefined(); + expect(current.app.deepLinkRef).toBe(TX_FUNDING); + expect(screen.queryByTestId('deep-link-banner')).not.toBeNull(); + }, +); + +it('retains a failed lookup and completes a subsequent retry', async () => { + const view = setup(); + receive(TX_FUNDING); + const normalFetch = vi.mocked(fetch).getMockImplementation()!; + vi.mocked(fetch).mockRejectedValue(new Error('Synthetic unavailable backend')); + await clickReference(); + expect(screen.getByRole('alert').textContent).toBeTruthy(); + expect(screen.queryByTestId('deep-link-banner')).not.toBeNull(); + expect(current.workspace.selection.selectedId).toBeUndefined(); + vi.mocked(fetch).mockImplementation(normalFetch); + await clickReference(); + expectSelection(view.id, transactionReference(TX_FUNDING)); + expect(screen.queryByTestId('deep-link-banner')).toBeNull(); +}); + +it.each( + (['wallet', 'analysis'] as const).flatMap((workbench) => + [false, true].map((cached) => ({ workbench, cached })), + ), +)( + 'opens Graph after a successful lookup from $workbench (cached=$cached) and retains the return route', + async ({ workbench, cached }) => { + policy.connected = !cached; + const view = setup({ cached, workbench }); + expect(current.workspace.workbench).toBe(workbench); + receive(TX_FUNDING); + await clickReference(); + expectSelection(view.id, transactionReference(TX_FUNDING)); + expect(current.workspace.workbench).toBe('graph'); + expect(current.workspace.returnWorkbench).toBe(workbench); + expect(current.workspace.workbenchEntry?.target).toBe('stage'); + expect(appServices.workspaceStore.getUnlocked(view.id)!.data.view.workbench).toBe('graph'); + expect(screen.queryByTestId('deep-link-banner')).toBeNull(); + }, +); + +it('completes ancestor loading before opening Graph from Analysis', async () => { + const view = setup({ workbench: 'analysis' }); + act(() => current.workspace.setPrefetchDepth(1)); + receive(TX_SPENDING); + await clickReference(); + expectSelection(view.id, transactionReference(TX_SPENDING)); + expect( + appServices.workspaceStore.getUnlocked(view.id)!.data.chainData.transactions[TX_FUNDING], + ).toBeDefined(); + expect(current.workspace.workbench).toBe('graph'); + expect(current.workspace.returnWorkbench).toBe('analysis'); + expect(screen.queryByTestId('deep-link-banner')).toBeNull(); +}); + +it('stays in Analysis when the linked lookup cannot complete', async () => { + policy.connected = false; + setup({ workbench: 'analysis' }); + receive(TX_FUNDING); + await clickReference(); + expect(current.workspace.workbench).toBe('analysis'); + expect(screen.queryByTestId('deep-link-banner')).not.toBeNull(); + expect(screen.getByRole('alert').textContent).toBeTruthy(); +}); + +it('consumes the startup fragment, preserves anchors and can dismiss without an unlocked workspace', async () => { + window.history.replaceState(null, '', `/?keep=1#?s=${TX_FUNDING}`); + render(); + expect(window.location.hash).toBe(''); + expect(window.location.search).toBe('?keep=1'); + expect(current.app.deepLinkRef).toBe(TX_FUNDING); + await clickReference(); + expect(current.app.notice).toBeDefined(); + expect(screen.queryByTestId('deep-link-banner')).not.toBeNull(); + act(() => { + window.history.pushState(null, '', '/#main-workspace'); + window.dispatchEvent(new HashChangeEvent('hashchange')); + }); + expect(window.location.hash).toBe('#main-workspace'); + expect(current.app.deepLinkRef).toBe(TX_FUNDING); + fireEvent.click(screen.getByRole('button', { name: /dismiss linked lookup/i })); + expect(screen.queryByTestId('deep-link-banner')).toBeNull(); +});