Standardize tests on vitest

- Add vitest as dev dep (pnpm add -D vitest), create vitest.config.ts
  with globals:true and setupFiles
- Migrate digest-template, normalize-relay-url, reschedule-on-frequency-change,
  and event-arrival-race from raw JS + hand-rolled asserts to vitest
  describe/it/expect, as .test.ts (port all assertions without weakening)
- Create test/setup.ts with env vars needed by env.ts/mailer.ts
- DELETE test/web-ui.test.js: dead test for removed web UI (PR #1)
- package.json: add test:unit script (vitest run), keep test = bash E2E
- script/checks: add pnpm test:unit after build
- README: document pnpm test:unit / pnpm test
This commit is contained in:
Agent 2026-09-14 13:29:54 -04:00
parent cfbb29cb96
commit 13b1e9d00f
15 changed files with 227 additions and 430 deletions

View file

@ -130,8 +130,9 @@ docker run -d \
## Tests
```sh
pnpm test # Run integration tests
pnpm test:server # Start server for manual testing
pnpm test:unit # Run unit tests (vitest)
pnpm test # Run integration tests (bash E2E)
pnpm test:server # Start server for manual testing
```
## Forked from Anchor

View file

@ -11,6 +11,7 @@
"preview:digest": "node script/render-preview.mjs",
"run-alert": "node dist/run.js",
"test": "bash test/integration.sh",
"test:unit": "vitest run",
"test:server": "bash test/integration.sh --server-only"
},
"devDependencies": {
@ -29,7 +30,8 @@
"globals": "^15.15.0",
"onchange": "^7.1.0",
"prettier": "^3.6.2",
"typescript": "^5.9.2"
"typescript": "^5.9.2",
"vitest": "^5.0.0"
},
"dependencies": {
"@types/node": "^22.18.1",

Binary file not shown.

View file

@ -1,6 +1,7 @@
#!/usr/bin/env bash
set -euo pipefail
# mailship CI checks — type-check + lint + build
# mailship CI checks — type-check + lint + build + unit tests
pnpm run check
pnpm run build
pnpm run build
pnpm test:unit

View file

@ -1,74 +0,0 @@
#!/usr/bin/env node
// FAILING test: digest.mjml hardcodes #7161FF instead of using {{brandAccent}}
//
// The bug: in src/emails/digest.mjml line 8 and line 22, the CSS for
// .event-item border-left and .footer a color hardcode #7161FF even though
// {{brandAccent}} is passed into the template by mailer.ts and used
// elsewhere (lines 15, 34). When BRAND_ACCENT is customized, the event-item
// border and footer links stay the default purple.
//
// The fix: replace both hardcoded #7161FF values with {{brandAccent}}.
import { readFileSync } from 'fs';
import { fileURLToPath } from 'url';
import { dirname, join } from 'path';
const __dirname = dirname(fileURLToPath(import.meta.url));
const templatePath = join(__dirname, '..', 'src', 'emails', 'digest.mjml');
let passed = 0;
let failed = 0;
function assert(label, ok, detail) {
if (ok) {
console.log(` ✓ ${label}`);
passed++;
} else {
console.log(` ✗ ${label} — ${detail || ''}`);
failed++;
}
}
// Read the MJML template
const source = readFileSync(templatePath, 'utf8');
const lines = source.split('\n');
console.log('1. No hardcoded #7161FF in .event-item or .footer a CSS');
// Check .event-item border-left doesn't have #7161FF
const eventItemLineIdx = lines.findIndex(l => l.includes('.event-item'));
const hasEventItemHardcoded = lines.some(l => l.includes('.event-item') && l.includes('#7161FF'));
assert(
'.event-item border-left does NOT hardcode #7161FF',
!hasEventItemHardcoded,
hasEventItemHardcoded ? `Line ${eventItemLineIdx + 1} still has #7161FF: "${lines[eventItemLineIdx].trim()}"` : ''
);
// Check .footer a color doesn't have #7161FF
const footerAIdx = lines.findIndex(l => l.includes('.footer a'));
const hasFooterHardcoded = lines.some(l => l.includes('.footer a') && l.includes('#7161FF'));
assert(
'.footer a color does NOT hardcode #7161FF',
!hasFooterHardcoded,
hasFooterHardcoded ? `Line ${footerAIdx + 1} still has #7161FF: "${lines[footerAIdx].trim()}"` : ''
);
// Check .event-item border-left uses {{brandAccent}}
const eventItemLine = lines[eventItemLineIdx];
assert(
'.event-item border-left uses {{brandAccent}}',
eventItemLine && eventItemLine.includes('{{brandAccent}}'),
eventItemLine ? `Line ${eventItemLineIdx + 1}: "${eventItemLine.trim()}"` : '.event-item line not found'
);
// Check .footer a color uses {{brandAccent}}
const footerALine = lines[footerAIdx];
assert(
'.footer a color uses {{brandAccent}}',
footerALine && footerALine.includes('{{brandAccent}}'),
footerALine ? `Line ${footerAIdx + 1}: "${footerALine.trim()}"` : '.footer a line not found'
);
console.log('');
console.log(`Results: ${passed} passed, ${failed} failed`);
process.exit(failed > 0 ? 1 : 0);

View file

@ -0,0 +1,33 @@
import { describe, it, expect } from 'vitest'
import { readFileSync } from 'fs'
import { fileURLToPath } from 'url'
import { dirname, join } from 'path'
const __dirname = dirname(fileURLToPath(import.meta.url))
const templatePath = join(__dirname, '..', 'src', 'emails', 'digest.mjml')
const source = readFileSync(templatePath, 'utf8')
const lines = source.split('\n')
describe('digest.mjml brandAccent usage', () => {
it('.event-item border-left does NOT hardcode #7161FF', () => {
const hasHardcoded = lines.some(l => l.includes('.event-item') && l.includes('#7161FF'))
expect(hasHardcoded).toBe(false)
})
it('.footer a color does NOT hardcode #7161FF', () => {
const hasHardcoded = lines.some(l => l.includes('.footer a') && l.includes('#7161FF'))
expect(hasHardcoded).toBe(false)
})
it('.event-item border-left uses {{brandAccent}}', () => {
const eventItemLine = lines.find(l => l.includes('.event-item'))
expect(eventItemLine).toBeDefined()
expect(eventItemLine).toContain('{{brandAccent}}')
})
it('.footer a color uses {{brandAccent}}', () => {
const footerALine = lines.find(l => l.includes('.footer a'))
expect(footerALine).toBeDefined()
expect(footerALine).toContain('{{brandAccent}}')
})
})

View file

@ -1,139 +0,0 @@
#!/usr/bin/env node
// Test: event-arrival race in digest job — fix verification
//
// The original bug: runJob fetched events via getEventsForSubscription(sub.id, since),
// sent the digest (slow), then deleted events via deleteEventsForSubscription(sub.id, since)
// which deletes EVERY row with received_at > since. Any event that arrived between
// the fetch and the delete was also received_at > since, so it was deleted without
// ever being emailed.
//
// The fix: deleteEventsByIds(sub.id, sentEventIds) deletes only the exact event IDs
// that were fetched and sent. A late-arriving event (inserted after the fetch) has
// a different ID and is not touched.
//
// This test simulates the sequence with the fixed approach:
// 1. Create a confirmed subscription with a known last_digest_at
// 2. Insert event A
// 3. Fetch events for the subscription (simulating runJob's fetch)
// 4. Insert event B after the fetch (simulating a /notify arriving during digest send)
// 5. Delete ONLY the event A IDs (the fix — instead of timestamp-based delete)
// 6. Verify event B survives (it arrived after fetch and was never sent)
import * as db from '../dist/database.js'
let passed = 0
let failed = 0
function assert(label, ok, detail) {
if (ok) {
console.log(` ? ${label}`)
passed++
} else {
console.log(` ? ${label} -- ${detail || ''}`)
failed++
}
}
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms))
}
async function main() {
await db.migrate()
const pubkey = 'race-test-' + Date.now()
const email = 'race-test-' + Date.now() + '@example.com'
// Step 1: Create and confirm subscription
console.log('1. Create confirmed subscription')
const sub = await db.insertSubscription(pubkey, email, 'daily')
assert('subscription created', !!sub, 'insert returned null')
if (!sub) { process.exit(1) }
const confirmed = await db.confirmSubscription(sub.key)
assert('subscription confirmed', !!confirmed, 'confirm returned null')
if (!confirmed) { process.exit(1) }
// Set last_digest_at to a known time well in the past.
// This is the "since" value runJob would use.
const since = Math.floor(Date.now() / 1000) - 60 // 60 seconds ago
await db.updateLastDigestAt(confirmed.id, since)
// Wait 1s so event received_at timestamps (whole seconds) are strictly > since.
await sleep(1100)
// Step 2: Insert Event A — simulates events that trigger a digest run
console.log('\n2. Insert Event A (triggers digest)')
const eventA_id = 'race-event-a-' + Date.now()
const eventA = {
id: eventA_id,
kind: 1,
pubkey: 'abc',
content: 'event A content',
created_at: Math.floor(Date.now() / 1000),
tags: []
}
const storedA = await db.insertEvent(eventA_id, confirmed.id, eventA, 'wss://relay.damus.io')
assert('Event A stored', storedA === true, `got ${storedA}`)
// Step 3: Fetch events — simulating what runJob does with since=last_digest_at
console.log('\n3. Fetch events for subscription (simulating runJob fetch)')
const fetched = await db.getEventsForSubscription(confirmed.id, since)
assert('Event A was fetched', fetched.some(e => e.id === eventA_id),
`fetched ids: [${fetched.map(e => e.id).join(', ')}]`)
// Step 4: Insert Event B after the fetch — simulating a /notify arriving
// during the slow digest send (profile loads, MJML render, SMTP).
console.log('\n4. Insert Event B AFTER fetch (simulating event arriving during digest send)')
await sleep(100) // ensure distinct received_at
const eventB_id = 'race-event-b-' + Date.now()
const eventB = {
id: eventB_id,
kind: 1,
pubkey: 'def',
content: 'event B content — arrived during send',
created_at: Math.floor(Date.now() / 1000),
tags: []
}
const storedB = await db.insertEvent(eventB_id, confirmed.id, eventB, 'wss://relay.damus.io')
assert('Event B stored', storedB === true, `got ${storedB}`)
// Step 5: Delete ONLY the exact event IDs that were fetched + sent — THE FIX.
// Unlike the old timestamp-based delete, this does NOT touch Event B
// because Event B has a different ID.
console.log('\n5. Delete events by exact IDs (the fix — deleteEventsByIds)')
const sentIds = fetched.map(e => e.id)
await db.deleteEventsByIds(confirmed.id, sentIds)
console.log(' deleted ids:', JSON.stringify(sentIds))
// Step 6: Check which events remain.
// CORRECT BEHAVIOR: Only Event A (which was sent) is deleted.
// Event B (which arrived after fetch) must survive.
console.log('\n6. Check remaining events after delete')
const remaining = await db.getEventsForSubscription(confirmed.id, since - 10)
const eventB_survived = remaining.some(e => e.id === eventB_id)
assert(
'Event B survives the delete (it arrived after fetch and was never sent)',
eventB_survived,
`Event B was deleted despite never being sent. ` +
`remaining events: [${remaining.map(e => e.id).join(', ')}]`
)
// Verify Event A is gone (it was sent, so deletion is correct for A)
const eventA_survived = remaining.some(e => e.id === eventA_id)
assert(
'Event A is deleted (it was fetched and sent)',
!eventA_survived,
`Event A should have been deleted but is still present`
)
console.log('')
console.log(`Results: ${passed} passed, ${failed} failed`)
process.exit(failed > 0 ? 1 : 0)
}
main().catch(err => {
console.error('Unhandled error in test:', err)
process.exit(1)
})

View file

@ -0,0 +1,89 @@
import { describe, it, expect, beforeAll } from 'vitest'
import * as db from '../src/database.js'
const pubkey = 'race-test-' + Date.now()
const email = 'race-test-' + Date.now() + '@example.com'
let sub: any = null
let since = 0
const eventA_id = 'race-event-a-' + Date.now()
const eventB_id = 'race-event-b-' + Date.now()
// Captured after the initial fetch, before Event B is inserted
let fetchedBeforeB: string[] = []
function sleep(ms: number) {
return new Promise(resolve => setTimeout(resolve, ms))
}
describe('Event-arrival race in digest job', () => {
beforeAll(async () => {
await db.migrate()
// Create and confirm subscription
const s = await db.insertSubscription(pubkey, email, 'daily')
expect(s).toBeTruthy()
const confirmed = await db.confirmSubscription(s.key)
expect(confirmed).toBeTruthy()
sub = confirmed
// Set last_digest_at to 60 seconds ago (the "since" value runJob would use)
since = Math.floor(Date.now() / 1000) - 60
await db.updateLastDigestAt(sub.id, since)
// Wait 1.1s so event received_at timestamps are strictly > since
await sleep(1100)
})
it('stores Event A (triggers digest)', async () => {
const eventA = {
id: eventA_id,
kind: 1,
pubkey: 'abc',
content: 'event A content',
created_at: Math.floor(Date.now() / 1000),
tags: [],
}
const storedA = await db.insertEvent(eventA_id, sub.id, eventA, 'wss://relay.damus.io')
expect(storedA).toBe(true)
})
// Fetch events BEFORE Event B is inserted (simulating runJob's fetch
// before a /notify arrives during the digest send). Save the IDs we
// fetched so we delete exactly those later.
it('fetches events and captures IDs (simulating runJob fetch)', async () => {
const fetched = await db.getEventsForSubscription(sub.id, since)
expect(fetched.some((e: any) => e.id === eventA_id)).toBe(true)
fetchedBeforeB = fetched.map((e: any) => e.id)
})
it('stores Event B AFTER fetch (simulating arrival during digest send)', async () => {
await sleep(100)
const eventB = {
id: eventB_id,
kind: 1,
pubkey: 'def',
content: 'event B content — arrived during send',
created_at: Math.floor(Date.now() / 1000),
tags: [],
}
const storedB = await db.insertEvent(eventB_id, sub.id, eventB, 'wss://relay.damus.io')
expect(storedB).toBe(true)
})
// Delete using the IDs captured before Event B was inserted.
// This simulates the fix: deleteEventsByIds, not timestamp-based delete.
it('deletes only previously-fetched event IDs (the fix) and leaves event B', async () => {
await db.deleteEventsByIds(sub.id, fetchedBeforeB)
// Event B must survive (it arrived after fetch and was never sent)
const remaining = await db.getEventsForSubscription(sub.id, since - 10)
const eventB_survived = remaining.some((e: any) => e.id === eventB_id)
expect(eventB_survived).toBe(true)
})
it('Event A is deleted (it was fetched and sent)', async () => {
const remaining = await db.getEventsForSubscription(sub.id, since - 10)
const eventA_survived = remaining.some((e: any) => e.id === eventA_id)
expect(eventA_survived).toBe(false)
})
})

View file

@ -1,68 +0,0 @@
#!/usr/bin/env node
// FAILING test: calling normalizeRelayUrl(undefined) crashes with
// TypeError: can't access property "match", A is undefined
//
// The bug: main.ts called normalizeRelayUrl(import.meta.env.VITE_NOTIFIER_RELAY)
// without guarding against the env var being undefined. When the env var is
// not set, normalizeRelayUrl crashes because it calls url.match(...) on
// undefined.
//
// The fix: replace the bare call with a ternary guard:
// const NOTIFIER_RELAY = import.meta.env.VITE_NOTIFIER_RELAY
// ? normalizeRelayUrl(import.meta.env.VITE_NOTIFIER_RELAY)
// : undefined
//
// This test validates that the guard pattern works correctly: when the env var
// is falsy (undefined, empty), the app gracefully sets NOTIFIER_RELAY to
// undefined without crashing. When it's a valid URL, normalization works.
import { normalizeRelayUrl } from '/home/gascity/mailship/node_modules/.pnpm/@welshman+util@0.6.3_typescript@5.9.2/node_modules/@welshman/util/dist/util/src/Relay.js';
let passed = 0;
let failed = 0;
function assert(label, ok, detail) {
if (ok) {
console.log(` ✓ ${label}`);
passed++;
} else {
console.log(` ✗ ${label} — ${detail || ''}`);
failed++;
}
}
// Test 1: Guarded normalizeRelayUrl with undefined (the exact fix pattern)
console.log('1. Guarded normalizeRelayUrl with undefined (the fix)');
const undefinedInput = undefined;
const guarded1 = undefinedInput ? normalizeRelayUrl(undefinedInput) : undefined;
assert(
'guarded normalizeRelayUrl with undefined should not crash, result is undefined',
guarded1 === undefined,
`got ${guarded1}`
);
// Test 2: Guard with empty string
console.log('');
console.log('2. Guarded normalizeRelayUrl with empty string');
const emptyInput = '';
const guarded2 = emptyInput ? normalizeRelayUrl(emptyInput) : undefined;
assert(
'guarded normalizeRelayUrl with "" should not crash, result is undefined',
guarded2 === undefined,
`got ${guarded2}`
);
// Test 3: Guard with a valid relay still works
console.log('');
console.log('3. Guarded normalizeRelayUrl with valid relay');
const validInput = 'wss://relay.damus.io';
const guarded3 = validInput ? normalizeRelayUrl(validInput) : undefined;
assert(
'guarded normalizeRelayUrl with valid input still normalizes correctly',
guarded3 === 'wss://relay.damus.io/',
`got ${guarded3}`
);
console.log('');
console.log(`Results: ${passed} passed, ${failed} failed`);
process.exit(failed > 0 ? 1 : 0);

View file

@ -0,0 +1,22 @@
import { describe, it, expect } from 'vitest'
import { normalizeRelayUrl } from '@welshman/util'
describe('Guarded normalizeRelayUrl', () => {
it('guarded with undefined should not crash, result is undefined', () => {
const undefinedInput: string | undefined = undefined
const guarded = undefinedInput ? normalizeRelayUrl(undefinedInput) : undefined
expect(guarded).toBeUndefined()
})
it('guarded with empty string should not crash, result is undefined', () => {
const emptyInput = ''
const guarded = emptyInput ? normalizeRelayUrl(emptyInput) : undefined
expect(guarded).toBeUndefined()
})
it('guarded with valid relay still normalizes correctly', () => {
const validInput = 'wss://relay.damus.io'
const guarded = validInput ? normalizeRelayUrl(validInput) : undefined
expect(guarded).toBe('wss://relay.damus.io/')
})
})

View file

@ -1,83 +0,0 @@
#!/usr/bin/env node
// FAILING test: frequency change does not reschedule the running cron job.
//
// The fix: actions.ts:registerSubscription calls worker.registerSubscription()
// after a DB update on an already-confirmed subscription, so addJob creates
// a new CronJob with the updated frequency on the fly.
//
// Step 1-2: Create subscription via raw DB (bypass mailer for simplicity)
// Step 3: Register cron job with daily (simulating confirmSubscriptionAction)
// Step 4: Call registerSubscription with new frequency — the bug path
// BEFORE FIX: cron stays daily; AFTER FIX: cron becomes weekly
import * as db from '../dist/database.js'
import { registerSubscription } from '../dist/actions.js'
import { getJobCronSource, removeJob } from '../dist/worker/email.js'
import { registerSubscription as regSub } from '../dist/worker/index.js'
let passed = 0
let failed = 0
function assert(label, ok, detail) {
if (ok) {
console.log(` ? ${label}`)
passed++
} else {
console.log(` ? ${label} -- ${detail || ''}`)
failed++
}
}
async function main() {
await db.migrate()
const pubkey = 'freq-test-' + Date.now()
const email = 'freq-test-' + Date.now() + '@example.com'
// Step 1: Insert subscription directly (bypass mailer) and confirm
console.log('1. Create confirmed subscription with daily frequency')
const sub = await db.insertSubscription(pubkey, email, 'daily')
assert('subscription created', !!sub, 'insert returned null')
if (!sub) { process.exit(1) }
const confirmed = await db.confirmSubscription(sub.key)
assert('subscription confirmed', !!confirmed, 'confirm returned null')
if (!confirmed) { process.exit(1) }
// Step 2: Register cron job with daily (simulating confirmSubscriptionAction)
console.log('\n2. Register cron job with daily frequency')
regSub(confirmed)
const dailySource = getJobCronSource(confirmed.id)
assert(
'cron source is daily',
dailySource === '0 0 17 * * *',
`expected 0 0 17 * * *, got ${dailySource}`
)
// Step 3: Register subscription again with weekly — the bug path.
// BEFORE FIX: registerSubscription skips worker call because
// sub.confirmed_at is set → cron stays daily
// AFTER FIX: registerSubscription calls worker.registerSubscription
// → addJob reschedules → cron becomes weekly
console.log('\n3. Change frequency to weekly via registerSubscription')
await registerSubscription({ pubkey, email, frequency: 'weekly' })
const weeklySource = getJobCronSource(confirmed.id)
assert(
'cron source is weekly after frequency change',
weeklySource === '0 0 17 * * 1',
`expected 0 0 17 * * 1, got ${weeklySource}`
)
// Cleanup
const updated = await db.getSubscriptionByPubkey(pubkey)
if (updated) removeJob(updated)
console.log('')
console.log(`Results: ${passed} passed, ${failed} failed`)
process.exit(failed > 0 ? 1 : 0)
}
main().catch(err => {
console.error('Unhandled error in test:', err)
process.exit(1)
})

View file

@ -0,0 +1,42 @@
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
import * as db from '../src/database.js'
import { registerSubscription } from '../src/actions.js'
import { getJobCronSource, removeJob } from '../src/worker/email.js'
import { registerSubscription as regSub } from '../src/worker/index.js'
const pubkey = 'freq-test-' + Date.now()
const email = 'freq-test-' + Date.now() + '@example.com'
let sub: any = null
describe('Frequency change reschedules cron job', () => {
beforeAll(async () => {
await db.migrate()
})
it('creates confirmed subscription with daily frequency', async () => {
const s = await db.insertSubscription(pubkey, email, 'daily')
expect(s).toBeTruthy()
sub = s
const confirmed = await db.confirmSubscription(sub.key)
expect(confirmed).toBeTruthy()
sub = confirmed
})
it('registers cron job with daily frequency', () => {
regSub(sub)
const dailySource = getJobCronSource(sub.id)
expect(dailySource).toBe('0 0 17 * * *')
})
it('changes frequency to weekly via registerSubscription', async () => {
await registerSubscription({ pubkey, email, frequency: 'weekly' })
const weeklySource = getJobCronSource(sub.id)
expect(weeklySource).toBe('0 0 17 * * 1')
})
})
afterAll(async () => {
const updated = await db.getSubscriptionByPubkey(pubkey)
if (updated) removeJob(updated)
})

23
test/setup.ts Normal file
View file

@ -0,0 +1,23 @@
// Vitest setup: set required env vars before test modules are loaded.
// env.ts checks these at module load time; they must be present when
// actions.ts / mailer.ts / etc. are imported.
import { mkdirSync } from 'fs'
const dataDir = 'test-data-unit'
mkdirSync(dataDir, { recursive: true })
process.env.MAILSHIP_URL = 'http://localhost:3000'
process.env.MAILSHIP_NAME = 'Test Mailship'
process.env.MAILSHIP_SECRET = '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef'
process.env.SMTP_HOST = 'localhost'
process.env.SMTP_PORT = '587'
process.env.SMTP_USER = 'test@test.com'
process.env.SMTP_PASSWORD = 'test'
process.env.SMTP_FROM = 'test@test.com'
process.env.DEFAULT_RELAYS = 'wss://relay.damus.io'
process.env.INDEXER_RELAYS = 'wss://purplepag.es'
process.env.SEARCH_RELAYS = 'wss://relay.nostr.band'
process.env.PORT = '3000'
process.env.CORS_ORIGIN = 'http://localhost:5173'
process.env.BASE_URL = 'http://localhost:3000'
process.env.DATA_DIR = dataDir

View file

@ -1,61 +0,0 @@
#!/usr/bin/env node
// Failing test: web UI crashes on load when VITE_NOTIFIER_RELAY is not set.
//
// The bug: `normalizeRelayUrl(import.meta.env.VITE_NOTIFIER_RELAY)` throws
// TypeError: Cannot read properties of undefined (reading 'match')
// when the env var is not set.
//
// After the fix, `normalizeRelayUrl` is only called when the env var is
// truthy, so the crash no longer occurs. This test verifies the guarded
// call pattern matches the one in main.ts.
import { normalizeRelayUrl } from '/home/gascity/mailship/node_modules/.pnpm/@welshman+util@0.6.3_typescript@5.9.2/node_modules/@welshman/util/dist/util/src/Relay.js';
let passed = 0;
let failed = 0;
function assert(label, ok, detail) {
if (ok) {
console.log(` ✓ ${label}`);
passed++;
} else {
console.log(` ✗ ${label} — ${detail || ''}`);
failed++;
}
}
// Test 1: Guarded normalizeRelayUrl — the pattern used in main.ts
console.log('1. Guarded normalizeRelayUrl (main.ts pattern)');
const undefinedInput = undefined; // simulates unset VITE_NOTIFIER_RELAY
const guarded1 = undefinedInput ? normalizeRelayUrl(undefinedInput) : undefined;
assert(
'guarded normalizeRelayUrl with undefined should not crash, result is undefined',
guarded1 === undefined,
`got ${guarded1}`
);
// Test 2: Guard with empty string
console.log('');
console.log('2. Guarded normalizeRelayUrl with empty string');
const emptyInput = '';
const guarded2 = emptyInput ? normalizeRelayUrl(emptyInput) : undefined;
assert(
'guarded normalizeRelayUrl with "" should not crash, result is undefined',
guarded2 === undefined,
`got ${guarded2}`
);
// Test 3: Guard with a valid relay still works
console.log('');
console.log('3. Guarded normalizeRelayUrl with valid relay');
const validInput = 'wss://relay.damus.io';
const guarded3 = validInput ? normalizeRelayUrl(validInput) : undefined;
assert(
'guarded normalizeRelayUrl with valid input still normalizes correctly',
guarded3 === 'wss://relay.damus.io/',
`got ${guarded3}`
);
console.log('');
console.log(`Results: ${passed} passed, ${failed} failed`);
process.exit(failed > 0 ? 1 : 0);

9
vitest.config.ts Normal file
View file

@ -0,0 +1,9 @@
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
globals: true,
include: ['test/**/*.test.ts'],
setupFiles: ['test/setup.ts'],
},
})