From 1af6af6481493089c742bd143354cb0407757719 Mon Sep 17 00:00:00 2001 From: tmm Date: Thu, 2 Apr 2026 15:40:37 -0500 Subject: [PATCH 1/7] wip: html compose --- pnpm-lock.yaml | 113 +++++++++- scripts/build:html.ts | 27 +++ src/server/Mppx.test.ts | 203 +++++++++++++++++ src/server/Mppx.ts | 213 +++++++++++++++++- src/server/Transport.test.ts | 5 +- src/server/Transport.ts | 14 +- src/server/internal/html/compose.main.ts | 58 +++++ src/server/internal/html/config.ts | 60 +++++ .../internal/html/tsconfig.compose.json | 8 + src/stripe/server/internal/html/main.ts | 11 +- src/tempo/server/internal/html/main.ts | 8 +- test/html/compose.test.ts | 144 ++++++++++++ test/html/playwright.config.ts | 5 + test/html/server.ts | 27 ++- tsconfig.json | 1 + 15 files changed, 861 insertions(+), 36 deletions(-) create mode 100644 src/server/internal/html/compose.main.ts create mode 100644 src/server/internal/html/tsconfig.compose.json create mode 100644 test/html/compose.test.ts diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 906a1f9b..94b74c2e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -197,6 +197,33 @@ importers: specifier: latest version: 8.0.3(@types/node@25.5.0)(esbuild@0.27.2)(tsx@4.21.0)(yaml@2.8.3) + examples/compose: + dependencies: + '@stripe/stripe-js': + specifier: ^8.7.0 + version: 8.9.0 + '@types/node': + specifier: latest + version: 25.5.0 + '@typescript/native-preview': + specifier: 7.0.0-dev.20260323.1 + version: 7.0.0-dev.20260323.1 + mppx: + specifier: workspace:* + version: link:../.. + stripe: + specifier: ^17.7.0 + version: 17.7.0 + typescript: + specifier: 5.9.3 + version: 5.9.3 + viem: + specifier: ^2.47.5 + version: 2.47.6(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6) + vite: + specifier: latest + version: 8.0.3(@types/node@25.5.0)(esbuild@0.27.2)(tsx@4.21.0)(yaml@2.8.3) + examples/session/multi-fetch: dependencies: '@remix-run/node-fetch-server': @@ -291,13 +318,13 @@ importers: dependencies: accounts: specifier: 0.4.12 - version: 0.4.12(@types/react@19.2.14)(@wagmi/core@3.4.0(@tanstack/query-core@5.90.20)(@types/react@19.2.14)(ox@0.14.7(typescript@5.9.3)(zod@4.3.6))(react@19.2.4)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@19.2.4))(viem@2.47.6(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6)))(react@19.2.4)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@19.2.4))(viem@2.47.6(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6)) + version: 0.4.12(@types/react@19.2.14)(@wagmi/core@3.4.0(@tanstack/query-core@5.90.20)(@types/react@19.2.14)(ox@0.14.7(typescript@6.0.2)(zod@4.3.6))(react@19.2.4)(typescript@6.0.2)(use-sync-external-store@1.4.0(react@19.2.4))(viem@2.47.6(bufferutil@4.1.0)(typescript@6.0.2)(utf-8-validate@5.0.10)(zod@4.3.6)))(react@19.2.4)(typescript@6.0.2)(use-sync-external-store@1.4.0(react@19.2.4))(viem@2.47.6(bufferutil@4.1.0)(typescript@6.0.2)(utf-8-validate@5.0.10)(zod@4.3.6)) mppx: specifier: workspace:* version: link:../../../../.. viem: specifier: ^2.47.5 - version: 2.47.6(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6) + version: 2.47.6(bufferutil@4.1.0)(typescript@6.0.2)(utf-8-validate@5.0.10)(zod@4.3.6) packages: @@ -3458,6 +3485,11 @@ packages: engines: {node: '>=14.17'} hasBin: true + typescript@6.0.2: + resolution: {integrity: sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ==} + engines: {node: '>=14.17'} + hasBin: true + uint8array-extras@1.5.0: resolution: {integrity: sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==} engines: {node: '>=18'} @@ -4956,11 +4988,33 @@ snapshots: - react - use-sync-external-store + '@wagmi/core@3.4.0(@tanstack/query-core@5.90.20)(@types/react@19.2.14)(ox@0.14.7(typescript@6.0.2)(zod@4.3.6))(react@19.2.4)(typescript@6.0.2)(use-sync-external-store@1.4.0(react@19.2.4))(viem@2.47.6(bufferutil@4.1.0)(typescript@6.0.2)(utf-8-validate@5.0.10)(zod@4.3.6))': + dependencies: + eventemitter3: 5.0.1 + mipd: 0.0.7(typescript@6.0.2) + viem: 2.47.6(bufferutil@4.1.0)(typescript@6.0.2)(utf-8-validate@5.0.10)(zod@4.3.6) + zustand: 5.0.0(@types/react@19.2.14)(react@19.2.4)(use-sync-external-store@1.4.0(react@19.2.4)) + optionalDependencies: + '@tanstack/query-core': 5.90.20 + ox: 0.14.7(typescript@6.0.2)(zod@4.3.6) + typescript: 6.0.2 + transitivePeerDependencies: + - '@types/react' + - immer + - react + - use-sync-external-store + optional: true + abitype@1.2.3(typescript@5.9.3)(zod@4.3.6): optionalDependencies: typescript: 5.9.3 zod: 4.3.6 + abitype@1.2.3(typescript@6.0.2)(zod@4.3.6): + optionalDependencies: + typescript: 6.0.2 + zod: 4.3.6 + abort-controller@3.0.0: dependencies: event-target-shim: 5.0.1 @@ -4970,21 +5024,21 @@ snapshots: mime-types: 3.0.2 negotiator: 1.0.0 - accounts@0.4.12(@types/react@19.2.14)(@wagmi/core@3.4.0(@tanstack/query-core@5.90.20)(@types/react@19.2.14)(ox@0.14.7(typescript@5.9.3)(zod@4.3.6))(react@19.2.4)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@19.2.4))(viem@2.47.6(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6)))(react@19.2.4)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@19.2.4))(viem@2.47.6(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6)): + accounts@0.4.12(@types/react@19.2.14)(@wagmi/core@3.4.0(@tanstack/query-core@5.90.20)(@types/react@19.2.14)(ox@0.14.7(typescript@6.0.2)(zod@4.3.6))(react@19.2.4)(typescript@6.0.2)(use-sync-external-store@1.4.0(react@19.2.4))(viem@2.47.6(bufferutil@4.1.0)(typescript@6.0.2)(utf-8-validate@5.0.10)(zod@4.3.6)))(react@19.2.4)(typescript@6.0.2)(use-sync-external-store@1.4.0(react@19.2.4))(viem@2.47.6(bufferutil@4.1.0)(typescript@6.0.2)(utf-8-validate@5.0.10)(zod@4.3.6)): dependencies: '@remix-run/fetch-router': 0.17.0 idb-keyval: 6.2.2 - mipd: 0.0.7(typescript@5.9.3) + mipd: 0.0.7(typescript@6.0.2) mppx: 'link:' - ox: 0.14.7(typescript@5.9.3)(zod@4.3.6) + ox: 0.14.7(typescript@6.0.2)(zod@4.3.6) tsx: 4.21.0 - webauthx: 0.1.0(typescript@5.9.3)(zod@4.3.6) + webauthx: 0.1.0(typescript@6.0.2)(zod@4.3.6) zod: 4.3.6 zustand: 5.0.12(@types/react@19.2.14)(react@19.2.4)(use-sync-external-store@1.4.0(react@19.2.4)) optionalDependencies: - '@wagmi/core': 3.4.0(@tanstack/query-core@5.90.20)(@types/react@19.2.14)(ox@0.14.7(typescript@5.9.3)(zod@4.3.6))(react@19.2.4)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@19.2.4))(viem@2.47.6(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6)) + '@wagmi/core': 3.4.0(@tanstack/query-core@5.90.20)(@types/react@19.2.14)(ox@0.14.7(typescript@6.0.2)(zod@4.3.6))(react@19.2.4)(typescript@6.0.2)(use-sync-external-store@1.4.0(react@19.2.4))(viem@2.47.6(bufferutil@4.1.0)(typescript@6.0.2)(utf-8-validate@5.0.10)(zod@4.3.6)) react: 19.2.4 - viem: 2.47.6(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6) + viem: 2.47.6(bufferutil@4.1.0)(typescript@6.0.2)(utf-8-validate@5.0.10)(zod@4.3.6) transitivePeerDependencies: - '@types/react' - immer @@ -6120,6 +6174,10 @@ snapshots: optionalDependencies: typescript: 5.9.3 + mipd@0.0.7(typescript@6.0.2): + optionalDependencies: + typescript: 6.0.2 + mkdirp-classic@0.5.3: {} mkdirp@1.0.4: {} @@ -6210,6 +6268,21 @@ snapshots: transitivePeerDependencies: - zod + ox@0.14.7(typescript@6.0.2)(zod@4.3.6): + dependencies: + '@adraffy/ens-normalize': 1.11.1 + '@noble/ciphers': 1.3.0 + '@noble/curves': 1.9.1 + '@noble/hashes': 1.8.0 + '@scure/bip32': 1.7.0 + '@scure/bip39': 1.6.0 + abitype: 1.2.3(typescript@6.0.2)(zod@4.3.6) + eventemitter3: 5.0.1 + optionalDependencies: + typescript: 6.0.2 + transitivePeerDependencies: + - zod + oxfmt@0.42.0: dependencies: tinypool: 2.1.0 @@ -6860,6 +6933,9 @@ snapshots: typescript@5.9.3: {} + typescript@6.0.2: + optional: true + uint8array-extras@1.5.0: {} undici-types@5.26.5: {} @@ -6927,6 +7003,23 @@ snapshots: - utf-8-validate - zod + viem@2.47.6(bufferutil@4.1.0)(typescript@6.0.2)(utf-8-validate@5.0.10)(zod@4.3.6): + dependencies: + '@noble/curves': 1.9.1 + '@noble/hashes': 1.8.0 + '@scure/bip32': 1.7.0 + '@scure/bip39': 1.6.0 + abitype: 1.2.3(typescript@6.0.2)(zod@4.3.6) + isows: 1.0.7(ws@8.18.3(bufferutil@4.1.0)(utf-8-validate@5.0.10)) + ox: 0.14.7(typescript@6.0.2)(zod@4.3.6) + ws: 8.18.3(bufferutil@4.1.0)(utf-8-validate@5.0.10) + optionalDependencies: + typescript: 6.0.2 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + - zod + vite-plus@0.1.14(@types/node@25.5.0)(bufferutil@4.1.0)(esbuild@0.27.2)(tsx@4.21.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(vite@8.0.3(@types/node@25.5.0)(esbuild@0.27.2)(tsx@4.21.0)(yaml@2.8.3))(yaml@2.8.3): dependencies: '@oxc-project/types': 0.122.0 @@ -7012,9 +7105,9 @@ snapshots: - ox - porto - webauthx@0.1.0(typescript@5.9.3)(zod@4.3.6): + webauthx@0.1.0(typescript@6.0.2)(zod@4.3.6): dependencies: - ox: 0.14.7(typescript@5.9.3)(zod@4.3.6) + ox: 0.14.7(typescript@6.0.2)(zod@4.3.6) transitivePeerDependencies: - typescript - zod diff --git a/scripts/build:html.ts b/scripts/build:html.ts index a11393ad..929aa86a 100644 --- a/scripts/build:html.ts +++ b/scripts/build:html.ts @@ -10,6 +10,33 @@ const stripeMode = process.env.STRIPE_HTML_MODE ?? defaultMode const formatBundleSize = (bytes: number) => bytes >= 1_000 ? `${(bytes / 1_000).toFixed(1)} kB` : `${bytes} B` +// Tab script (bundled as raw JS string for compose HTML) +// Must be built before HTML entries since they import config.ts which re-exports tabScript +{ + const entry = 'src/server/internal/html/compose.main.ts' + const outFile = path.resolve(root, 'src/server/internal/html/compose.main.gen.ts') + + await build({ + input: path.resolve(root, entry), + output: { + dir: outDir, + format: 'iife', + minify: true, + }, + }) + + const jsFile = fs.readdirSync(outDir).find((f) => f.endsWith('.js')) + if (!jsFile) throw new Error(`No .js output found for ${entry}`) + + const code = fs.readFileSync(path.join(outDir, jsFile), 'utf8').trim() + const bundleBytes = Buffer.byteLength(code) + const content = `// Generated — do not edit.\nexport const tabScript = ${JSON.stringify(``)}\n` + + fs.writeFileSync(outFile, content) + fs.rmSync(outDir, { recursive: true }) + console.log(`wrote ${path.relative(root, outFile)} (${formatBundleSize(bundleBytes)})`) +} + // HTML entries — bundled into ', + formatAmount: (request: Record) => `$${request.amount}`, + text: undefined, + theme: undefined, + } + + const htmlOptionsB = { + config: { providerB: true }, + content: '', + formatAmount: (request: Record) => `$${request.amount}`, + text: undefined, + theme: undefined, + } + + const alphaWithHtml = Method.toServer(mockChargeA, { + html: htmlOptionsA, + async verify() { + return mockReceipt('alpha') + }, + }) + + const betaWithHtml = Method.toServer(mockChargeB, { + html: htmlOptionsB, + async verify() { + return mockReceipt('beta') + }, + }) + + test('returns html with tabs when multiple methods have html', async () => { + const mppx = Mppx.create({ + methods: [alphaWithHtml, betaWithHtml], + realm, + secretKey, + }) + + const handle = mppx.compose([alphaWithHtml, challengeOpts], [betaWithHtml, challengeOpts]) + + const result = await handle( + new Request('https://example.com/resource', { + headers: { Accept: 'text/html' }, + }), + ) + + expect(result.status).toBe(402) + if (result.status !== 402) throw new Error() + + const body = await result.challenge.text() + expect(result.challenge.headers.get('Content-Type')).toBe('text/html; charset=utf-8') + + // Tab a11y markup + expect(body).toContain('role="tablist"') + expect(body).toContain('role="tab"') + expect(body).toContain('role="tabpanel"') + expect(body).toContain('aria-selected="true"') + expect(body).toContain('aria-controls="mppx-panel-0"') + expect(body).toContain('aria-controls="mppx-panel-1"') + + // Tab labels from method names + expect(body).toContain('Alpha') + expect(body).toContain('Beta') + + // Both method bundles included + expect(body).toContain('/alpha-bundle.js') + expect(body).toContain('/beta-bundle.js') + + // Data map with both entries + const dataMatch = body.match( + / + ${htmlEntries + .map((entry) => + entry.handler._internal.html!.content.replace( + ' ${options.html.content} diff --git a/src/server/internal/html/compose.main.ts b/src/server/internal/html/compose.main.ts new file mode 100644 index 00000000..f3e8fd5e --- /dev/null +++ b/src/server/internal/html/compose.main.ts @@ -0,0 +1,58 @@ +const tablist = document.querySelector('.mppx-tablist')! +const param = '__mppx_tab' +const tabs = Array.from(tablist.querySelectorAll('[role="tab"]')) + +// Generate unique slugs: tempo, stripe, stripe-2 +const slugs: string[] = [] +const counts: Record = {} +for (const tab of tabs) { + const name = tab.textContent!.trim().toLowerCase() + counts[name] = (counts[name] || 0) + 1 + slugs.push(counts[name] === 1 ? name : `${name}-${counts[name]}`) +} + +function activate(tab: HTMLElement, updateUrl = true) { + tabs.forEach((t) => { + t.setAttribute('aria-selected', 'false') + t.setAttribute('tabindex', '-1') + }) + tab.setAttribute('aria-selected', 'true') + tab.removeAttribute('tabindex') + tab.focus() + document.querySelectorAll('[role="tabpanel"]').forEach((p) => { + p.hidden = true + }) + document.getElementById(tab.getAttribute('aria-controls')!)!.hidden = false + + if (updateUrl) { + const url = new URL(location.href) + url.searchParams.set(param, slugs[tabs.indexOf(tab)]!) + history.replaceState(null, '', url) + } +} + +// Restore tab from URL on load +const initial = new URL(location.href).searchParams.get(param) +if (initial !== null) { + const idx = slugs.indexOf(initial) + if (idx >= 0) activate(tabs[idx]!, false) +} + +tablist.addEventListener('click', (e) => { + const tab = (e.target as HTMLElement).closest('[role="tab"]') + if (tab) activate(tab) +}) + +tablist.addEventListener('keydown', (e) => { + const idx = tabs.indexOf(e.target as HTMLElement) + if (idx < 0) return + let next: HTMLElement | undefined + if (e.key === 'ArrowRight') next = tabs[(idx + 1) % tabs.length] + else if (e.key === 'ArrowLeft') next = tabs[(idx - 1 + tabs.length) % tabs.length] + else if (e.key === 'Home') next = tabs[0] + else if (e.key === 'End') next = tabs[tabs.length - 1] + if (next) { + e.preventDefault() + activate(next) + } +}) diff --git a/src/server/internal/html/config.ts b/src/server/internal/html/config.ts index fde68327..d9e1d941 100644 --- a/src/server/internal/html/config.ts +++ b/src/server/internal/html/config.ts @@ -1,3 +1,5 @@ +import { Json } from 'ox' + import type * as Challenge from '../../../Challenge.js' import type * as Method from '../../../Method.js' @@ -13,6 +15,8 @@ export type Data< method extends Method.Method = Method.Method, config extends Record = {}, > = { + label: string + rootId: string config: config challenge: Challenge.FromMethods<[method]> text: { [k in keyof Text]-?: NonNullable } @@ -29,6 +33,24 @@ export const rootId = 'root' export const serviceWorkerParam = '__mppx_worker' +export const challengeIdAttr = 'data-mppx-challenge-id' + +export function getData< + method extends Method.Method = Method.Method, + config extends Record = {}, +>(methodName: method['name']): Data { + const g = globalThis as Record + if (!g.__mppx_data) { + const el = document.getElementById(dataId)! + g.__mppx_data = Json.parse(el.textContent) as Record + el.remove() + } + const map = g.__mppx_data as Record> + const challengeId = document.currentScript?.getAttribute(challengeIdAttr) + if (challengeId) return map[challengeId]! + return Object.values(map).find((d) => d.challenge.method === methodName)! +} + export const classNames = { error: 'mppx-error', header: 'mppx-header', @@ -41,6 +63,9 @@ export const classNames = { summaryAmount: 'mppx-summary-amount', summaryDescription: 'mppx-summary-description', summaryExpires: 'mppx-summary-expires', + tab: 'mppx-tab', + tabList: 'mppx-tablist', + tabPanel: 'mppx-tabpanel', } export function sanitize(str: string): string { @@ -208,6 +233,41 @@ export function style(theme: { ` } +export function tabStyle() { + return html` + + ` +} + export function showError(message: string) { const existing = document.getElementById(errorId) if (existing) { diff --git a/src/server/internal/html/tsconfig.compose.json b/src/server/internal/html/tsconfig.compose.json new file mode 100644 index 00000000..356e02e6 --- /dev/null +++ b/src/server/internal/html/tsconfig.compose.json @@ -0,0 +1,8 @@ +{ + "extends": "../../../../tsconfig.base.json", + "compilerOptions": { + "lib": ["es2022", "dom"], + "types": [] + }, + "include": ["compose.main.ts"] +} diff --git a/src/stripe/server/internal/html/main.ts b/src/stripe/server/internal/html/main.ts index bc230d48..2c5d4b0f 100644 --- a/src/stripe/server/internal/html/main.ts +++ b/src/stripe/server/internal/html/main.ts @@ -1,6 +1,5 @@ import type { Appearance } from '@stripe/stripe-js' import { loadStripe } from '@stripe/stripe-js/pure' -import { Json } from 'ox' import { stripe } from '../../../../client/index.js' import * as Html from '../../../../server/internal/html/config.js' @@ -9,13 +8,9 @@ import type { charge as chargeClient } from '../../../../stripe/client/Charge.js import type { charge } from '../../../../stripe/server/Charge.js' import type * as Methods from '../../../Methods.js' -const dataElement = document.getElementById(Html.dataId)! -const data = Json.parse(dataElement.textContent) as Html.Data< - typeof Methods.charge, - NonNullable -> +const data = Html.getData>('stripe') -const root = document.getElementById(Html.rootId)! +const root = document.getElementById(data.rootId)! const css = String.raw const style = document.createElement('style') @@ -170,5 +165,3 @@ async function createToken(opts: chargeClient.OnChallengeParameters) { const json = (await res.json()) as { spt: string } return json.spt } - -dataElement.remove() diff --git a/src/tempo/server/internal/html/main.ts b/src/tempo/server/internal/html/main.ts index 3d8c9f56..28273f3c 100644 --- a/src/tempo/server/internal/html/main.ts +++ b/src/tempo/server/internal/html/main.ts @@ -1,5 +1,4 @@ import { local, Provider } from 'accounts' -import { Json } from 'ox' import { createClient, custom, http } from 'viem' import { tempoModerato, tempoLocalnet } from 'viem/chains' @@ -8,10 +7,9 @@ import * as Html from '../../../../server/internal/html/config.js' import { submitCredential } from '../../../../server/internal/html/serviceWorker.client.js' import type * as Methods from '../../../Methods.js' -const dataElement = document.getElementById(Html.dataId)! -const data = Json.parse(dataElement.textContent) as Html.Data +const data = Html.getData('tempo') -const root = document.getElementById(Html.rootId)! +const root = document.getElementById(data.rootId)! const css = String.raw const style = document.createElement('style') @@ -107,5 +105,3 @@ button.onclick = async () => { } } root.appendChild(button) - -dataElement.remove() diff --git a/test/html/compose.test.ts b/test/html/compose.test.ts new file mode 100644 index 00000000..bb0c2e06 --- /dev/null +++ b/test/html/compose.test.ts @@ -0,0 +1,144 @@ +import { expect, test } from '@playwright/test' + +test('compose renders tabs for multiple methods', async ({ page }) => { + await page.goto('/compose', { waitUntil: 'domcontentloaded' }) + + // Verify 402 payment page rendered + await expect(page.getByText('Payment Required')).toBeVisible() + + // Both tabs visible + const tempoTab = page.getByRole('tab', { name: 'Tempo' }) + const stripeTab = page.getByRole('tab', { name: 'Stripe' }) + await expect(tempoTab).toBeVisible() + await expect(stripeTab).toBeVisible() + + // Tempo tab is active by default + await expect(tempoTab).toHaveAttribute('aria-selected', 'true') + await expect(stripeTab).toHaveAttribute('aria-selected', 'false') + + // Tempo panel visible, Stripe panel hidden + const tempoPanel = page.locator('#mppx-panel-0') + const stripePanel = page.locator('#mppx-panel-1') + await expect(tempoPanel).toBeVisible() + await expect(stripePanel).toBeHidden() + + // Tempo content rendered + await expect(page.getByRole('button', { name: /continue with tempo/i })).toBeVisible() +}) + +test('compose tab switching', async ({ page }) => { + await page.goto('/compose', { waitUntil: 'domcontentloaded' }) + + const tempoTab = page.getByRole('tab', { name: 'Tempo' }) + const stripeTab = page.getByRole('tab', { name: 'Stripe' }) + + // Click Stripe tab + await stripeTab.click() + await expect(stripeTab).toHaveAttribute('aria-selected', 'true') + await expect(tempoTab).toHaveAttribute('aria-selected', 'false') + + // Stripe panel visible, Tempo panel hidden + await expect(page.locator('#mppx-panel-1')).toBeVisible() + await expect(page.locator('#mppx-panel-0')).toBeHidden() + + // Click back to Tempo + await tempoTab.click() + await expect(tempoTab).toHaveAttribute('aria-selected', 'true') + await expect(page.locator('#mppx-panel-0')).toBeVisible() +}) + +test('compose arrow key navigation', async ({ page }) => { + await page.goto('/compose', { waitUntil: 'domcontentloaded' }) + + const tempoTab = page.getByRole('tab', { name: 'Tempo' }) + const stripeTab = page.getByRole('tab', { name: 'Stripe' }) + + // Focus Tempo tab and press ArrowRight + await tempoTab.focus() + await page.keyboard.press('ArrowRight') + await expect(stripeTab).toHaveAttribute('aria-selected', 'true') + await expect(stripeTab).toBeFocused() + + // ArrowRight wraps to first tab + await page.keyboard.press('ArrowRight') + await expect(tempoTab).toHaveAttribute('aria-selected', 'true') + await expect(tempoTab).toBeFocused() + + // ArrowLeft wraps to last tab + await page.keyboard.press('ArrowLeft') + await expect(stripeTab).toHaveAttribute('aria-selected', 'true') + await expect(stripeTab).toBeFocused() +}) + +test('compose service worker endpoint', async ({ page }) => { + const response = await page.goto('/compose?__mppx_worker') + expect(response?.headers()['content-type']).toContain('application/javascript') + expect(response?.status()).toBe(200) +}) + +test('compose tab switching updates URL query param', async ({ page }) => { + await page.goto('/compose', { waitUntil: 'domcontentloaded' }) + + const stripeTab = page.getByRole('tab', { name: 'Stripe' }) + + // Click Stripe tab — URL should update + await stripeTab.click() + expect(new URL(page.url()).searchParams.get('__mppx_tab')).toBe('stripe') + + // Click Tempo tab — URL should update + await page.getByRole('tab', { name: 'Tempo' }).click() + expect(new URL(page.url()).searchParams.get('__mppx_tab')).toBe('tempo') +}) + +test('compose restores tab from URL query param', async ({ page }) => { + await page.goto('/compose?__mppx_tab=stripe', { waitUntil: 'domcontentloaded' }) + + // Stripe tab should be active + await expect(page.getByRole('tab', { name: 'Stripe' })).toHaveAttribute('aria-selected', 'true') + await expect(page.getByRole('tab', { name: 'Tempo' })).toHaveAttribute('aria-selected', 'false') + + // Stripe panel visible, Tempo panel hidden + await expect(page.locator('#mppx-panel-1')).toBeVisible() + await expect(page.locator('#mppx-panel-0')).toBeHidden() +}) + +test('compose duplicate method names get unique slugs', async ({ page }) => { + await page.goto('/compose-duplicates', { waitUntil: 'domcontentloaded' }) + + const tabs = page.getByRole('tab') + await expect(tabs).toHaveCount(3) + + // Click second Stripe tab — should get stripe-2 slug + await tabs.nth(2).click() + expect(new URL(page.url()).searchParams.get('__mppx_tab')).toBe('stripe-2') + + // Click first Stripe tab — should get stripe slug + await tabs.nth(1).click() + expect(new URL(page.url()).searchParams.get('__mppx_tab')).toBe('stripe') +}) + +test('compose restores duplicate method tab from URL', async ({ page }) => { + await page.goto('/compose-duplicates?__mppx_tab=stripe-2', { waitUntil: 'domcontentloaded' }) + + const tabs = page.getByRole('tab') + + // Third tab (second Stripe) should be active + await expect(tabs.nth(2)).toHaveAttribute('aria-selected', 'true') + await expect(tabs.nth(0)).toHaveAttribute('aria-selected', 'false') + await expect(tabs.nth(1)).toHaveAttribute('aria-selected', 'false') + + // Third panel visible + await expect(page.locator('#mppx-panel-2')).toBeVisible() + await expect(page.locator('#mppx-panel-0')).toBeHidden() + await expect(page.locator('#mppx-panel-1')).toBeHidden() +}) + +test('compose pay via tempo tab', async ({ page }) => { + await page.goto('/compose', { waitUntil: 'domcontentloaded' }) + + // Tempo tab is active by default, click pay + await page.getByRole('button', { name: /continue with tempo/i }).click() + + // Wait for service worker to submit credential and page to reload with paid response + await expect(page.locator('body')).toContainText('"ok":', { timeout: 30_000 }) +}) diff --git a/test/html/playwright.config.ts b/test/html/playwright.config.ts index 6217d152..c3785116 100644 --- a/test/html/playwright.config.ts +++ b/test/html/playwright.config.ts @@ -28,6 +28,11 @@ export default defineConfig({ testMatch: 'stripe.test.ts', use: { baseURL: `http://localhost:${port}` }, }, + { + name: 'compose', + testMatch: 'compose.test.ts', + use: { baseURL: `http://localhost:${port}` }, + }, ], }) diff --git a/test/html/server.ts b/test/html/server.ts index 8097be5d..4fe0f9f3 100644 --- a/test/html/server.ts +++ b/test/html/server.ts @@ -65,8 +65,6 @@ export async function startServer(port: number): Promise { return result.withReceipt(Response.json({ url: 'https://example.com/photo.jpg' })) } - if (url.pathname === createTokenUrl) return createSharedPaymentToken(request, stripeSecretKey) - if (url.pathname === '/stripe/charge') { const result = await mppx.stripe.charge({ amount: '1', @@ -93,6 +91,31 @@ export async function startServer(port: number): Promise { return result.withReceipt(Response.json({ fortune })) } + if (url.pathname === createTokenUrl) return createSharedPaymentToken(request, stripeSecretKey) + + if (url.pathname === '/compose') { + const result = await mppx.compose( + ['tempo/charge', { amount: '0.01', description: 'Composed payment' }], + ['stripe/charge', { amount: '1', currency: 'usd', decimals: 2 }], + )(request) + + if (result.status === 402) return result.challenge + + return result.withReceipt(Response.json({ ok: true })) + } + + if (url.pathname === '/compose-duplicates') { + const result = await mppx.compose( + ['tempo/charge', { amount: '0.01', description: 'Composed payment' }], + ['stripe/charge', { amount: '1', currency: 'usd', decimals: 2 }], + ['stripe/charge', { amount: '2', currency: 'usd', decimals: 2 }], + )(request) + + if (result.status === 402) return result.challenge + + return result.withReceipt(Response.json({ ok: true })) + } + return new Response('Not Found', { status: 404 }) }), ) diff --git a/tsconfig.json b/tsconfig.json index aac76729..e4b36ee0 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -8,6 +8,7 @@ "files": [], "references": [ { "path": "./src" }, + { "path": "./src/server/internal/html/tsconfig.compose.json" }, { "path": "./src/server/internal/html/tsconfig.worker.client.json" }, { "path": "./src/server/internal/html/tsconfig.worker.json" }, { "path": "./test" } From 2464de2f4993ba4d039a38a61fa9e344ea6d51be Mon Sep 17 00:00:00 2001 From: tmm Date: Thu, 2 Apr 2026 16:03:00 -0500 Subject: [PATCH 2/7] feat(html): compose --- pnpm-lock.yaml | 27 --------- src/server/Mppx.test.ts | 4 +- src/server/Mppx.ts | 70 +++++++++++++++--------- src/server/Transport.test.ts | 2 +- src/server/Transport.ts | 1 + src/server/internal/html/compose.main.ts | 29 ++++++++++ src/server/internal/html/config.ts | 20 ++++--- 7 files changed, 88 insertions(+), 65 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 94b74c2e..19b427a4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -197,33 +197,6 @@ importers: specifier: latest version: 8.0.3(@types/node@25.5.0)(esbuild@0.27.2)(tsx@4.21.0)(yaml@2.8.3) - examples/compose: - dependencies: - '@stripe/stripe-js': - specifier: ^8.7.0 - version: 8.9.0 - '@types/node': - specifier: latest - version: 25.5.0 - '@typescript/native-preview': - specifier: 7.0.0-dev.20260323.1 - version: 7.0.0-dev.20260323.1 - mppx: - specifier: workspace:* - version: link:../.. - stripe: - specifier: ^17.7.0 - version: 17.7.0 - typescript: - specifier: 5.9.3 - version: 5.9.3 - viem: - specifier: ^2.47.5 - version: 2.47.6(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6) - vite: - specifier: latest - version: 8.0.3(@types/node@25.5.0)(esbuild@0.27.2)(tsx@4.21.0)(yaml@2.8.3) - examples/session/multi-fetch: dependencies: '@remix-run/node-fetch-server': diff --git a/src/server/Mppx.test.ts b/src/server/Mppx.test.ts index c8daba62..35e07ffb 100644 --- a/src/server/Mppx.test.ts +++ b/src/server/Mppx.test.ts @@ -1255,7 +1255,7 @@ describe('compose', () => { // Data map with both entries const dataMatch = body.match( - / - ${htmlEntries - .map((entry) => - entry.handler._internal.html!.content.replace( - ' - ${options.html.content} - - - ` + const dataMap = { + [challenge.id]: { + label: challenge.method, + rootId: Html.rootId, + formattedAmount: amount, + config: options.html.config, + challenge, + text, + theme, + }, + } satisfies Record + + return Html.render({ + entries: [{ challenge, content: options.html.content }], + dataMap, + formattedAmount: amount, + text, + theme, + }) } if (error) { headers['Content-Type'] = 'application/problem+json' diff --git a/src/server/internal/html/config.ts b/src/server/internal/html/config.ts index 54cc7dbf..ea0cea60 100644 --- a/src/server/internal/html/config.ts +++ b/src/server/internal/html/config.ts @@ -2,11 +2,12 @@ import { Json } from 'ox' import type * as Challenge from '../../../Challenge.js' import type * as Method from '../../../Method.js' +import { tabScript } from './compose.main.gen.js' export type Options = { config: Record content: string - formatAmount: (request: any) => string | Promise + formatAmount: (request: Record) => string | Promise text: Text | undefined theme: Theme | undefined } @@ -26,16 +27,14 @@ export type Data< } } -export const dataId = '__MPPX_DATA__' - export const errorId = 'root_error' - export const rootId = 'root' +const dataId = '__MPPX_DATA__' export const serviceWorkerParam = '__mppx_worker' -export const challengeIdAttr = 'data-mppx-challenge-id' -export const remainingAttr = 'data-remaining' +const challengeIdAttr = 'data-mppx-challenge-id' +const remainingAttr = 'data-remaining' export function getData< method extends Method.Method = Method.Method, @@ -55,7 +54,21 @@ export function getData< return Object.values(map).find((d) => d.challenge.method === methodName)! } -export const classNames = { +export function showError(message: string) { + const existing = document.getElementById(errorId) + if (existing) { + existing.textContent = message + return + } + const el = document.createElement('p') + el.id = errorId + el.className = classNames.error + el.role = 'alert' + el.textContent = message + document.getElementById(rootId)?.after(el) +} + +const classNames = { error: 'mppx-error', header: 'mppx-header', logo: 'mppx-logo', @@ -72,23 +85,6 @@ export const classNames = { tabPanel: 'mppx-tabpanel', } -export function sanitize(str: string): string { - return str - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, ''') -} - -export function sanitizeRecord>(record: type): type { - return Object.fromEntries( - Object.entries(record).map(([key, value]) => [key, sanitize(value)]), - ) as type -} - -export const html = String.raw - class CssVar { readonly name: string constructor(token: string) { @@ -114,17 +110,236 @@ export const vars = { spacingUnit: new CssVar('spacing-unit'), } as const -export function font(theme: Theme) { - if (!theme.fontUrl) return '' - return html` - ` +export const defaultText = { + expires: 'Expires at', + pay: 'Pay', + paymentRequired: 'Payment Required', + title: 'Payment Required', +} as const satisfies Required + +export type Text = { + /** Prefix for the expiry line. @default 'Expires at' */ + expires?: string | undefined + /** Pay button label. @default 'Pay' */ + pay?: string | undefined + /** Badge label. @default 'Payment Required' */ + paymentRequired?: string | undefined + /** Page title. @default text.paymentRequired */ + title?: string | undefined +} + +export const defaultTheme = { + colorScheme: 'light dark', + fontFamily: 'system-ui, -apple-system, sans-serif', + fontSizeBase: '16px', + radius: '6px', + spacingUnit: '2px', + + accent: ['#171717', '#ededed'], + background: ['#ffffff', '#0a0a0a'], + border: ['#e5e5e5', '#2e2e2e'], + foreground: ['#0a0a0a', '#ededed'], + muted: ['#666666', '#a1a1a1'], + negative: ['#e5484d', '#e5484d'], + positive: ['#30a46c', '#30a46c'], + surface: ['#f5f5f5', '#1a1a1a'], +} as const satisfies Required> + +export type Theme = { + /** Color scheme. @default 'light dark' */ + colorScheme?: 'light' | 'dark' | 'light dark' | undefined + /** Font family. @default 'system-ui, -apple-system, sans-serif' */ + fontFamily?: string | undefined + /** Base font size. @default '16px' */ + fontSizeBase?: string | undefined + /** Font URL to inject (e.g. Google Fonts ``). */ + fontUrl?: string | undefined + /** Favicon URL. Light/dark variants supported. Falls back to host's favicon via Google S2 service. */ + favicon?: string | { light: string; dark: string } | undefined + /** Logo URL shown in header. Light/dark variants supported. */ + logo?: string | { light: string; dark: string } | undefined + /** Border radius. @default '6px' */ + radius?: string | undefined + /** The base spacing unit that all other spacing is derived from. Increase or decrease this value to make your layout more or less spacious. @default '2px' */ + spacingUnit?: string | undefined + + /** Accent color (buttons, links). @default ['#171717', '#ededed'] */ + accent?: LightDark | undefined + /** Page background. @default ['#ffffff', '#0a0a0a'] */ + background?: LightDark | undefined + /** Border color. @default ['#e5e5e5', '#2e2e2e'] */ + border?: LightDark | undefined + /** Primary text/content color. @default ['#0a0a0a', '#ededed'] */ + foreground?: LightDark | undefined + /** Secondary/muted text. @default ['#666666', '#a1a1a1'] */ + muted?: LightDark | undefined + /** Error/danger color. @default ['#e5484d', '#e5484d'] */ + negative?: LightDark | undefined + /** Success color. @default ['#30a46c', '#30a46c'] */ + positive?: LightDark | undefined + /** Input/card surface. @default ['#f5f5f5', '#1a1a1a'] */ + surface?: LightDark | undefined } -export function style(theme: { +type LightDark = string | readonly [light: string, dark: string] + +function resolveColor( + value: Theme[(typeof colorTokens)[number]] | undefined, + fallback: readonly [string, string], +): readonly [light: string, dark: string] { + if (!value) return fallback + if (typeof value === 'string') return [value, value] + return value +} + +const colorTokens = [ + 'accent', + 'negative', + 'positive', + 'background', + 'foreground', + 'muted', + 'surface', + 'border', +] as const satisfies readonly (keyof typeof defaultTheme)[] + +export function resolveOptions(options: Options): { + theme: ResolvedTheme + text: ResolvedText +} { + const theme = mergeDefined( + { + favicon: undefined as Theme['favicon'], + fontUrl: undefined as Theme['fontUrl'], + logo: undefined as Theme['logo'], + ...defaultTheme, + }, + (options.theme as never) ?? {}, + ) + const text = sanitizeRecord(mergeDefined(defaultText, (options.text as never) ?? {})) + return { theme, text } +} + +type ResolvedTheme = { + [k in keyof Omit]-?: NonNullable +} & Pick +type ResolvedText = { [k in keyof Text]-?: NonNullable } + +const html = String.raw + +export function render(options: { + entries: readonly { + challenge: Challenge.Challenge + content: string + }[] + dataMap: Record + formattedAmount: string + /** Whether to render panel wrappers around each entry. @default entries.length > 1 */ + panels?: boolean | undefined + text: ResolvedText + theme: ResolvedTheme +}): string { + const { entries, dataMap, formattedAmount, text, theme } = options + const firstChallenge = entries[0]!.challenge + const hasTabs = entries.length > 1 + const hasPanels = options.panels ?? hasTabs + const dataValues = Object.values(dataMap) + + const tabListHtml = hasTabs + ? html`` + : '' + + const panelsHtml = hasPanels + ? entries + .map( + (_entry, i) => + html`
+
+
`, + ) + .join('') + : html`
` + + const contentScripts = hasTabs + ? entries + .map((entry) => + entry.content.replace( + ' + ${contentScripts} ${hasTabs ? tabScript : ''} + + + ` +} + +function style(theme: { [k in keyof Omit]-?: NonNullable }) { const colors = Object.fromEntries( @@ -145,7 +360,6 @@ export function style(theme: { : '' return html` - ` -} - -export function showError(message: string) { - const existing = document.getElementById(errorId) - if (existing) { - existing.textContent = message - return - } - const el = document.createElement('p') - el.id = errorId - el.className = classNames.error - el.role = 'alert' - el.textContent = message - document.getElementById(rootId)?.after(el) -} - -export function favicon(theme: Theme, realm: string) { +function favicon(theme: Theme, realm: string) { if (typeof theme.favicon === 'string') return html`` if (typeof theme.favicon === 'object') { @@ -313,7 +478,17 @@ export function favicon(theme: Theme, realm: string) { } } -export function logo(value: Theme) { +function font(theme: Theme) { + if (!theme.fontUrl) return '' + return html` + ` +} + +function logo(value: Theme) { if (typeof value.logo === 'undefined') return '' if (typeof value.logo === 'string') return html`` @@ -329,97 +504,176 @@ export function logo(value: Theme) { .join('\n') } -export type Text = { - /** Prefix for the expiry line. @default 'Expires at' */ - expires?: string | undefined - /** Pay button label. @default 'Pay' */ - pay?: string | undefined - /** Badge label. @default 'Payment Required' */ - paymentRequired?: string | undefined - /** Page title. @default text.paymentRequired */ - title?: string | undefined +function tabStyle() { + return html` + + ` } -export const defaultText = { - expires: 'Expires at', - pay: 'Pay', - paymentRequired: 'Payment Required', - title: 'Payment Required', -} as const satisfies Required - -export type Theme = { - /** Color scheme. @default 'light dark' */ - colorScheme?: 'light' | 'dark' | 'light dark' | undefined - /** Font family. @default 'system-ui, -apple-system, sans-serif' */ - fontFamily?: string | undefined - /** Base font size. @default '16px' */ - fontSizeBase?: string | undefined - /** Font URL to inject (e.g. Google Fonts ``). */ - fontUrl?: string | undefined - /** Favicon URL. Light/dark variants supported. Falls back to host's favicon via Google S2 service. */ - favicon?: string | { light: string; dark: string } | undefined - /** Logo URL shown in header. Light/dark variants supported. */ - logo?: string | { light: string; dark: string } | undefined - /** Border radius. @default '6px' */ - radius?: string | undefined - /** The base spacing unit that all other spacing is derived from. Increase or decrease this value to make your layout more or less spacious. @default '2px' */ - spacingUnit?: string | undefined +// Slimmed down Tailwind preflight +// https://github.com/tailwindlabs/tailwindcss/blob/main/packages/tailwindcss/preflight.css +const preflight = html`` - /** Accent color (buttons, links). @default ['#171717', '#ededed'] */ - accent?: LightDark | undefined - /** Page background. @default ['#ffffff', '#0a0a0a'] */ - background?: LightDark | undefined - /** Border color. @default ['#e5e5e5', '#2e2e2e'] */ - border?: LightDark | undefined - /** Primary text/content color. @default ['#0a0a0a', '#ededed'] */ - foreground?: LightDark | undefined - /** Secondary/muted text. @default ['#666666', '#a1a1a1'] */ - muted?: LightDark | undefined - /** Error/danger color. @default ['#e5484d', '#e5484d'] */ - negative?: LightDark | undefined - /** Success color. @default ['#30a46c', '#30a46c'] */ - positive?: LightDark | undefined - /** Input/card surface. @default ['#f5f5f5', '#1a1a1a'] */ - surface?: LightDark | undefined +function sanitize(str: string): string { + return str + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, ''') } -export type LightDark = string | readonly [light: string, dark: string] - -export const defaultTheme = { - colorScheme: 'light dark', - fontFamily: 'system-ui, -apple-system, sans-serif', - fontSizeBase: '16px', - radius: '6px', - spacingUnit: '2px', - - accent: ['#171717', '#ededed'], - background: ['#ffffff', '#0a0a0a'], - border: ['#e5e5e5', '#2e2e2e'], - foreground: ['#0a0a0a', '#ededed'], - muted: ['#666666', '#a1a1a1'], - negative: ['#e5484d', '#e5484d'], - positive: ['#30a46c', '#30a46c'], - surface: ['#f5f5f5', '#1a1a1a'], -} as const satisfies Required> - -export const colorTokens = [ - 'accent', - 'negative', - 'positive', - 'background', - 'foreground', - 'muted', - 'surface', - 'border', -] as const satisfies readonly (keyof typeof defaultTheme)[] - -export function resolveColor( - value: Theme[(typeof colorTokens)[number]] | undefined, - fallback: readonly [string, string], -): readonly [light: string, dark: string] { - if (!value) return fallback - if (typeof value === 'string') return [value, value] - return value +function sanitizeRecord>(record: type): type { + return Object.fromEntries( + Object.entries(record).map(([key, value]) => [key, sanitize(value)]), + ) as type } export function mergeDefined(defaults: type, value: DeepPartial | undefined): type { @@ -452,27 +706,3 @@ type DeepPartial = { function isPlainObject(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value) } - -// Slimmed down Tailwind preflight -// https://github.com/tailwindlabs/tailwindcss/blob/main/packages/tailwindcss/preflight.css -const reset = html` - *, ::after, ::before, ::backdrop, ::file-selector-button { box-sizing: border-box; margin: 0; - padding: 0; border: 0 solid; border-color: ${vars.border}; } html, :host { line-height: 1.5; - -webkit-text-size-adjust: 100%; tab-size: 4; -webkit-tap-highlight-color: transparent; } h1, h2, - h3, h4, h5, h6 { font-size: inherit; font-weight: inherit; } a { color: inherit; - -webkit-text-decoration: inherit; text-decoration: inherit; } b, strong { font-weight: bolder; } - code, kbd, samp, pre { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, - 'Liberation Mono', 'Courier New', monospace; font-size: 1em; } small { font-size: 80%; } ol, ul, - menu { list-style: none; } img, svg, video, canvas, audio, iframe, embed, object { display: block; - vertical-align: middle; } img, video { max-width: 100%; height: auto; } button, input, select, - optgroup, textarea, ::file-selector-button { font: inherit; font-feature-settings: inherit; - font-variation-settings: inherit; letter-spacing: inherit; color: inherit; border-radius: 0; - background-color: transparent; opacity: 1; } ::file-selector-button { margin-inline-end: 4px; } - ::placeholder { opacity: 1; } @supports (not (-webkit-appearance: -apple-pay-button)) or - (contain-intrinsic-size: 1px) { ::placeholder { color: color-mix(in oklab, currentcolor 50%, - transparent); } } textarea { resize: vertical; } ::-webkit-search-decoration { -webkit-appearance: - none; } :-moz-ui-invalid { box-shadow: none; } button, input:where([type='button'], - [type='reset'], [type='submit']), ::file-selector-button { appearance: button; } - ::-webkit-inner-spin-button, ::-webkit-outer-spin-button { height: auto; } - [hidden]:where(:not([hidden='until-found'])) { display: none !important; } -` diff --git a/src/tempo/server/internal/html/main.ts b/src/tempo/server/internal/html/main.ts index 28273f3c..49c02be9 100644 --- a/src/tempo/server/internal/html/main.ts +++ b/src/tempo/server/internal/html/main.ts @@ -83,17 +83,23 @@ button.onclick = async () => { document.getElementById(Html.errorId)?.remove() button.disabled = true - const chain = [...(provider?.chains ?? []), tempoModerato, tempoLocalnet].find( - (x) => x.id === data.challenge.request.methodDetails?.chainId, - ) - const client = createClient({ chain, transport: custom(provider) }) const account = await (async () => { const accounts = await provider.request({ method: 'eth_accounts' }) if (accounts.length > 0) return accounts.at(0) const result = await provider.request({ method: 'wallet_connect' }) return result.accounts[0]?.address })() - const method = tempo({ account, getClient: () => client })[0] + const method = tempo({ + account, + getClient(opts) { + const chainId = opts.chainId ?? data.challenge.request.methodDetails?.chainId + const chain = [...(provider?.chains ?? []), tempoModerato, tempoLocalnet].find( + (x) => x.id === chainId, + ) + return createClient({ chain, transport: custom(provider) }) + return + }, + })[0] const credential = await method.createCredential({ challenge: data.challenge, context: {} }) await submitCredential(credential) From c0eac3a94628d8394db45f8ccc25bd4c47e0e644 Mon Sep 17 00:00:00 2001 From: tmm Date: Fri, 3 Apr 2026 13:41:32 -0400 Subject: [PATCH 4/7] fix: types --- pnpm-lock.yaml | 166 +++++++++++-------------- pnpm-workspace.yaml | 1 + src/server/internal/html/config.ts | 2 +- src/tempo/server/internal/html/main.ts | 1 - 4 files changed, 75 insertions(+), 95 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 19b427a4..c283d3a4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7,6 +7,7 @@ settings: overrides: mppx: workspace:* vitest: npm:@voidzero-dev/vite-plus-test@~0.1.14 + typescript: ~5.9.3 ox: 0.14.7 viem: ^2.47.5 path-to-regexp@<8.4.0: 8.4.0 @@ -139,7 +140,7 @@ importers: specifier: workspace:* version: link:../.. typescript: - specifier: latest + specifier: ~5.9.3 version: 5.9.3 viem: specifier: ^2.47.5 @@ -191,7 +192,7 @@ importers: specifier: latest version: 6.0.1(vite@8.0.3(@types/node@25.5.0)(esbuild@0.27.2)(tsx@4.21.0)(yaml@2.8.3)) typescript: - specifier: latest + specifier: ~5.9.3 version: 5.9.3 vite: specifier: latest @@ -215,7 +216,7 @@ importers: specifier: latest version: 4.21.0 typescript: - specifier: latest + specifier: ~5.9.3 version: 5.9.3 viem: specifier: ^2.47.5 @@ -242,7 +243,7 @@ importers: specifier: latest version: 4.21.0 typescript: - specifier: latest + specifier: ~5.9.3 version: 5.9.3 viem: specifier: ^2.47.5 @@ -272,7 +273,7 @@ importers: specifier: ^17.7.0 version: 17.7.0 typescript: - specifier: latest + specifier: ~5.9.3 version: 5.9.3 vite: specifier: latest @@ -291,13 +292,13 @@ importers: dependencies: accounts: specifier: 0.4.12 - version: 0.4.12(@types/react@19.2.14)(@wagmi/core@3.4.0(@tanstack/query-core@5.90.20)(@types/react@19.2.14)(ox@0.14.7(typescript@6.0.2)(zod@4.3.6))(react@19.2.4)(typescript@6.0.2)(use-sync-external-store@1.4.0(react@19.2.4))(viem@2.47.6(bufferutil@4.1.0)(typescript@6.0.2)(utf-8-validate@5.0.10)(zod@4.3.6)))(react@19.2.4)(typescript@6.0.2)(use-sync-external-store@1.4.0(react@19.2.4))(viem@2.47.6(bufferutil@4.1.0)(typescript@6.0.2)(utf-8-validate@5.0.10)(zod@4.3.6)) + version: 0.4.12(@modelcontextprotocol/sdk@1.26.0(zod@4.3.6))(@types/react@19.2.14)(@wagmi/core@3.4.0(@tanstack/query-core@5.90.20)(@types/react@19.2.14)(ox@0.14.7(typescript@5.9.3)(zod@4.3.6))(react@19.2.4)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@19.2.4))(viem@2.47.6(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6)))(elysia@1.4.27(@sinclair/typebox@0.34.48)(exact-mirror@0.2.7(@sinclair/typebox@0.34.48))(file-type@21.3.2)(openapi-types@12.1.3)(typescript@5.9.3))(express@5.2.1)(hono@4.12.7)(openapi-types@12.1.3)(react@19.2.4)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@19.2.4))(viem@2.47.6(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6)) mppx: specifier: workspace:* version: link:../../../../.. viem: specifier: ^2.47.5 - version: 2.47.6(bufferutil@4.1.0)(typescript@6.0.2)(utf-8-validate@5.0.10)(zod@4.3.6) + version: 2.47.6(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6) packages: @@ -1507,7 +1508,7 @@ packages: sugarss: ^5.0.0 terser: ^5.16.0 tsx: ^4.8.1 - typescript: ^5.0.0 + typescript: ~5.9.3 unplugin-unused: ^0.5.0 yaml: '>=2.8.3' peerDependenciesMeta: @@ -1632,7 +1633,7 @@ packages: '@wagmi/core': 3.4.0 '@walletconnect/ethereum-provider': ^2.21.1 porto: ~0.2.35 - typescript: '>=5.7.3' + typescript: ~5.9.3 viem: ^2.47.5 peerDependenciesMeta: '@base-org/account': @@ -1657,7 +1658,7 @@ packages: peerDependencies: '@tanstack/query-core': '>=5.0.0' ox: 0.14.7 - typescript: '>=5.7.3' + typescript: ~5.9.3 viem: ^2.47.5 peerDependenciesMeta: '@tanstack/query-core': @@ -1670,7 +1671,7 @@ packages: abitype@1.2.3: resolution: {integrity: sha512-Ofer5QUnuUdTFsBRwARMoWKOH1ND5ehwYhJ3OJ/BQO+StkwQjHw0XyVh4vDttzHB7QOFhPHa/o413PJ82gU/Tg==} peerDependencies: - typescript: '>=5.0.4' + typescript: ~5.9.3 zod: ^3.22.0 || ^4.0.0 peerDependenciesMeta: typescript: @@ -2096,7 +2097,7 @@ packages: exact-mirror: '>= 0.0.9' file-type: 21.3.2 openapi-types: '>= 12.0.0' - typescript: '>= 5.0.0' + typescript: ~5.9.3 peerDependenciesMeta: '@types/bun': optional: true @@ -2810,7 +2811,7 @@ packages: mipd@0.0.7: resolution: {integrity: sha512-aAPZPNDQ3uMTdKbuO2YmAw2TxLHO0moa4YKAyETM/DTj5FloZo+a+8tU+iv4GmW+sOxKLSRwcSFuczk+Cpt6fg==} peerDependencies: - typescript: '>=5.0.4' + typescript: ~5.9.3 peerDependenciesMeta: typescript: optional: true @@ -2823,6 +2824,25 @@ packages: engines: {node: '>=10'} hasBin: true + mppx@0.4.12: + resolution: {integrity: sha512-qN84ijeWcm+agw+ne+xxcMJhEw3+LJGyNxTnOQePyOrubXdPiI6duMdwNL796FXjsLMm8HizATOZBWw+qBGaMA==} + hasBin: true + peerDependencies: + '@modelcontextprotocol/sdk': 1.26.0 + elysia: 1.4.26 + express: '>=5' + hono: 4.12.7 + viem: ^2.47.5 + peerDependenciesMeta: + '@modelcontextprotocol/sdk': + optional: true + elysia: + optional: true + express: + optional: true + hono: + optional: true + mri@1.2.0: resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} engines: {node: '>=4'} @@ -2916,7 +2936,7 @@ packages: ox@0.14.7: resolution: {integrity: sha512-zSQ/cfBdolj7U4++NAvH7sI+VG0T3pEohITCgcQj8KlawvTDY4vGVhDT64Atsm0d6adWfIYHDpu88iUBMMp+AQ==} peerDependencies: - typescript: '>=5.4.0' + typescript: ~5.9.3 peerDependenciesMeta: typescript: optional: true @@ -3429,7 +3449,7 @@ packages: engines: {node: ^18 || >=20} hasBin: true peerDependencies: - typescript: ^5.0.0 + typescript: ~5.9.3 peerDependenciesMeta: typescript: optional: true @@ -3458,11 +3478,6 @@ packages: engines: {node: '>=14.17'} hasBin: true - typescript@6.0.2: - resolution: {integrity: sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ==} - engines: {node: '>=14.17'} - hasBin: true - uint8array-extras@1.5.0: resolution: {integrity: sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==} engines: {node: '>=18'} @@ -3532,7 +3547,7 @@ packages: viem@2.47.6: resolution: {integrity: sha512-zExmbI99NGvMdYa7fmqSTLgkwh48dmhgEqFrUgkpL4kfG4XkVefZ8dZqIKVUhZo6Uhf0FrrEXOsHm9LUyIvI2Q==} peerDependencies: - typescript: '>=5.0.4' + typescript: ~5.9.3 peerDependenciesMeta: typescript: optional: true @@ -3590,7 +3605,7 @@ packages: peerDependencies: '@tanstack/react-query': '>=5.0.0' react: '>=18' - typescript: '>=5.7.3' + typescript: ~5.9.3 viem: ^2.47.5 peerDependenciesMeta: typescript: @@ -3694,7 +3709,7 @@ packages: hasBin: true peerDependencies: '@typescript/native-preview': '>=7.0.0' - typescript: '>=5' + typescript: ~5.9.3 peerDependenciesMeta: '@typescript/native-preview': optional: true @@ -4961,33 +4976,11 @@ snapshots: - react - use-sync-external-store - '@wagmi/core@3.4.0(@tanstack/query-core@5.90.20)(@types/react@19.2.14)(ox@0.14.7(typescript@6.0.2)(zod@4.3.6))(react@19.2.4)(typescript@6.0.2)(use-sync-external-store@1.4.0(react@19.2.4))(viem@2.47.6(bufferutil@4.1.0)(typescript@6.0.2)(utf-8-validate@5.0.10)(zod@4.3.6))': - dependencies: - eventemitter3: 5.0.1 - mipd: 0.0.7(typescript@6.0.2) - viem: 2.47.6(bufferutil@4.1.0)(typescript@6.0.2)(utf-8-validate@5.0.10)(zod@4.3.6) - zustand: 5.0.0(@types/react@19.2.14)(react@19.2.4)(use-sync-external-store@1.4.0(react@19.2.4)) - optionalDependencies: - '@tanstack/query-core': 5.90.20 - ox: 0.14.7(typescript@6.0.2)(zod@4.3.6) - typescript: 6.0.2 - transitivePeerDependencies: - - '@types/react' - - immer - - react - - use-sync-external-store - optional: true - abitype@1.2.3(typescript@5.9.3)(zod@4.3.6): optionalDependencies: typescript: 5.9.3 zod: 4.3.6 - abitype@1.2.3(typescript@6.0.2)(zod@4.3.6): - optionalDependencies: - typescript: 6.0.2 - zod: 4.3.6 - abort-controller@3.0.0: dependencies: event-target-shim: 5.0.1 @@ -4997,24 +4990,31 @@ snapshots: mime-types: 3.0.2 negotiator: 1.0.0 - accounts@0.4.12(@types/react@19.2.14)(@wagmi/core@3.4.0(@tanstack/query-core@5.90.20)(@types/react@19.2.14)(ox@0.14.7(typescript@6.0.2)(zod@4.3.6))(react@19.2.4)(typescript@6.0.2)(use-sync-external-store@1.4.0(react@19.2.4))(viem@2.47.6(bufferutil@4.1.0)(typescript@6.0.2)(utf-8-validate@5.0.10)(zod@4.3.6)))(react@19.2.4)(typescript@6.0.2)(use-sync-external-store@1.4.0(react@19.2.4))(viem@2.47.6(bufferutil@4.1.0)(typescript@6.0.2)(utf-8-validate@5.0.10)(zod@4.3.6)): + accounts@0.4.12(@modelcontextprotocol/sdk@1.26.0(zod@4.3.6))(@types/react@19.2.14)(@wagmi/core@3.4.0(@tanstack/query-core@5.90.20)(@types/react@19.2.14)(ox@0.14.7(typescript@5.9.3)(zod@4.3.6))(react@19.2.4)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@19.2.4))(viem@2.47.6(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6)))(elysia@1.4.27(@sinclair/typebox@0.34.48)(exact-mirror@0.2.7(@sinclair/typebox@0.34.48))(file-type@21.3.2)(openapi-types@12.1.3)(typescript@5.9.3))(express@5.2.1)(hono@4.12.7)(openapi-types@12.1.3)(react@19.2.4)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@19.2.4))(viem@2.47.6(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6)): dependencies: '@remix-run/fetch-router': 0.17.0 idb-keyval: 6.2.2 - mipd: 0.0.7(typescript@6.0.2) - mppx: 'link:' - ox: 0.14.7(typescript@6.0.2)(zod@4.3.6) + mipd: 0.0.7(typescript@5.9.3) + mppx: 0.4.12(@modelcontextprotocol/sdk@1.26.0(zod@4.3.6))(elysia@1.4.27(@sinclair/typebox@0.34.48)(exact-mirror@0.2.7(@sinclair/typebox@0.34.48))(file-type@21.3.2)(openapi-types@12.1.3)(typescript@5.9.3))(express@5.2.1)(hono@4.12.7)(openapi-types@12.1.3)(typescript@5.9.3)(viem@2.47.6(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6)) + ox: 0.14.7(typescript@5.9.3)(zod@4.3.6) tsx: 4.21.0 - webauthx: 0.1.0(typescript@6.0.2)(zod@4.3.6) + webauthx: 0.1.0(typescript@5.9.3)(zod@4.3.6) zod: 4.3.6 zustand: 5.0.12(@types/react@19.2.14)(react@19.2.4)(use-sync-external-store@1.4.0(react@19.2.4)) optionalDependencies: - '@wagmi/core': 3.4.0(@tanstack/query-core@5.90.20)(@types/react@19.2.14)(ox@0.14.7(typescript@6.0.2)(zod@4.3.6))(react@19.2.4)(typescript@6.0.2)(use-sync-external-store@1.4.0(react@19.2.4))(viem@2.47.6(bufferutil@4.1.0)(typescript@6.0.2)(utf-8-validate@5.0.10)(zod@4.3.6)) + '@wagmi/core': 3.4.0(@tanstack/query-core@5.90.20)(@types/react@19.2.14)(ox@0.14.7(typescript@5.9.3)(zod@4.3.6))(react@19.2.4)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@19.2.4))(viem@2.47.6(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6)) react: 19.2.4 - viem: 2.47.6(bufferutil@4.1.0)(typescript@6.0.2)(utf-8-validate@5.0.10)(zod@4.3.6) + viem: 2.47.6(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6) transitivePeerDependencies: + - '@cfworker/json-schema' + - '@modelcontextprotocol/sdk' - '@types/react' + - elysia + - express + - hono - immer + - openapi-types + - supports-color - typescript - use-sync-external-store @@ -6147,14 +6147,29 @@ snapshots: optionalDependencies: typescript: 5.9.3 - mipd@0.0.7(typescript@6.0.2): - optionalDependencies: - typescript: 6.0.2 - mkdirp-classic@0.5.3: {} mkdirp@1.0.4: {} + mppx@0.4.12(@modelcontextprotocol/sdk@1.26.0(zod@4.3.6))(elysia@1.4.27(@sinclair/typebox@0.34.48)(exact-mirror@0.2.7(@sinclair/typebox@0.34.48))(file-type@21.3.2)(openapi-types@12.1.3)(typescript@5.9.3))(express@5.2.1)(hono@4.12.7)(openapi-types@12.1.3)(typescript@5.9.3)(viem@2.47.6(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6)): + dependencies: + '@remix-run/fetch-proxy': 0.7.1 + '@remix-run/node-fetch-server': 0.13.0 + incur: 0.3.1(openapi-types@12.1.3) + ox: 0.14.7(typescript@5.9.3)(zod@4.3.6) + viem: 2.47.6(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6) + zod: 4.3.6 + optionalDependencies: + '@modelcontextprotocol/sdk': 1.26.0(zod@4.3.6) + elysia: 1.4.27(@sinclair/typebox@0.34.48)(exact-mirror@0.2.7(@sinclair/typebox@0.34.48))(file-type@21.3.2)(openapi-types@12.1.3)(typescript@5.9.3) + express: 5.2.1 + hono: 4.12.7 + transitivePeerDependencies: + - '@cfworker/json-schema' + - openapi-types + - supports-color + - typescript + mri@1.2.0: {} mrmime@2.0.1: {} @@ -6241,21 +6256,6 @@ snapshots: transitivePeerDependencies: - zod - ox@0.14.7(typescript@6.0.2)(zod@4.3.6): - dependencies: - '@adraffy/ens-normalize': 1.11.1 - '@noble/ciphers': 1.3.0 - '@noble/curves': 1.9.1 - '@noble/hashes': 1.8.0 - '@scure/bip32': 1.7.0 - '@scure/bip39': 1.6.0 - abitype: 1.2.3(typescript@6.0.2)(zod@4.3.6) - eventemitter3: 5.0.1 - optionalDependencies: - typescript: 6.0.2 - transitivePeerDependencies: - - zod - oxfmt@0.42.0: dependencies: tinypool: 2.1.0 @@ -6906,9 +6906,6 @@ snapshots: typescript@5.9.3: {} - typescript@6.0.2: - optional: true - uint8array-extras@1.5.0: {} undici-types@5.26.5: {} @@ -6976,23 +6973,6 @@ snapshots: - utf-8-validate - zod - viem@2.47.6(bufferutil@4.1.0)(typescript@6.0.2)(utf-8-validate@5.0.10)(zod@4.3.6): - dependencies: - '@noble/curves': 1.9.1 - '@noble/hashes': 1.8.0 - '@scure/bip32': 1.7.0 - '@scure/bip39': 1.6.0 - abitype: 1.2.3(typescript@6.0.2)(zod@4.3.6) - isows: 1.0.7(ws@8.18.3(bufferutil@4.1.0)(utf-8-validate@5.0.10)) - ox: 0.14.7(typescript@6.0.2)(zod@4.3.6) - ws: 8.18.3(bufferutil@4.1.0)(utf-8-validate@5.0.10) - optionalDependencies: - typescript: 6.0.2 - transitivePeerDependencies: - - bufferutil - - utf-8-validate - - zod - vite-plus@0.1.14(@types/node@25.5.0)(bufferutil@4.1.0)(esbuild@0.27.2)(tsx@4.21.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(vite@8.0.3(@types/node@25.5.0)(esbuild@0.27.2)(tsx@4.21.0)(yaml@2.8.3))(yaml@2.8.3): dependencies: '@oxc-project/types': 0.122.0 @@ -7078,9 +7058,9 @@ snapshots: - ox - porto - webauthx@0.1.0(typescript@6.0.2)(zod@4.3.6): + webauthx@0.1.0(typescript@5.9.3)(zod@4.3.6): dependencies: - ox: 0.14.7(typescript@6.0.2)(zod@4.3.6) + ox: 0.14.7(typescript@5.9.3)(zod@4.3.6) transitivePeerDependencies: - typescript - zod diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 64a5ef06..1aee9200 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -7,6 +7,7 @@ packages: overrides: mppx: 'workspace:*' vitest: 'npm:@voidzero-dev/vite-plus-test@~0.1.14' + typescript: $typescript ox: '0.14.7' viem: '^2.47.5' path-to-regexp@<8.4.0: '8.4.0' diff --git a/src/server/internal/html/config.ts b/src/server/internal/html/config.ts index ea0cea60..d8939077 100644 --- a/src/server/internal/html/config.ts +++ b/src/server/internal/html/config.ts @@ -7,7 +7,7 @@ import { tabScript } from './compose.main.gen.js' export type Options = { config: Record content: string - formatAmount: (request: Record) => string | Promise + formatAmount: (request: any) => string | Promise text: Text | undefined theme: Theme | undefined } diff --git a/src/tempo/server/internal/html/main.ts b/src/tempo/server/internal/html/main.ts index 49c02be9..7dff0810 100644 --- a/src/tempo/server/internal/html/main.ts +++ b/src/tempo/server/internal/html/main.ts @@ -97,7 +97,6 @@ button.onclick = async () => { (x) => x.id === chainId, ) return createClient({ chain, transport: custom(provider) }) - return }, })[0] From baf147406ab7015e1be4ee9a51a51d7fdf860768 Mon Sep 17 00:00:00 2001 From: tmm Date: Fri, 3 Apr 2026 13:49:33 -0400 Subject: [PATCH 5/7] fix: test --- src/server/Mppx.test.ts | 4 ++-- src/server/Transport.test.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/server/Mppx.test.ts b/src/server/Mppx.test.ts index a6d51b9a..f7cfd0d8 100644 --- a/src/server/Mppx.test.ts +++ b/src/server/Mppx.test.ts @@ -1255,7 +1255,7 @@ describe('compose', () => { // Data map with both entries const dataMatch = body.match( - /`)}\n` + // Confirm test-only dead code was eliminated for non-test builds + if (mode !== 'test') { + const markers = testOnlyMarkers[entry] ?? [] + const leaked = markers.filter((m) => code.includes(m)) + if (leaked.length > 0) + throw new Error( + `Dead code elimination failed for ${entry} (mode=${mode}). ` + + `Test-only markers found in bundle: ${leaked.join(', ')}`, + ) + } + fs.writeFileSync(outFile, content) fs.rmSync(outDir, { recursive: true }) console.log(`wrote ${path.relative(root, outFile)} (${formatBundleSize(bundleBytes)})`) diff --git a/src/server/Mppx.ts b/src/server/Mppx.ts index 573baa03..526fbb1f 100644 --- a/src/server/Mppx.ts +++ b/src/server/Mppx.ts @@ -733,7 +733,7 @@ export function compose( // Serve service worker for html-enabled compose if (new URL(input.url).searchParams.has(Html.serviceWorkerParam)) { const hasHtml = handlers.some((h) => (h as ConfiguredHandler)._internal?.html) - if (hasHtml) { + if (hasHtml) return { status: 402, challenge: new Response(serviceWorker, { @@ -744,7 +744,6 @@ export function compose( }, }), } as MethodFn.Response - } } // Try to extract a Payment credential to decide whether to dispatch or challenge. diff --git a/test/html/compose.test.ts b/test/html/compose.test.ts index bb0c2e06..03616cca 100644 --- a/test/html/compose.test.ts +++ b/test/html/compose.test.ts @@ -1,5 +1,7 @@ import { expect, test } from '@playwright/test' +import { getStripePaymentFrame } from './utils.js' + test('compose renders tabs for multiple methods', async ({ page }) => { await page.goto('/compose', { waitUntil: 'domcontentloaded' }) @@ -133,6 +135,43 @@ test('compose restores duplicate method tab from URL', async ({ page }) => { await expect(page.locator('#mppx-panel-1')).toBeHidden() }) +test('compose pay via stripe tab', async ({ page }, testInfo) => { + test.slow() + + await page.goto('/compose', { waitUntil: 'domcontentloaded' }) + + // Switch to Stripe tab + await page.getByRole('tab', { name: 'Stripe' }).click() + await expect(page.getByRole('button', { name: 'Pay' })).toBeVisible({ timeout: 10_000 }) + + if (!testInfo.project.use.headless) { + const stripeFrame = await getStripePaymentFrame(page) + const numberInput = stripeFrame.locator('[name="number"]') + const cardButton = stripeFrame.locator('[data-value="card"]') + + await cardButton.isVisible({ timeout: 90_000 }) + await cardButton.click() + await page.waitForTimeout(1_000) + + await expect(numberInput).toBeVisible({ timeout: 90_000 }) + await numberInput.fill('4242424242424242') + await stripeFrame.locator('[name="expiry"]').fill('12/34') + await stripeFrame.locator('[name="cvc"]').fill('123') + + const postalCode = stripeFrame.locator('[name="postalCode"]') + await postalCode.isVisible({ timeout: 2_000 }) + await postalCode.fill('10001') + + await page.waitForTimeout(500) + } + + // Submit payment (force needed — Stripe Link overlay can intercept click) + await page.getByRole('button', { name: 'Pay' }).click({ force: true }) + + // Wait for service worker to submit credential and page to reload with paid response + await expect(page.locator('body')).toContainText('"ok":', { timeout: 30_000 }) +}) + test('compose pay via tempo tab', async ({ page }) => { await page.goto('/compose', { waitUntil: 'domcontentloaded' }) diff --git a/test/html/stripe.test.ts b/test/html/stripe.test.ts index cf057891..55e6f4da 100644 --- a/test/html/stripe.test.ts +++ b/test/html/stripe.test.ts @@ -1,6 +1,7 @@ -import type { Frame, Page } from '@playwright/test' import { expect, test } from '@playwright/test' +import { getStripePaymentFrame } from './utils.js' + test('charge via stripe html payment page', async ({ page }, testInfo) => { test.slow() @@ -45,24 +46,3 @@ test('service worker endpoint returns javascript', async ({ page }) => { expect(response?.headers()['content-type']).toContain('application/javascript') expect(response?.status()).toBe(200) }) - -async function getStripePaymentFrame(page: Page, timeout = 30_000): Promise { - const deadline = Date.now() + timeout - - while (Date.now() < deadline) { - for (const frame of page.frames()) { - if (!frame.name().startsWith('__privateStripeFrame')) continue - - const hasCardButton = - (await frame - .locator('[data-value="card"]') - .count() - .catch(() => 0)) > 0 - if (hasCardButton) return frame - } - - await page.waitForTimeout(250) - } - - throw new Error('Timed out waiting for Stripe payment frame') -} diff --git a/test/html/utils.ts b/test/html/utils.ts new file mode 100644 index 00000000..b2a1ad5b --- /dev/null +++ b/test/html/utils.ts @@ -0,0 +1,22 @@ +import type { Frame, Page } from '@playwright/test' + +export async function getStripePaymentFrame(page: Page, timeout = 30_000): Promise { + const deadline = Date.now() + timeout + + while (Date.now() < deadline) { + for (const frame of page.frames()) { + if (!frame.name().startsWith('__privateStripeFrame')) continue + + const hasCardButton = + (await frame + .locator('[data-value="card"]') + .count() + .catch(() => 0)) > 0 + if (hasCardButton) return frame + } + + await page.waitForTimeout(250) + } + + throw new Error('Timed out waiting for Stripe payment frame') +} From a3906f71fcadc7cd185e67f08e4a1567d97e72c3 Mon Sep 17 00:00:00 2001 From: tmm Date: Fri, 3 Apr 2026 14:03:22 -0400 Subject: [PATCH 7/7] chore: tweaks --- src/server/internal/html/config.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/server/internal/html/config.ts b/src/server/internal/html/config.ts index d8939077..46d484f0 100644 --- a/src/server/internal/html/config.ts +++ b/src/server/internal/html/config.ts @@ -677,13 +677,13 @@ function sanitizeRecord>(record: type): type } export function mergeDefined(defaults: type, value: DeepPartial | undefined): type { - if (value === undefined) return defaults + if (value === undefined || value === null) return defaults if (!isPlainObject(defaults) || !isPlainObject(value)) return (value ?? defaults) as type const result: Record = { ...defaults } for (const [key, nextValue] of Object.entries(value)) { - if (nextValue === undefined) continue + if (nextValue === undefined || nextValue === null || nextValue === '') continue const currentValue = result[key]