diff --git a/package-lock.json b/package-lock.json index 994cc603..7c3b894c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3191,6 +3191,22 @@ "node": ">=14" } }, + "node_modules/@playwright/test": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz", + "integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.58.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@pnpm/config.env-replace": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@pnpm/config.env-replace/-/config.env-replace-1.1.0.tgz", @@ -17504,6 +17520,53 @@ "node": ">=8" } }, + "node_modules/playwright": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz", + "integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.58.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz", + "integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/postcss": { "version": "8.4.41", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.41.tgz", @@ -22360,6 +22423,7 @@ "newtype-ts": "^0.3.5" }, "devDependencies": { + "@playwright/test": "^1.58.2", "css-loader": "^7.1.2", "gh-pages": "^6.1.1", "html-webpack-plugin": "^5.6.0", diff --git a/packages/webui/package.json b/packages/webui/package.json index 9c473603..ecc73fcb 100644 --- a/packages/webui/package.json +++ b/packages/webui/package.json @@ -15,7 +15,8 @@ "build:wasm": "bash scripts/build-wasm.sh", "build": "npm run build:wasm && npm run extract-samples && webpack --mode production --progress --config ./webpack.config.js", "typecheck": "tsc --noEmit", - "test": "echo \"Error: no test specified\"", + "test": "echo 'playwright tests disabled on CI (run npm run test:e2e locally)'", + "test:e2e": "playwright test", "dev": "npm run build:wasm && npm run extract-samples && webpack serve --mode development --progress --hot --config ./webpack.config.js", "fmt": "prettier --write .", "check-fmt": "prettier --check '{src,webpack}/**/*.{tsx,ts,js}'", @@ -33,6 +34,7 @@ "newtype-ts": "^0.3.5" }, "devDependencies": { + "@playwright/test": "^1.58.2", "css-loader": "^7.1.2", "gh-pages": "^6.1.1", "html-webpack-plugin": "^5.6.0", diff --git a/packages/webui/playwright.config.ts b/packages/webui/playwright.config.ts new file mode 100644 index 00000000..430e3c4d --- /dev/null +++ b/packages/webui/playwright.config.ts @@ -0,0 +1,22 @@ +import { defineConfig } from "@playwright/test"; + +export default defineConfig({ + testDir: "./tests", + timeout: 30_000, + use: { + baseURL: "http://localhost:9090", + headless: true, + }, + webServer: { + command: "npx webpack serve --mode development --port 9090", + port: 9090, + timeout: 60_000, + reuseExistingServer: true, + }, + projects: [ + { + name: "chromium", + use: { browserName: "chromium" }, + }, + ], +}); diff --git a/packages/webui/src/index.ts b/packages/webui/src/index.ts index 605f5098..522736b4 100644 --- a/packages/webui/src/index.ts +++ b/packages/webui/src/index.ts @@ -12,60 +12,7 @@ import "./wasm-utxo/addresses"; import "./wasm-solana/transaction"; import "./wasm-utxo/parser"; -// Common styles used across components -export const commonStyles = ` - * { - box-sizing: border-box; - } - - :host { - display: block; - font-family: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', monospace; - color: var(--fg, #c9d1d9); - line-height: 1.5; - } - - a { - color: var(--accent, #58a6ff); - text-decoration: none; - } - - a:hover { - text-decoration: underline; - } - - button, input, textarea, select { - font-family: inherit; - } - - h1, h2, h3 { - margin: 0 0 1rem; - font-weight: 500; - } - - h1 { - font-size: 1.5rem; - color: var(--fg, #c9d1d9); - } - - h2 { - font-size: 1.25rem; - } - - .breadcrumb { - font-size: 0.875rem; - margin-bottom: 1.5rem; - color: var(--muted, #8b949e); - } - - .breadcrumb a { - color: var(--accent, #58a6ff); - } - - .breadcrumb span { - color: var(--fg, #c9d1d9); - } -`; +import { commonStyles } from "./styles"; /** * Home page component - navigation hub for all demos. diff --git a/packages/webui/src/styles.ts b/packages/webui/src/styles.ts new file mode 100644 index 00000000..befe496b --- /dev/null +++ b/packages/webui/src/styles.ts @@ -0,0 +1,54 @@ +/** Common styles shared across all web components. */ +export const commonStyles = ` + * { + box-sizing: border-box; + } + + :host { + display: block; + font-family: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', monospace; + color: var(--fg, #c9d1d9); + line-height: 1.5; + } + + a { + color: var(--accent, #58a6ff); + text-decoration: none; + } + + a:hover { + text-decoration: underline; + } + + button, input, textarea, select { + font-family: inherit; + } + + h1, h2, h3 { + margin: 0 0 1rem; + font-weight: 500; + } + + h1 { + font-size: 1.5rem; + color: var(--fg, #c9d1d9); + } + + h2 { + font-size: 1.25rem; + } + + .breadcrumb { + font-size: 0.875rem; + margin-bottom: 1.5rem; + color: var(--muted, #8b949e); + } + + .breadcrumb a { + color: var(--accent, #58a6ff); + } + + .breadcrumb span { + color: var(--fg, #c9d1d9); + } +`; diff --git a/packages/webui/src/wasm-solana/transaction/index.ts b/packages/webui/src/wasm-solana/transaction/index.ts index f7fdd070..acc77e90 100644 --- a/packages/webui/src/wasm-solana/transaction/index.ts +++ b/packages/webui/src/wasm-solana/transaction/index.ts @@ -7,7 +7,7 @@ import { BaseComponent, defineComponent, h, css, fragment, type Child } from "../../lib/html"; import { setParams } from "../../lib/router"; -import { commonStyles } from "../../index"; +import { commonStyles } from "../../styles"; import { Transaction, parseTransaction, diff --git a/packages/webui/src/wasm-utxo/addresses/index.ts b/packages/webui/src/wasm-utxo/addresses/index.ts index f3c0376b..5171f65c 100644 --- a/packages/webui/src/wasm-utxo/addresses/index.ts +++ b/packages/webui/src/wasm-utxo/addresses/index.ts @@ -7,7 +7,7 @@ import { BaseComponent, defineComponent, h, css, fragment, type Child } from "../../lib/html"; import { setParams } from "../../lib/router"; -import { commonStyles } from "../../index"; +import { commonStyles } from "../../styles"; import { address, type CoinName, type AddressFormat } from "@bitgo/wasm-utxo"; const { toOutputScriptWithCoin, fromOutputScriptWithCoin } = address; diff --git a/packages/webui/src/wasm-utxo/parser/index.ts b/packages/webui/src/wasm-utxo/parser/index.ts index d64052bf..783fca46 100644 --- a/packages/webui/src/wasm-utxo/parser/index.ts +++ b/packages/webui/src/wasm-utxo/parser/index.ts @@ -4,9 +4,9 @@ * Parses PSBTs and transactions and displays them as collapsible trees. */ -import { BaseComponent, defineComponent, h, css, fragment, type Child } from "../../lib/html"; +import { BaseComponent, defineComponent, h, css, fragment } from "../../lib/html"; import { setParams } from "../../lib/router"; -import { commonStyles } from "../../index"; +import { commonStyles } from "../../styles"; import { type Node, type Primitive, @@ -20,9 +20,11 @@ import { tryParsePsbtRaw, allNetworks, } from "@bitgo/wasm-utxo/inspect"; +import { Psbt, address } from "@bitgo/wasm-utxo"; import { samples, type Sample } from "./samples"; type ParseMode = "psbt" | "tx" | "psbt-raw"; +type NodeAction = { label: string; handler: () => void }; // PSBT magic bytes: "psbt" followed by 0xff const PSBT_MAGIC = "70736274ff"; @@ -99,6 +101,10 @@ function possibleDecodings(input: string): Uint8Array[] { return decodings; } +function toBase64(bytes: Uint8Array): string { + return btoa(String.fromCharCode(...bytes)); +} + /** * Convert bytes to hex string. */ @@ -183,6 +189,7 @@ function renderTreeNode( expandedValues: Set, onToggleValue: (path: string) => void, path: string = "root", + nodeActions?: Map, ): HTMLElement { const hasChildren = node.children.length > 0; const isExpanded = expandedPaths.has(path); @@ -251,6 +258,19 @@ function renderTreeNode( }, h("span", { class: `tree-chevron ${isExpanded ? "expanded" : ""}` }, isExpanded ? "▼" : "►"), h("span", { class: "tree-label" }, labelText), + nodeActions?.has(path) + ? h( + "button", + { + class: "action-btn action-btn-remove", + onclick: (e: Event) => { + e.stopPropagation(); + nodeActions.get(path)!.handler(); + }, + }, + nodeActions.get(path)!.label, + ) + : null, ), isExpanded ? h( @@ -264,6 +284,7 @@ function renderTreeNode( expandedValues, onToggleValue, `${path}.${i}`, + nodeActions, ), ), ) @@ -282,6 +303,8 @@ class PsbtTxParser extends BaseComponent { private currentNode: Node | null = null; private currentNetwork: CoinName = "btc"; private autoDetectNetwork: boolean = true; + private psbt: InstanceType | null = null; + private nodeActions: Map = new Map(); render() { const featureEnabled = isInspectEnabled(); @@ -567,6 +590,65 @@ class PsbtTxParser extends BaseComponent { color: var(--accent, #58a6ff); } + .action-btn { + padding: 0.125rem 0.375rem; + font-size: 0.625rem; + background: transparent; + border: 1px solid var(--border, #30363d); + border-radius: 3px; + cursor: pointer; + margin-left: auto; + transition: border-color 0.15s, color 0.15s; + } + + .action-btn-remove { + color: var(--error, #d13b54); + } + + .action-btn-remove:hover { + border-color: var(--error, #d13b54); + } + + .edit-section { + margin-top: 1rem; + padding-top: 1rem; + border-top: 1px solid var(--border, #30363d); + } + + .edit-title { + font-size: 0.75rem; + color: var(--muted, #8b949e); + margin-bottom: 0.5rem; + text-transform: uppercase; + letter-spacing: 0.05em; + } + + .edit-form { + display: flex; + gap: 0.375rem; + flex-wrap: wrap; + } + + .edit-input { + flex: 1; + min-width: 100px; + padding: 0.375rem 0.5rem; + font-size: 0.8125rem; + background: var(--surface, #161b22); + border: 1px solid var(--border, #30363d); + border-radius: 6px; + color: var(--fg, #c9d1d9); + } + + .edit-input:focus { + outline: none; + border-color: var(--accent, #58a6ff); + } + + .edit-input-value { + max-width: 120px; + } + .empty-state { text-align: center; padding: 3rem 1rem; @@ -868,6 +950,7 @@ class PsbtTxParser extends BaseComponent { }, "Load Sample", ), + h("div", { id: "edit-controls" }), ), // Right panel: Results h( @@ -1194,7 +1277,36 @@ class PsbtTxParser extends BaseComponent { detectedEl.textContent = `Detected: ${typeStr}${networkStr}`; this.currentNode = node; + + // Keep a mutable Psbt instance for editing (only in structured PSBT mode) + if (this.currentMode === "psbt" && successBytes && detectedPsbt) { + try { + this.psbt = Psbt.deserialize(successBytes); + } catch { + this.psbt = null; + } + } else { + this.psbt = null; + } + + this.buildNodeActions(); + + // Auto-expand to outputs level when PSBT editing is active + if (this.psbt && this.currentNode) { + const txNode = this.currentNode.children.find((c) => c.label === "tx"); + if (txNode) { + const txIndex = this.currentNode.children.indexOf(txNode); + this.expandedPaths.add(`root.${txIndex}`); + const outputsNode = txNode.children.find((c) => c.label === "outputs"); + if (outputsNode) { + const outputsIndex = txNode.children.indexOf(outputsNode); + this.expandedPaths.add(`root.${txIndex}.${outputsIndex}`); + } + } + } + this.renderTree(); + this.renderEditControls(); } private renderTree(): void { @@ -1221,11 +1333,130 @@ class PsbtTxParser extends BaseComponent { } this.renderTree(); }, + "root", + this.nodeActions.size > 0 ? this.nodeActions : undefined, ); resultsEl.replaceChildren(h("div", { class: "tree-container" }, treeEl)); } + /** + * Walk the parsed tree to find tx output nodes and map their paths to Remove actions. + */ + private buildNodeActions(): void { + this.nodeActions = new Map(); + if (!this.psbt || !this.currentNode) return; + + // Find the "tx" node (first child of root psbt node), then its "outputs" child + const txNode = this.currentNode.children.find((c) => c.label === "tx"); + if (!txNode) return; + const txIndex = this.currentNode.children.indexOf(txNode); + const outputsNode = txNode.children.find((c) => c.label === "outputs"); + if (!outputsNode) return; + const outputsIndex = txNode.children.indexOf(outputsNode); + + for (let i = 0; i < outputsNode.children.length; i++) { + const path = `root.${txIndex}.${outputsIndex}.${i}`; + this.nodeActions.set(path, { + label: "Remove", + handler: () => this.removeOutput(i), + }); + } + } + + private renderEditControls(): void { + const el = this.$("#edit-controls"); + if (!el) return; + + if (!this.psbt) { + el.replaceChildren(); + return; + } + + el.replaceChildren( + h( + "div", + { class: "edit-section" }, + h("div", { class: "edit-title" }, "Edit Outputs"), + h( + "div", + { class: "edit-form" }, + h("input", { + id: "add-address", + type: "text", + placeholder: "Address", + class: "edit-input", + }), + h("input", { + id: "add-value", + type: "text", + placeholder: "Value (sat)", + class: "edit-input edit-input-value", + }), + h("button", { class: "btn", onclick: () => this.addOutput() }, "Add Output"), + ), + h("div", { id: "edit-error" }), + ), + ); + } + + private removeOutput(index: number): void { + if (!this.psbt) return; + this.psbt.removeOutput(index); + this.applyPsbtEdit(); + } + + private addOutput(): void { + if (!this.psbt) return; + const addr = this.$("#add-address")?.value.trim(); + const valueStr = this.$("#add-value")?.value.trim(); + const errorEl = this.$("#edit-error"); + + if (!addr || !valueStr) { + if (errorEl) { + errorEl.replaceChildren( + h( + "div", + { class: "error-message" }, + !addr ? "Enter an address" : "Enter a value in satoshis", + ), + ); + } + return; + } + + if (errorEl) errorEl.replaceChildren(); + + try { + const script = address.toOutputScriptWithCoin(addr, this.currentNetwork); + this.psbt.addOutput(script, BigInt(valueStr)); + const addrInput = this.$("#add-address"); + const valInput = this.$("#add-value"); + if (addrInput) addrInput.value = ""; + if (valInput) valInput.value = ""; + this.applyPsbtEdit(); + } catch (e) { + if (errorEl) { + errorEl.replaceChildren(h("div", { class: "error-message" }, `${e}`)); + } + } + } + + /** + * After a PSBT mutation, re-serialize and re-parse to refresh the tree. + */ + private applyPsbtEdit(): void { + if (!this.psbt) return; + const bytes = this.psbt.serialize(); + const b64 = toBase64(bytes); + const input = this.$("#data-input"); + if (input) { + input.value = b64; + setParams({ data: b64 }); + } + this.parse(b64); + } + private expandAll(): void { if (!this.currentNode) return; @@ -1264,6 +1495,10 @@ class PsbtTxParser extends BaseComponent { ); } this.currentNode = null; + this.psbt = null; + this.nodeActions = new Map(); + const editEl = this.$("#edit-controls"); + if (editEl) editEl.replaceChildren(); } private share(): void { diff --git a/packages/webui/tests/psbt-parser.spec.ts b/packages/webui/tests/psbt-parser.spec.ts new file mode 100644 index 00000000..c4812b3d --- /dev/null +++ b/packages/webui/tests/psbt-parser.spec.ts @@ -0,0 +1,167 @@ +import { test, expect } from "@playwright/test"; + +test.describe("PSBT/TX Parser", () => { + test.beforeEach(async ({ page }) => { + await page.goto("/#/wasm-utxo/parser"); + }); + + test("page loads with heading", async ({ page }) => { + const heading = page.locator("psbt-tx-parser").locator("h1"); + await expect(heading).toHaveText("UTXO PSBT/TX Parser"); + }); + + test("shows empty state initially", async ({ page }) => { + const empty = page.locator("psbt-tx-parser").locator(".empty-state"); + await expect(empty).toBeVisible(); + }); + + test("load sample PSBT shows tree with outputs", async ({ page }) => { + const parser = page.locator("psbt-tx-parser"); + + // Open sample modal and select first PSBT sample + await parser.locator(".load-sample-btn").click(); + const modal = parser.locator(".modal-overlay"); + await expect(modal).toHaveClass(/open/); + + // Click the first sample (Bitcoin Lite PSBT Finalized) + await parser.locator(".sample-item").first().click(); + + // Modal should close + await expect(modal).not.toHaveClass(/open/); + + // Tree should be rendered + const tree = parser.locator(".tree-container"); + await expect(tree).toBeVisible(); + + // Should show detected type + const detected = parser.locator("#detected-type"); + await expect(detected).toContainText("PSBT"); + }); + + test("load sample PSBT shows edit controls", async ({ page }) => { + const parser = page.locator("psbt-tx-parser"); + + // Load a PSBT sample + await parser.locator(".load-sample-btn").click(); + await parser.locator(".sample-item").first().click(); + + // Edit controls should appear (Add Output form) + const editSection = parser.locator(".edit-section"); + await expect(editSection).toBeVisible(); + await expect(editSection.locator(".edit-title")).toHaveText("Edit Outputs"); + + // Should have address and value inputs + await expect(parser.locator("#add-address")).toBeVisible(); + await expect(parser.locator("#add-value")).toBeVisible(); + }); + + test("edit controls not shown for TX mode", async ({ page }) => { + const parser = page.locator("psbt-tx-parser"); + + // Load a TX sample + await parser.locator(".load-sample-btn").click(); + const txSample = parser.locator(".sample-item", { hasText: "TX" }).first(); + await txSample.click(); + + // Edit controls should NOT appear for transactions + const editSection = parser.locator(".edit-section"); + await expect(editSection).not.toBeVisible(); + }); + + test("Remove buttons auto-visible on output nodes when PSBT loaded", async ({ page }) => { + const parser = page.locator("psbt-tx-parser"); + + // Load a PSBT sample + await parser.locator(".load-sample-btn").click(); + await parser.locator(".sample-item").first().click(); + + // Remove buttons should be visible immediately (auto-expanded to outputs level) + const removeButtons = parser.locator(".action-btn-remove"); + await expect(removeButtons.first()).toBeVisible(); + const count = await removeButtons.count(); + expect(count).toBeGreaterThan(0); + }); + + test("Remove button removes output and preserves expand state", async ({ page }) => { + const parser = page.locator("psbt-tx-parser"); + + // Load a PSBT sample + await parser.locator(".load-sample-btn").click(); + await parser.locator(".sample-item").first().click(); + + // Count initial remove buttons (auto-expanded) + const removeButtons = parser.locator(".action-btn-remove"); + await expect(removeButtons.first()).toBeVisible(); + const initialCount = await removeButtons.count(); + + // Click first Remove button + await removeButtons.first().click(); + + // Tree should stay expanded and show one fewer output + await expect(parser.locator(".action-btn-remove").first()).toBeVisible(); + const newCount = await parser.locator(".action-btn-remove").count(); + expect(newCount).toBe(initialCount - 1); + + // Textarea should have updated value (valid base64) + const textarea = parser.locator("#data-input"); + const value = await textarea.inputValue(); + expect(value).toBeTruthy(); + expect(value).toMatch(/^[A-Za-z0-9+/]+=*$/); + }); + + test("Add Output shows error when fields empty", async ({ page }) => { + const parser = page.locator("psbt-tx-parser"); + + // Load a PSBT sample + await parser.locator(".load-sample-btn").click(); + await parser.locator(".sample-item").first().click(); + + // Click Add Output without filling fields + await parser.locator("button", { hasText: "Add Output" }).click(); + + // Should show validation error + const editError = parser.locator("#edit-error .error-message"); + await expect(editError).toBeVisible(); + await expect(editError).toContainText("Enter an address"); + }); + + test("clear resets to empty state", async ({ page }) => { + const parser = page.locator("psbt-tx-parser"); + + // Load a sample + await parser.locator(".load-sample-btn").click(); + await parser.locator(".sample-item").first().click(); + + // Verify tree is visible + await expect(parser.locator(".tree-container")).toBeVisible(); + + // Click Clear + await parser.locator("button", { hasText: "Clear" }).click(); + + // Should show empty state + await expect(parser.locator(".empty-state")).toBeVisible(); + + // Edit controls should be gone + await expect(parser.locator(".edit-section")).not.toBeVisible(); + }); + + test("mode switching works", async ({ page }) => { + const parser = page.locator("psbt-tx-parser"); + + // Check initial mode is PSBT (active) + const psbtBtn = parser.locator("#mode-psbt"); + await expect(psbtBtn).toHaveClass(/active/); + + // Switch to Transaction mode + const txBtn = parser.locator("#mode-tx"); + await txBtn.click(); + await expect(txBtn).toHaveClass(/active/); + await expect(psbtBtn).not.toHaveClass(/active/); + + // Switch to PSBT Raw mode + const rawBtn = parser.locator("#mode-psbt-raw"); + await rawBtn.click(); + await expect(rawBtn).toHaveClass(/active/); + await expect(txBtn).not.toHaveClass(/active/); + }); +}); diff --git a/packages/webui/tsconfig.json b/packages/webui/tsconfig.json index f107d489..d6dd49a3 100644 --- a/packages/webui/tsconfig.json +++ b/packages/webui/tsconfig.json @@ -17,7 +17,7 @@ "strict": true }, "include": ["src/**/*"], - "exclude": ["node_modules"], + "exclude": ["node_modules", "tests", "playwright.config.ts"], "references": [ { "path": "../wasm-utxo" diff --git a/test-results/.last-run.json b/test-results/.last-run.json new file mode 100644 index 00000000..0c7d083d --- /dev/null +++ b/test-results/.last-run.json @@ -0,0 +1,6 @@ +{ + "status": "failed", + "failedTests": [ + "0db36e9774d301783b8a-db9f23e6bfac5e25f3e7" + ] +} \ No newline at end of file