// Deterministic browser coverage: real post actions and zap state, fake payment // settlement. No wallet funds, relays, or payment services are contacted. // Run: bun tools/zap-feedback-test.js import assert from 'node:assert/strict'; import puppeteer from './puppeteer.js'; const app = await Bun.file('src/app.js').text(); const domHelper = app.slice(app.indexOf('function h(tag,'), app.indexOf('// ---------------------------------------------------------------- morphing')); const entry = ` import { messagesFeature } from './src/features/messages.js'; import { zapsFeature } from './src/features/zaps.js'; import { arkFeature } from './src/features/ark.js'; ${domHelper} const me = '1'.repeat(64), author = '2'.repeat(64), id = '3'.repeat(64); const ev = { id, pubkey: author, kind: 1, tags: [], content: 'A little lightning goes a long way.' }; let feature, ark, arkResolve, arkReject, amount = 21, sound = true, mode = 'pending', calls = 0, toasts = [], loginPk = null; const ui = { screen: 'wallet', chatOpen: true }; const wallet = { nostr: { pk: me }, loadFeatureState: () => ({}), saveFeatureState() {}, loadArkState() {}, registerCacheExtension() {}, nostrProfile: async () => { throw new Error('Payment unavailable'); } }; const ctx = { h, ui, wallet, render, toast: (msg) => toasts.push(msg), zapDefaultSat: () => amount, setZapDefaultSat: (n) => amount = n, zapSound: () => sound, setZapSound: (on) => sound = on, brandHeader: () => null, testPay: () => new Promise((resolve, reject) => { arkResolve = resolve; arkReject = reject; }), showSend: () => { ui.chatOpen = false; }, hook(name, ...args) { if (name === 'zapNpub') { calls++; if (mode === 'ark') ark.testStart(...args); return mode !== 'unavailable'; } if (name === 'canLnPay' || name === 'arkReady') return true; if (name === 'zapSettled') return feature.zapSettled(...args); if (name === 'nostrLoginIdentity') return loginPk ? { pubkey: loginPk } : null; return false; }, }; function render() { if (!feature) return; document.querySelector('#actions').replaceChildren(feature.testActions(author, ev, { canZap: true })); document.querySelector('#setup').replaceChildren(...(ui.zapSetup ? [feature.testSetup()] : [])); } feature = messagesFeature(ctx); ark = arkFeature(ctx); function receipt(receiptId, pk, sats) { feature.testReceipt({ id: receiptId, kind: 9737, pubkey: pk, created_at: Math.floor(Date.now()/1000), tags: [['e', id], ['amount', String(sats)]], content: '' }); } receipt('base', author, 100); render(); window.test = { get calls() { return calls; }, get toasts() { return toasts; }, get amount() { return amount; }, ui, render, settle: (ok) => feature.zapSettled(id, ok, amount), receipt: () => receipt('ours', me, amount), unavailable: () => mode = 'unavailable', setup: () => { amount = 0; mode = 'pending'; }, lightningFailure: () => zapsFeature(ctx).lnZapNpub(author, '', id, 21), useArk: () => { mode = 'ark'; amount = 21; }, finishArk: (ok) => ok ? arkResolve() : arkReject(new Error('Ark payment failed')), login: (pk) => loginPk = pk, zapper: (pubkey, P) => feature.testZapper({ kind: 9737, pubkey, tags: [['e', id], ['amount', '21'], ...(P ? [['P', P]] : [])] }), senderTag: () => ark.testSenderTag(), }; `; const bundle = await Bun.build({ entrypoints: ['zap-test-entry'], target: 'browser', plugins: [{ name: 'zap-test', setup(build) { build.onResolve({ filter: /^zap-test-entry$/ }, () => ({ path: 'entry', namespace: 'zap-test' })); build.onLoad({ filter: /.*/, namespace: 'zap-test' }, () => ({ contents: entry, loader: 'js', resolveDir: process.cwd() })); build.onLoad({ filter: /src\/features\/messages\.js$/ }, async ({ path }) => ({ contents: (await Bun.file(path).text()).replace("id: 'messages',", "id: 'messages', testActions: noteActions, testReceipt: noteReceipt, testSetup: zapSetupScreen, testZapper: zapperOf,"), loader: 'js', })); build.onLoad({ filter: /src\/features\/ark\.js$/ }, async ({ path }) => ({ contents: (await Bun.file(path).text()).replace("id: 'ark',", `id: 'ark', testSenderTag: zapSenderTag, testStart(...args) { connectArk = async () => ({}); lookupArkZapTarget = async () => ({ status: 'ready', address: 'test' }); performArkZap = ctx.testPay; startNpubPay(...args); },`), loader: 'js', })); } }], }); assert(bundle.success, bundle.logs.join('\n')); const css = await Bun.file('src/style.css').text(); const js = await bundle.outputs[0].text(); const html = `
@coinos

A little lightning goes a long way.

`; const server = Bun.serve({ port: 0, fetch: () => new Response(html, { headers: { 'content-type': 'text/html' } }) }); const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: true, args: ['--no-sandbox'] }); const page = await browser.newPage(); await page.evaluateOnNewDocument(() => { window.zapAudio = { starts: 0, context: null, buffer: null }; const NativeAudioContext = window.AudioContext; window.AudioContext = class extends NativeAudioContext { constructor(...args) { super(...args); zapAudio.context = this; } createBufferSource() { const source = super.createBufferSource(), start = source.start.bind(source); source.start = (...args) => { zapAudio.starts++; zapAudio.buffer = source.buffer; return start(...args); }; return source; } }; }); const errors = []; page.on('pageerror', (e) => errors.push(e.message)); await page.setRequestInterception(true); page.on('request', (r) => r.url().startsWith(server.url.origin) ? r.continue() : r.abort()); const pause = (ms) => new Promise((r) => setTimeout(r, ms)); const check = (name, value) => { assert(value, name); console.log(' ✓ ' + name); }; const state = () => page.evaluate(() => ({ tally: document.querySelector('.note-zap').textContent.trim(), flying: document.querySelector('.note-zap').classList.contains('flying'), effects: document.querySelectorAll('.zap-fx').length, calls: test.calls, })); try { await page.setViewport({ width: 390, height: 844 }); await page.goto(server.url.href); await page.waitForSelector('.note-zap'); await pause(600); // a live AudioContext opens the phone's speaker path (a pop on every open // or refocus of the installed app) — none may exist before a tap check('no audio context is opened before the first tap', await page.evaluate(() =>
const { DataTypes } = require('sequelize');

const attributes = {
  id: {
    type: DataTypes.INTEGER(11),
    primaryKey: true,
    autoIncrement: true,
    allowNull: false
  },
  user_id: {
    type: DataTypes.INTEGER(11),
    references: { model: User, key: 'id' },
    allowNull: false,
    comment: "Referral granted to this user (updated when token used)"
  },
  username: {
    type: DataTypes.STRING(255),
    allowNull: false,
  },
  gravitar: {
    type: DataTypes.STRING(255),
  },
  fiat: {
    type: DataTypes.BOOLEAN,
    defaultValue: true,
    allowNull: false,
    comment: "What is this for ?"
  },
  default_crypto_unit: {
    type: DataTypes.ENUM,
    values: ['SAT', 'BTC'],
    defaultValue: 'SAT',
    allowNull: false
  },
  default_currency_code: {
    type: DataTypes.INTEGER(11),
    references: { model: Currency, key: 'code' },
    defaultValue: "CAD",
    allowNull: false
  },
  currencies: {
    type: DataTypes.JSON,
    allowNull: false,
    defaultValue: ["USD"],
    comment: "List of currency options to show for this user",
  }
}

const options = {
  tableName: "user_preferences",
  comment: "settings that can be controlled by users themselves",
  timestamps: true,
  createdAt: 'created_at',
  updatedAt: 'updated_at',
  indexes: [
    {fields: ['user_id']}
  ]
};

db["UserPreference"] = db.define("user_preferences_model", attributes, options);