diff --git a/applications/viewer/README.md b/applications/viewer/README.md index 282c98e..ba3cb60 100644 --- a/applications/viewer/README.md +++ b/applications/viewer/README.md @@ -11,7 +11,10 @@ A live Cardano explorer for your Yaci DevKit devnet, or any [Yaci Store](https:/ - **Script Lab:** in your browser, with the [julc](https://github.com/bloxbean/julc) WebAssembly engine: - decode any Plutus script into searchable UPLC, see the builtins it uses and verify its hash; - re-run every script of a transaction with the cost model of its epoch, compare the budget with what was declared - on chain, and see the exact ScriptContext the validator received. + on chain, and see the exact ScriptContext the validator received; + - step through any run in a time-travel debugger: step, over, out, continue and back, breakpoints on lines, + builtins, traces and failures, environment, frames and budget per step, and links to an exact step + (`/transactions//debug//?step=N`). - Addresses, stake accounts, assets, epochs and protocol parameters, pools, rewards, certificates, Ada pots and Conway governance. - Light and dark themes; works on a phone. @@ -71,7 +74,7 @@ into `static/julc/` (git-ignored). The engine is only downloaded by the browser release's `julc-wasm--SHA256SUMS.txt`. - The engine needs WebAssembly GC and exception handling (exnref): current Chrome, Edge, Firefox and Safari. Other browsers get a message instead. Playwright's bundled Chromium is too old, so run the Script Lab test with Chrome: - `SCRIPT_LAB_HASH= SCRIPT_LAB_TX= PW_CHANNEL=chrome npx playwright test script-lab`. + `SCRIPT_LAB_HASH= SCRIPT_LAB_TX= SCRIPT_LAB_RUN= PW_CHANNEL=chrome npx playwright test script-lab`. - Re-runs rebuild the transaction from Yaci Store data (`src/lib/julc/replay/`). Until julc builds contexts exactly as the ledger does ([julc#244](https://github.com/bloxbean/julc/issues/244)), the viewer corrects julc's context (asset order, the other redeemers, datums given by hash, Plutus V1 shapes) and runs the script again; each result diff --git a/applications/viewer/src/lib/julc/CodeView.svelte b/applications/viewer/src/lib/julc/CodeView.svelte index 233bfcd..a9a3682 100644 --- a/applications/viewer/src/lib/julc/CodeView.svelte +++ b/applications/viewer/src/lib/julc/CodeView.svelte @@ -1,6 +1,7 @@ + + + +{#if phase === 'checking' || phase === 'preparing'} + +
+ + {phase === 'checking' ? 'Checking the julc engine…' : julc.status === 'loading' ? 'Starting the julc engine…' : 'Rebuilding the transaction and replaying the run…'} +
+ +
+{:else if phase === 'error'} +
+ +
+

Can’t debug this run

+

{failure}

+ Back to the transaction’s scripts +
+
+{:else} + +
+
+ + + + + + + + {#if errorStep != null} + + {/if} + Step {num(step)} / {num(last)} + {#if busy}{/if} + +
+
+ + {#if failure}

{failure}

{/if} +
+ +
+
+ + +
+ + + {#if lineBreaks.size} + Lines {[...lineBreaks].sort((a, b) => a - b).join(', ')} + {/if} +
+ {#if builtins.length} +
Break on builtin
+
+ {#each builtins as b} + + {/each} +
+ {/if} +
+
+ +
+ {#if run?.plan.notes.length} +
    + {#each run.plan.notes as note}
  • {note}
  • {/each} +
+ {/if} + + {#if snap} +
+
Phase
{snap.phase ?? '—'}
+
Term
{snap.termKind ?? '—'}
+
Stopped by
{snap.stopReason ?? '—'}
+
Stack depth
{snap.stackDepth}
+
+ {#if snap.value} +
Value
+
{snap.value}
+ {/if} + {#if snap.finished} +
+ {#if snap.status === 'success'}{:else}{/if} + {snap.status === 'success' ? 'The script finished successfully.' : snap.error || 'The script failed.'} +
+ {/if} + {/if} +
+ + + {#if snap && timeline} + {#each [{ label: 'CPU', used: snap.cpu, delta: snap.cpuDelta, end: timeline.cpu, want: declared?.steps }, { label: 'Memory', used: snap.mem, delta: snap.memDelta, end: timeline.mem, want: declared?.mem }] as b (b.label)} +
+
+ {b.label} + {num(b.used)} / {num(b.end)} this run{b.want ? ` · ${num(b.want)} declared` : ''} +
+
+
+{num(b.delta)} this step
+
+ {/each} + {/if} +
+ + + {#if snap?.environment?.length} +
    + {#each snap.environment as v} +
  • {v.name} = {v.value}
  • + {/each} +
+ {/if} +
+ + + {#if snap?.frames?.length} +
    + {#each snap.frames as f, i} +
  1. + {i} + {f.kind} + {f.detail} + {#if f.span}· line {f.span.startLine}{/if} +
  2. + {/each} +
+ {/if} +
+ + + {#if snap?.traces?.length} +
    + {#each snap.traces as t, i}
  1. {i + 1}{t}
  2. {/each} +
+ {/if} +
+ +
+
+{/if} diff --git a/applications/viewer/src/lib/julc/debug/Timeline.svelte b/applications/viewer/src/lib/julc/debug/Timeline.svelte new file mode 100644 index 0000000..106ea17 --- /dev/null +++ b/applications/viewer/src/lib/julc/debug/Timeline.svelte @@ -0,0 +1,72 @@ + + +
+
(dragging = false)} + onpointerleave={() => (hover = null)} + > +
+ {#each traceMarks as t}{/each} + {#if error != null}{/if} + + + + {#if hover != null && !dragging} + {num(hover)} + {/if} +
+
+ 0 + + {#if traces.length}{traces.length} trace{traces.length === 1 ? '' : 's'}{/if} + {#if error != null}failure at {num(error)}{/if} + + {num(Math.max(0, total - 1))} +
+
diff --git a/applications/viewer/src/lib/julc/replay/run.test.ts b/applications/viewer/src/lib/julc/replay/run.test.ts new file mode 100644 index 0000000..23744ea --- /dev/null +++ b/applications/viewer/src/lib/julc/replay/run.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it, vi } from 'vitest'; + +vi.mock('../engine.svelte', () => ({ julc: {} })); +const { argsFor, vmRequest } = await import('./run'); +import type { RunPlan } from './build'; + +const plan = (over: Partial): RunPlan => ({ + key: 'spend#0', + purpose: 'spend', + index: 0, + scriptHash: 'ab'.repeat(28), + language: 'V2', + declared: null, + redeemer: 'd87980', + datum: '01', + hashedDatums: [], + witnessDatums: [], + request: { script: { script: '4e4d01000033222220051200120011', language: 'V2' }, transaction: {} as never, target: { language: 'PlutusV2', protocol: 11 }, costModel: null, maxCpu: 10n }, + notes: [], + ...over +}); +const c = (value: string) => ({ format: 'cbor' as const, value }); + +describe('argsFor', () => { + it('applies V3 scripts to the context only', () => { + expect(argsFor(plan({ language: 'V3' }), 'ctx')).toEqual([c('ctx')]); + }); + it('applies V1/V2 spending scripts to datum, redeemer and context', () => { + expect(argsFor(plan({}), 'ctx')).toEqual([c('01'), c('d87980'), c('ctx')]); + expect(argsFor(plan({ datum: null }), 'ctx')).toBeNull(); + }); + it('applies other V1/V2 scripts to redeemer and context', () => { + expect(argsFor(plan({ purpose: 'mint', datum: null }), 'ctx')).toEqual([c('d87980'), c('ctx')]); + }); +}); + +describe('vmRequest', () => { + it('keeps the script, target, cost model and limits of the plan', () => { + const p = plan({}); + expect(vmRequest(p, [c('x')])).toEqual({ script: p.request!.script, args: [c('x')], target: p.request!.target, costModel: null, maxCpu: 10n, maxMem: undefined }); + }); +}); diff --git a/applications/viewer/src/lib/julc/replay/run.ts b/applications/viewer/src/lib/julc/replay/run.ts new file mode 100644 index 0000000..be4599c --- /dev/null +++ b/applications/viewer/src/lib/julc/replay/run.ts @@ -0,0 +1,130 @@ +import { julc } from '../engine.svelte'; +import type { DataInput, Evaluation, JulcClient, TransactionRequest } from '../types'; +import { buildRuns, type RunPlan } from './build'; +import { TAG_ORDER, fixContext, ownRedeemer } from './fix-context'; +import { loadReplayInput, type PageTx } from './load'; + +/* + * The re-run pipeline shared by the Scripts tab and the debugger: rebuild the transaction, let julc build and + * evaluate each redeemer's context, then apply the context corrections (fix-context.ts) and evaluate again with the + * VM. Every result keeps the arguments the script was finally applied to, so the debugger replays exactly the run + * the user saw. + */ + +export interface RunResult { + state: 'running' | 'done' | 'error' | 'skipped'; + plan: RunPlan; + evaluation?: Evaluation; + error?: string; + ms?: number; + /** The context was corrected and the script run again (julc#244 workarounds). */ + fixed?: boolean; + /** What the script was applied to: V3 the context; V1/V2 (datum,) redeemer, context. */ + args?: DataInput[]; +} + +export type VmRequest = Omit & { args: DataInput[] }; + +export function prepareRuns(page: PageTx): Promise { + return loadReplayInput(page).then(buildRuns); +} + +/** The VM arguments for a plan, given its ScriptContext (CBOR); null for a V1/V2 spend without a known datum. */ +export function argsFor(plan: RunPlan, contextCbor: string): DataInput[] | null { + const cbor = (value: string): DataInput => ({ format: 'cbor', value }); + if (plan.language === 'V3') return [cbor(contextCbor)]; + if (plan.purpose === 'spend') return plan.datum ? [cbor(plan.datum), cbor(plan.redeemer!), cbor(contextCbor)] : null; + return [cbor(plan.redeemer!), cbor(contextCbor)]; +} + +/** The request to apply a plan's script to `args` with the VM (evaluate or debug). */ +export function vmRequest(plan: RunPlan, args: DataInput[]): VmRequest { + const req = plan.request!; + return { script: req.script, args, target: req.target, costModel: req.costModel, maxCpu: req.maxCpu, maxMem: req.maxMem }; +} + +const usesUplc = (c: JulcClient) => { + if (!c.uplc) throw new Error('This julc engine can’t evaluate transactions (VM-only build).'); + return c.uplc; +}; + +/** + * Run every plan, reporting each result through `update` as it comes. Resolves with an error message when the + * engine itself can't run (unsupported browser, failed start), otherwise null. + */ +export async function runAll(plans: RunPlan[], update: (key: string, result: RunResult) => void): Promise { + const results: Record = {}; + const set = (key: string, r: RunResult) => { + results[key] = r; + update(key, r); + }; + for (const plan of plans) set(plan.key, plan.request ? { state: 'running', plan } : { state: 'skipped', plan, error: plan.skip }); + + for (const plan of plans) { + if (!plan.request) continue; + const started = performance.now(); + try { + const evaluation = await julc.run((c) => usesUplc(c).evaluateTransaction(plan.request!)); + const ms = Math.round(performance.now() - started); + if (evaluation.ok === false && evaluation.error) set(plan.key, { state: 'error', plan, error: evaluation.error, ms }); + else { + const args = evaluation.scriptContextCbor ? (argsFor(plan, evaluation.scriptContextCbor) ?? undefined) : undefined; + set(plan.key, { state: 'done', plan, evaluation, ms, args }); + } + } catch (e) { + set(plan.key, { state: 'error', plan, error: (e as Error)?.message ?? String(e) }); + if (julc.status === 'unsupported' || julc.status === 'error') return julc.error ?? (e as Error)?.message ?? 'The julc engine failed.'; + } + } + await fixContexts(plans, results, set); + return null; +} + +/* + * julc#244 workarounds (see fix-context.ts): put values in ledger order, restore datums given by hash, fix V1 shapes + * and, when every run produced its context, give each context the complete redeemer map. Runs whose context changed + * are evaluated again with the VM. Redeemers are only merged when all scripts share one Plutus version (V2 or V3): + * purposes are encoded differently per version. + */ +async function fixContexts(plans: RunPlan[], results: Record, set: (key: string, r: RunResult) => void) { + const withContext = plans.filter((p) => p.request && results[p.key]?.evaluation?.scriptContextCbor); + const language = plans[0]?.language; + let entries: NonNullable>[] | null = null; + if (plans.length > 1 && withContext.length === plans.length && language !== 'V1' && plans.every((p) => p.language === language)) { + const ordered = [...plans].sort((a, b) => TAG_ORDER[a.purpose] - TAG_ORDER[b.purpose] || a.index - b.index); + const found = ordered.map((p) => ownRedeemer(results[p.key].evaluation!.scriptContextCbor!)); + if (found.every((e) => e)) entries = found as NonNullable<(typeof found)[number]>[]; + } + for (const plan of withContext) { + const first = results[plan.key]; + const fixed = fixContext(first.evaluation!.scriptContextCbor!, plan.language!, entries, plan.hashedDatums, plan.witnessDatums); + if (!fixed) continue; + const args = argsFor(plan, fixed.cbor); + if (!args) continue; + const started = performance.now(); + try { + const evaluation = await julc.run((c) => c.vm.evaluate(vmRequest(plan, args))); + if (evaluation.ok === false && evaluation.error) continue; + const did = [ + fixed.sortedValues && 'put asset values in ledger order', + fixed.completedRedeemers && `added the other ${plans.length - 1} redeemer${plans.length > 2 ? 's' : ''}`, + fixed.restoredDatums && 'added the datums given by hash under their on-chain hashes', + fixed.v1Shape && 'gave V1 withdrawals and datums their list shape' + ].filter((d): d is string => !!d); + const what = did.length > 1 ? `${did.slice(0, -1).join(', ')} and ${did.at(-1)}` : did[0]; + set(plan.key, { + ...first, + evaluation: { ...evaluation, scriptContextCbor: fixed.cbor, language: first.evaluation!.language }, + ms: Math.round(performance.now() - started), + fixed: true, + args, + plan: { + ...plan, + notes: [`The viewer ${what} in the context, which julc doesn’t do yet (julc#244).`, ...plan.notes.filter((n) => !(fixed.completedRedeemers && n.includes('julc#244')))] + } + }); + } catch { + /* keep the first result */ + } + } +} diff --git a/applications/viewer/src/lib/julc/types.ts b/applications/viewer/src/lib/julc/types.ts index d1cc93d..e9975eb 100644 --- a/applications/viewer/src/lib/julc/types.ts +++ b/applications/viewer/src/lib/julc/types.ts @@ -132,6 +132,66 @@ export interface Evaluation { costModelId?: string; } +/** The whole run, known after the first session action. Steps are 0-based machine transitions. */ +export interface Timeline { + totalSteps: bigint; + traceSteps: bigint[] | null; + errorStep: bigint | null; + status: string | null; + truncated: boolean; + cpu: bigint; + mem: bigint; +} + +export interface Snapshot { + step: bigint; + /** 'compute' (about to evaluate a term) or 'return' (returning a value to a frame). */ + phase: string | null; + /** Position in the formatted UPLC (decode / prettyPrint text), 1-based lines and columns. */ + span: Span | null; + termKind: string | null; + value: string | null; + environment: { name: string | null; value: string | null }[] | null; + frames: { kind: string | null; detail: string | null; span: Span | null }[] | null; + stackDepth: number; + cpu: bigint; + mem: bigint; + cpuDelta: bigint; + memDelta: bigint; + traces: string[] | null; + finished: boolean; + status: string | null; + error: string | null; + stopReason: string | null; +} + +export interface Breakpoints { + lines?: number[]; + builtins?: string[]; + onTrace?: boolean; + onError?: boolean; +} + +export interface DebugReply { + ok: boolean; + error: string | null; + timeline: Timeline | null; + snapshot: Snapshot | null; +} + +/** A debug session in the engine's worker. Going back replays from step 0, which is fast (milliseconds). */ +export interface DebugSession { + sessionId: string; + isOpen(): boolean; + step(): Promise; + goto(step: bigint): Promise; + continue(breakpoints?: Breakpoints): Promise; + over(breakpoints?: Breakpoints): Promise; + out(breakpoints?: Breakpoints): Promise; + snapshot(): Promise; + close(): Promise<{ closed: boolean }>; +} + export type FeatureGroup = 'vm' | 'debug' | 'compiler' | 'uplc' | 'sourceDebug' | 'sourceDebugLocals'; export interface Features { @@ -160,6 +220,12 @@ export interface JulcClient { prettyPrint(request: { script: ScriptInput; width?: number }): Promise<{ uplcText: string }>; /** Apply the script to raw arguments (V3: the context; V1/V2: datum, redeemer, context). */ evaluate(request: Omit & { args: DataInput[] }): Promise; + /** Same request as evaluate; the script isn't run until the first action. */ + debug(request: Omit & { args: DataInput[] }): Promise; + }; + /** Low-level session protocol; the `timeline` action runs the whole script once and returns its timeline. */ + debug?: { + act(request: { sessionId: string; action: 'timeline'; step?: bigint }): Promise; }; /** Full engine only. */ uplc?: { diff --git a/applications/viewer/src/lib/tx/load.server.ts b/applications/viewer/src/lib/tx/load.server.ts new file mode 100644 index 0000000..aaf50d7 --- /dev/null +++ b/applications/viewer/src/lib/tx/load.server.ts @@ -0,0 +1,49 @@ +import { tryApi } from '$lib/api/client'; +import { must } from '$lib/api/load'; +import type { Utxo } from '$lib/tx/types'; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type Any = any; + +export interface TxDetail { + hash: string; + block_height: number; + slot: number; + inputs: Utxo[]; + outputs: Utxo[]; + fees: number | string; + total_output: number | string; + ttl?: number | null; + validity_interval_start?: number | null; + auxiliary_data_hash?: string | null; + script_data_hash?: string | null; + collateral_inputs?: Utxo[]; + collateral_return?: Utxo | null; + total_collateral?: number | string | null; + reference_inputs?: Utxo[]; + required_signers?: string[] | null; + invalid?: boolean; +} + +/** Everything the transaction pages show: the transaction, its script runs, metadata, withdrawals, mints and block. */ +export async function loadTransaction(id: string, fetch: typeof globalThis.fetch) { + const hash = id.trim().toLowerCase(); + const tx = await must(`/txs/${hash}`, { fetch }, 'No transaction with that hash on this chain.'); + const [scripts, metadata, withdrawals, mints, block, protocol] = await Promise.all([ + tryApi(`/txs/${hash}/scripts`, { fetch }), + tryApi(`/txs/${hash}/metadata`, { fetch }), + tryApi(`/txs/${hash}/withdrawals`, { fetch }), + tryApi(`/assets/txs/${hash}`, { fetch }), + tx.block_height >= 0 ? tryApi(`/blocks/${tx.block_height}`, { fetch }) : null, + tryApi('/epochs/latest/parameters', { fetch }) + ]); + return { + tx, + scripts: scripts ?? [], + metadata: metadata ?? [], + withdrawals: withdrawals ?? [], + mints: mints ?? [], + block: block ? { time: block.time as number, epoch: block.epoch as number, hash: block.hash as string } : null, + limits: { mem: Number(protocol?.max_tx_ex_mem) || 14_000_000, steps: Number(protocol?.max_tx_ex_steps) || 10_000_000_000 } + }; +} diff --git a/applications/viewer/src/routes/transactions/[id]/+page.server.ts b/applications/viewer/src/routes/transactions/[id]/+page.server.ts index fa42623..b64c2f2 100644 --- a/applications/viewer/src/routes/transactions/[id]/+page.server.ts +++ b/applications/viewer/src/routes/transactions/[id]/+page.server.ts @@ -1,49 +1,4 @@ -import { tryApi } from '$lib/api/client'; -import { must } from '$lib/api/load'; -import type { Utxo } from '$lib/tx/types'; +import { loadTransaction } from '$lib/tx/load.server'; import type { PageServerLoad } from './$types'; -// eslint-disable-next-line @typescript-eslint/no-explicit-any -type Any = any; - -export interface TxDetail { - hash: string; - block_height: number; - slot: number; - inputs: Utxo[]; - outputs: Utxo[]; - fees: number | string; - total_output: number | string; - ttl?: number | null; - validity_interval_start?: number | null; - auxiliary_data_hash?: string | null; - script_data_hash?: string | null; - collateral_inputs?: Utxo[]; - collateral_return?: Utxo | null; - total_collateral?: number | string | null; - reference_inputs?: Utxo[]; - required_signers?: string[] | null; - invalid?: boolean; -} - -export const load: PageServerLoad = async ({ params, fetch }) => { - const hash = params.id.trim().toLowerCase(); - const tx = await must(`/txs/${hash}`, { fetch }, 'No transaction with that hash on this chain.'); - const [scripts, metadata, withdrawals, mints, block, protocol] = await Promise.all([ - tryApi(`/txs/${hash}/scripts`, { fetch }), - tryApi(`/txs/${hash}/metadata`, { fetch }), - tryApi(`/txs/${hash}/withdrawals`, { fetch }), - tryApi(`/assets/txs/${hash}`, { fetch }), - tx.block_height >= 0 ? tryApi(`/blocks/${tx.block_height}`, { fetch }) : null, - tryApi('/epochs/latest/parameters', { fetch }) - ]); - return { - tx, - scripts: scripts ?? [], - metadata: metadata ?? [], - withdrawals: withdrawals ?? [], - mints: mints ?? [], - block: block ? { time: block.time as number, epoch: block.epoch as number, hash: block.hash as string } : null, - limits: { mem: Number(protocol?.max_tx_ex_mem) || 14_000_000, steps: Number(protocol?.max_tx_ex_steps) || 10_000_000_000 } - }; -}; +export const load: PageServerLoad = ({ params, fetch }) => loadTransaction(params.id, fetch); diff --git a/applications/viewer/src/routes/transactions/[id]/debug/[purpose]/[index]/+page.server.ts b/applications/viewer/src/routes/transactions/[id]/debug/[purpose]/[index]/+page.server.ts new file mode 100644 index 0000000..00b3b2a --- /dev/null +++ b/applications/viewer/src/routes/transactions/[id]/debug/[purpose]/[index]/+page.server.ts @@ -0,0 +1,13 @@ +import { error } from '@sveltejs/kit'; +import { loadTransaction } from '$lib/tx/load.server'; +import type { PageServerLoad } from './$types'; + +const PURPOSES = ['spend', 'mint', 'cert', 'reward', 'vote', 'propose']; + +/* The debugger for one script run of a transaction: /transactions/{hash}/debug/{purpose}/{index}. */ +export const load: PageServerLoad = async ({ params, fetch }) => { + const purpose = params.purpose.toLowerCase(); + if (!PURPOSES.includes(purpose)) error(404, `Unknown script purpose “${params.purpose}”. Use one of ${PURPOSES.join(', ')}.`); + if (!/^\d{1,4}$/.test(params.index)) error(404, 'The redeemer index must be a number.'); + return { ...(await loadTransaction(params.id, fetch)), purpose, index: Number(params.index) }; +}; diff --git a/applications/viewer/src/routes/transactions/[id]/debug/[purpose]/[index]/+page.svelte b/applications/viewer/src/routes/transactions/[id]/debug/[purpose]/[index]/+page.svelte new file mode 100644 index 0000000..431473e --- /dev/null +++ b/applications/viewer/src/routes/transactions/[id]/debug/[purpose]/[index]/+page.svelte @@ -0,0 +1,46 @@ + + +Debug {data.purpose} #{data.index} · Tx {shorten(tx.hash, 8, 6)} · Yaci Viewer + +Transaction {shorten(tx.hash, 10, 8)} + + + {#snippet icon()}{/snippet} + {#if script} +
+ Script +
+ {/if} + {#snippet actions()} + {#if script?.type && plutusVersion(script.type)}Plutus {plutusVersion(script.type)}{/if} + {#if tx.invalid}Failed on chain{/if} + {/snippet} +
+ +{#key `${tx.hash}/${data.purpose}/${data.index}`} + +{/key} diff --git a/applications/viewer/tests/script-lab.spec.ts b/applications/viewer/tests/script-lab.spec.ts index 7c6e137..0fe7332 100644 --- a/applications/viewer/tests/script-lab.spec.ts +++ b/applications/viewer/tests/script-lab.spec.ts @@ -4,13 +4,14 @@ import { expect, test } from '@playwright/test'; * Script Lab (julc engine). Needs data on the configured store: * * SCRIPT_LAB_HASH=<56-hex Plutus script hash> SCRIPT_LAB_TX= \ - * PW_CHANNEL=chrome npx playwright test script-lab + * SCRIPT_LAB_RUN= PW_CHANNEL=chrome npx playwright test script-lab * * The julc engine needs WebAssembly GC and exnref, which Playwright's bundled Chromium 128 lacks, so use a current * browser channel (PW_CHANNEL=chrome). Without support the test checks the "can't run" message instead. */ const hash = process.env.SCRIPT_LAB_HASH; const txHash = process.env.SCRIPT_LAB_TX; +const run = process.env.SCRIPT_LAB_RUN ?? 'spend/0'; const supported = () => WebAssembly.validate(new Uint8Array([0, 97, 115, 109, 1, 0, 0, 0, 1, 5, 1, 95, 1, 120, 0])) && @@ -64,3 +65,35 @@ test('re-runs every script of a transaction and compares with the declared budge await page.getByText('What the validator saw (ScriptContext)').first().click(); await expect(page.getByText(/ScriptContext · /).first()).toBeVisible(); }); + +test('steps through a script run, with breakpoints and step links', async ({ page }) => { + test.skip(!txHash, 'Set SCRIPT_LAB_TX (and SCRIPT_LAB_RUN) to a run on the configured store'); + await page.goto(`/transactions/${txHash}/debug/${run}`); + if (!(await page.evaluate(supported))) { + await expect(page.getByText(/can’t run the julc engine/)).toBeVisible(); + return; + } + const stepButton = page.getByRole('button', { name: 'Step', exact: true }); + await expect(stepButton).toBeVisible({ timeout: 90_000 }); + const position = page.getByText(/^Step [\d,]+ \/ [\d,]+$/); + await expect(position).toHaveText(/^Step 0 \//); + + await page.keyboard.press('ArrowRight'); + await page.keyboard.press('ArrowRight'); + await expect(position).toHaveText(/^Step 2 \//); + await expect(page).toHaveURL(/step=2/); + + await page.keyboard.press('End'); + await expect(page.getByText(/The script finished successfully|The script failed|Error/).first()).toBeVisible(); + + // Break on the first builtin the script uses, then continue from the start. + await page.keyboard.press('Home'); + const builtin = page.getByRole('button', { pressed: false }).filter({ hasText: /^[a-z][A-Za-z0-9_]+$/ }).first(); + await builtin.click(); + await page.keyboard.press('c'); + await expect(page.getByText('builtin', { exact: true }).first()).toBeVisible(); + + // A step link opens that exact step. + await page.goto(`/transactions/${txHash}/debug/${run}?step=5`); + await expect(page.getByText(/^Step 5 \//)).toBeVisible({ timeout: 90_000 }); +});