mirror of
https://github.com/simplex-chat/simplexmq.git
synced 2026-03-29 12:19:58 +00:00
browser environment
This commit is contained in:
208
rfcs/2026-02-03-browser-tests.md
Normal file
208
rfcs/2026-02-03-browser-tests.md
Normal file
@@ -0,0 +1,208 @@
|
||||
# Plan: Browser ↔ Haskell File Transfer Tests
|
||||
|
||||
## Table of Contents
|
||||
1. Goal
|
||||
2. Current State
|
||||
3. Implementation
|
||||
4. Success Criteria
|
||||
5. Files
|
||||
6. Order
|
||||
|
||||
## 1. Goal
|
||||
Run browser upload/download tests in headless Chromium via Vitest, proving fetch-based transport works in real browser environment.
|
||||
|
||||
## 2. Current State
|
||||
- `client.ts`: Transport abstraction done — http2 for Node, fetch for browser ✓
|
||||
- `agent.ts`: Uses `node:crypto` (randomBytes) and `node:zlib` (deflateRawSync/inflateRawSync) — **won't run in browser**
|
||||
- `XFTPWebTests.hs`: Cross-language tests exist (Haskell calls TS via Node.js) ✓
|
||||
|
||||
## 3. Implementation
|
||||
|
||||
### 3.1 Make agent.ts isomorphic
|
||||
|
||||
| Current (Node.js only) | Isomorphic replacement |
|
||||
|------------------------|------------------------|
|
||||
| `import crypto from "node:crypto"` | Remove import |
|
||||
| `import zlib from "node:zlib"` | `import pako from "pako"` |
|
||||
| `crypto.randomBytes(32)` | `crypto.getRandomValues(new Uint8Array(32))` |
|
||||
| `zlib.deflateRawSync(buf)` | `pako.deflateRaw(buf)` |
|
||||
| `zlib.inflateRawSync(buf)` | `pako.inflateRaw(buf)` |
|
||||
|
||||
Note: `crypto.getRandomValues` available in both browser and Node.js (globalThis.crypto).
|
||||
|
||||
### 3.2 Vitest browser mode setup
|
||||
|
||||
`package.json` additions:
|
||||
```json
|
||||
"devDependencies": {
|
||||
"vitest": "^3.0.0",
|
||||
"@vitest/browser": "^3.0.0",
|
||||
"playwright": "^1.50.0",
|
||||
"@types/pako": "^2.0.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"pako": "^2.1.0"
|
||||
}
|
||||
```
|
||||
|
||||
`vitest.config.ts`:
|
||||
```typescript
|
||||
import {defineConfig} from 'vitest/config'
|
||||
import {readFileSync} from 'fs'
|
||||
import {createHash} from 'crypto'
|
||||
|
||||
// Compute fingerprint from ca.crt (same as Haskell's loadFileFingerprint)
|
||||
const caCert = readFileSync('../tests/fixtures/ca.crt')
|
||||
const fingerprint = createHash('sha256').update(caCert).digest('base64url')
|
||||
const serverAddr = `xftp://${fingerprint}@localhost:7000`
|
||||
|
||||
export default defineConfig({
|
||||
define: {
|
||||
'import.meta.env.XFTP_SERVER': JSON.stringify(serverAddr)
|
||||
},
|
||||
test: {
|
||||
browser: {
|
||||
enabled: true,
|
||||
provider: 'playwright',
|
||||
instances: [{browser: 'chromium'}],
|
||||
headless: true,
|
||||
providerOptions: {
|
||||
launch: {ignoreHTTPSErrors: true}
|
||||
}
|
||||
},
|
||||
globalSetup: './test/globalSetup.ts'
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
### 3.3 Server startup
|
||||
|
||||
`test/globalSetup.ts`:
|
||||
```typescript
|
||||
import {spawn, ChildProcess} from 'child_process'
|
||||
import {resolve, join} from 'path'
|
||||
import {mkdtempSync, writeFileSync, copyFileSync} from 'fs'
|
||||
import {tmpdir} from 'os'
|
||||
|
||||
let server: ChildProcess | null = null
|
||||
|
||||
export async function setup() {
|
||||
const fixtures = resolve(__dirname, '../../tests/fixtures')
|
||||
|
||||
// Create temp directories
|
||||
const cfgDir = mkdtempSync(join(tmpdir(), 'xftp-cfg-'))
|
||||
const logDir = mkdtempSync(join(tmpdir(), 'xftp-log-'))
|
||||
const filesDir = mkdtempSync(join(tmpdir(), 'xftp-files-'))
|
||||
|
||||
// Copy certificates to cfgDir (xftp-server expects ca.crt, server.key, server.crt there)
|
||||
copyFileSync(join(fixtures, 'ca.crt'), join(cfgDir, 'ca.crt'))
|
||||
copyFileSync(join(fixtures, 'server.key'), join(cfgDir, 'server.key'))
|
||||
copyFileSync(join(fixtures, 'server.crt'), join(cfgDir, 'server.crt'))
|
||||
|
||||
// Write INI config file
|
||||
const iniContent = `[STORE_LOG]
|
||||
enable: off
|
||||
|
||||
[TRANSPORT]
|
||||
host: localhost
|
||||
port: 7000
|
||||
|
||||
[FILES]
|
||||
path: ${filesDir}
|
||||
|
||||
[WEB]
|
||||
cert: ${join(fixtures, 'web.crt')}
|
||||
key: ${join(fixtures, 'web.key')}
|
||||
`
|
||||
writeFileSync(join(cfgDir, 'file-server.ini'), iniContent)
|
||||
|
||||
// Spawn xftp-server with env vars
|
||||
server = spawn('cabal', ['exec', 'xftp-server', '--', 'start'], {
|
||||
env: {
|
||||
...process.env,
|
||||
XFTP_SERVER_CFG_PATH: cfgDir,
|
||||
XFTP_SERVER_LOG_PATH: logDir
|
||||
},
|
||||
stdio: ['ignore', 'pipe', 'pipe']
|
||||
})
|
||||
|
||||
// Wait for "Listening on port 7000..."
|
||||
await waitForServerReady(server)
|
||||
}
|
||||
|
||||
export async function teardown() {
|
||||
server?.kill('SIGTERM')
|
||||
await new Promise(r => setTimeout(r, 500))
|
||||
}
|
||||
|
||||
function waitForServerReady(proc: ChildProcess): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => reject(new Error('Server start timeout')), 15000)
|
||||
proc.stdout?.on('data', (data: Buffer) => {
|
||||
if (data.toString().includes('Listening on port')) {
|
||||
clearTimeout(timeout)
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
proc.stderr?.on('data', (data: Buffer) => {
|
||||
console.error('[xftp-server]', data.toString())
|
||||
})
|
||||
proc.on('error', reject)
|
||||
proc.on('exit', (code) => {
|
||||
clearTimeout(timeout)
|
||||
if (code !== 0) reject(new Error(`Server exited with code ${code}`))
|
||||
})
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
Server env vars (from `apps/xftp-server/Main.hs` + `getEnvPath`):
|
||||
- `XFTP_SERVER_CFG_PATH` — directory containing `file-server.ini` and certs (`ca.crt`, `server.key`, `server.crt`)
|
||||
- `XFTP_SERVER_LOG_PATH` — directory for logs
|
||||
|
||||
### 3.4 Browser test
|
||||
|
||||
`test/browser.test.ts`:
|
||||
```typescript
|
||||
import {test, expect} from 'vitest'
|
||||
import {encryptFileForUpload, uploadFile, downloadFile} from '../src/agent.js'
|
||||
import {parseXFTPServer} from '../src/protocol/address.js'
|
||||
|
||||
const server = parseXFTPServer(import.meta.env.XFTP_SERVER)
|
||||
|
||||
test('browser upload + download round-trip', async () => {
|
||||
const data = new Uint8Array(50000)
|
||||
crypto.getRandomValues(data)
|
||||
const encrypted = encryptFileForUpload(data, 'test.bin')
|
||||
const {rcvDescription} = await uploadFile(server, encrypted)
|
||||
const {content} = await downloadFile(rcvDescription)
|
||||
expect(content).toEqual(data)
|
||||
})
|
||||
```
|
||||
|
||||
## 4. Success Criteria
|
||||
|
||||
1. `npm run build` — agent.ts compiles without node: imports
|
||||
2. `cabal test --test-option='--match=/XFTP Web Client/'` — existing Node.js tests still pass
|
||||
3. `npm run test:browser` — browser round-trip test passes in headless Chromium
|
||||
|
||||
## 5. Files to Create/Modify
|
||||
|
||||
**Modify:**
|
||||
- `xftp-web/package.json` — add vitest, @vitest/browser, playwright, pako, @types/pako
|
||||
- `xftp-web/src/agent.ts` — replace node:crypto, node:zlib with isomorphic alternatives
|
||||
|
||||
**Create:**
|
||||
- `xftp-web/vitest.config.ts` — browser mode config
|
||||
- `xftp-web/test/globalSetup.ts` — xftp-server lifecycle
|
||||
- `xftp-web/test/browser.test.ts` — browser round-trip test
|
||||
|
||||
## 6. Order of Implementation
|
||||
|
||||
1. **Add pako dependency** — `npm install pako @types/pako`
|
||||
2. **Make agent.ts isomorphic** — replace node:crypto, node:zlib
|
||||
3. **Verify Node.js tests pass** — `cabal test --test-option='--match=/XFTP Web Client/'`
|
||||
4. **Set up Vitest** — add devDeps, create vitest.config.ts
|
||||
5. **Create globalSetup.ts** — write INI config, spawn xftp-server
|
||||
6. **Write browser test** — upload + download round-trip
|
||||
7. **Verify browser test passes** — `npm run test:browser`
|
||||
@@ -8,15 +8,21 @@
|
||||
"scripts": {
|
||||
"postinstall": "ln -sf ../../../libsodium-sumo/dist/modules-sumo-esm/libsodium-sumo.mjs node_modules/libsodium-wrappers-sumo/dist/modules-sumo-esm/libsodium-sumo.mjs",
|
||||
"build": "tsc",
|
||||
"test": "node --experimental-vm-modules node_modules/.bin/jest"
|
||||
"test": "node --experimental-vm-modules node_modules/.bin/jest",
|
||||
"test:browser": "vitest"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/libsodium-wrappers-sumo": "^0.7.8",
|
||||
"@types/node": "^20.0.0",
|
||||
"typescript": "^5.4.0"
|
||||
"@types/pako": "^2.0.3",
|
||||
"@vitest/browser": "^3.0.0",
|
||||
"playwright": "^1.50.0",
|
||||
"typescript": "^5.4.0",
|
||||
"vitest": "^3.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@noble/curves": "^1.9.7",
|
||||
"libsodium-wrappers-sumo": "^0.7.13"
|
||||
"libsodium-wrappers-sumo": "^0.7.13",
|
||||
"pako": "^2.1.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,8 +3,7 @@
|
||||
// Combines all building blocks: encryption, chunking, XFTP client commands,
|
||||
// file descriptions, and DEFLATE-compressed URI encoding.
|
||||
|
||||
import crypto from "node:crypto"
|
||||
import zlib from "node:zlib"
|
||||
import pako from "pako"
|
||||
import {encryptFile, encodeFileHeader} from "./crypto/file.js"
|
||||
import {generateEd25519KeyPair, encodePubKeyEd25519, encodePrivKeyEd25519, decodePrivKeyEd25519, ed25519KeyPairFromSeed} from "./crypto/keys.js"
|
||||
import {sha512} from "./crypto/digest.js"
|
||||
@@ -61,13 +60,13 @@ export interface DownloadResult {
|
||||
|
||||
export function encodeDescriptionURI(fd: FileDescription): string {
|
||||
const yaml = encodeFileDescription(fd)
|
||||
const compressed = zlib.deflateRawSync(Buffer.from(yaml))
|
||||
return base64urlEncode(new Uint8Array(compressed))
|
||||
const compressed = pako.deflateRaw(new TextEncoder().encode(yaml))
|
||||
return base64urlEncode(compressed)
|
||||
}
|
||||
|
||||
export function decodeDescriptionURI(fragment: string): FileDescription {
|
||||
const compressed = base64urlDecode(fragment)
|
||||
const yaml = zlib.inflateRawSync(Buffer.from(compressed)).toString()
|
||||
const yaml = new TextDecoder().decode(pako.inflateRaw(compressed))
|
||||
const fd = decodeFileDescription(yaml)
|
||||
const err = validateFileDescription(fd)
|
||||
if (err) throw new Error("decodeDescriptionURI: " + err)
|
||||
@@ -77,8 +76,10 @@ export function decodeDescriptionURI(fragment: string): FileDescription {
|
||||
// ── Upload ──────────────────────────────────────────────────────
|
||||
|
||||
export function encryptFileForUpload(source: Uint8Array, fileName: string): EncryptedFileInfo {
|
||||
const key = new Uint8Array(crypto.randomBytes(32))
|
||||
const nonce = new Uint8Array(crypto.randomBytes(24))
|
||||
const key = new Uint8Array(32)
|
||||
const nonce = new Uint8Array(24)
|
||||
crypto.getRandomValues(key)
|
||||
crypto.getRandomValues(nonce)
|
||||
const fileHdr = encodeFileHeader({fileName, fileExtra: null})
|
||||
const fileSize = BigInt(fileHdr.length + source.length)
|
||||
const payloadSize = Number(fileSize) + fileSizeLen + authTagSize
|
||||
|
||||
@@ -86,8 +86,19 @@ function createBrowserTransport(baseUrl: string): Transport {
|
||||
|
||||
// ── Connect + handshake ───────────────────────────────────────────
|
||||
|
||||
// Browser HTTP/2 connections are pooled per origin — the server binds a session
|
||||
// to the TLS connection, so a second handshake on the same connection fails.
|
||||
// Cache clients by baseUrl in browser environments to reuse the session.
|
||||
const browserClients = new Map<string, XFTPClient>()
|
||||
|
||||
export async function connectXFTP(server: XFTPServer): Promise<XFTPClient> {
|
||||
const baseUrl = "https://" + server.host + ":" + server.port
|
||||
|
||||
if (!isNode) {
|
||||
const cached = browserClients.get(baseUrl)
|
||||
if (cached) return cached
|
||||
}
|
||||
|
||||
const transport = await createTransport(baseUrl)
|
||||
|
||||
try {
|
||||
@@ -118,7 +129,9 @@ export async function connectXFTP(server: XFTPServer): Promise<XFTPClient> {
|
||||
const ack = await transport.post(encodeClientHandshake({xftpVersion, keyHash: server.keyHash}))
|
||||
if (ack.length !== 0) throw new Error("connectXFTP: non-empty handshake ack")
|
||||
|
||||
return {baseUrl, sessionId: hs.sessionId, xftpVersion, transport}
|
||||
const client = {baseUrl, sessionId: hs.sessionId, xftpVersion, transport}
|
||||
if (!isNode) browserClients.set(baseUrl, client)
|
||||
return client
|
||||
} catch (e) {
|
||||
transport.close()
|
||||
throw e
|
||||
@@ -211,5 +224,7 @@ export async function pingXFTP(c: XFTPClient): Promise<void> {
|
||||
// ── Close ─────────────────────────────────────────────────────────
|
||||
|
||||
export function closeXFTP(c: XFTPClient): void {
|
||||
c.transport.close()
|
||||
// In the browser, HTTP/2 connections are pooled per origin — closing is
|
||||
// a no-op since the connection persists and the session must stay cached.
|
||||
if (isNode) c.transport.close()
|
||||
}
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 2.0 KiB |
14
xftp-web/test/browser.test.ts
Normal file
14
xftp-web/test/browser.test.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import {test, expect} from 'vitest'
|
||||
import {encryptFileForUpload, uploadFile, downloadFile} from '../src/agent.js'
|
||||
import {parseXFTPServer} from '../src/protocol/address.js'
|
||||
|
||||
const server = parseXFTPServer(import.meta.env.XFTP_SERVER)
|
||||
|
||||
test('browser upload + download round-trip', async () => {
|
||||
const data = new Uint8Array(50000)
|
||||
crypto.getRandomValues(data)
|
||||
const encrypted = encryptFileForUpload(data, 'test.bin')
|
||||
const {rcvDescription} = await uploadFile(server, encrypted)
|
||||
const {content} = await downloadFile(rcvDescription)
|
||||
expect(content).toEqual(data)
|
||||
})
|
||||
149
xftp-web/test/globalSetup.ts
Normal file
149
xftp-web/test/globalSetup.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
import {spawn, execSync, ChildProcess} from 'child_process'
|
||||
import {createHash} from 'crypto'
|
||||
import {createConnection} from 'net'
|
||||
import {resolve, join} from 'path'
|
||||
import {readFileSync, mkdtempSync, writeFileSync, copyFileSync, existsSync, unlinkSync} from 'fs'
|
||||
import {tmpdir} from 'os'
|
||||
|
||||
const XFTP_PORT = 7000
|
||||
const LOCK_FILE = join(tmpdir(), 'xftp-test-server.pid')
|
||||
|
||||
let server: ChildProcess | null = null
|
||||
let isOwner = false
|
||||
|
||||
export async function setup() {
|
||||
// Vitest browser mode calls globalSetup twice; only the first should start the server
|
||||
if (existsSync(LOCK_FILE)) {
|
||||
const pid = parseInt(readFileSync(LOCK_FILE, 'utf-8').trim(), 10)
|
||||
try {
|
||||
process.kill(pid, 0) // check if process exists
|
||||
} catch (_) {
|
||||
// Stale lock from a crashed run — clean up
|
||||
unlinkSync(LOCK_FILE)
|
||||
// Kill any orphaned xftp-server on our port
|
||||
try { execSync(`kill $(lsof -ti :${XFTP_PORT}) 2>/dev/null`, {stdio: 'ignore'}) } catch (_) {}
|
||||
await new Promise(r => setTimeout(r, 500))
|
||||
}
|
||||
}
|
||||
if (existsSync(LOCK_FILE)) {
|
||||
await waitForPort(XFTP_PORT)
|
||||
return
|
||||
}
|
||||
writeFileSync(LOCK_FILE, String(process.pid))
|
||||
isOwner = true
|
||||
|
||||
const fixtures = resolve(__dirname, '../../tests/fixtures')
|
||||
|
||||
// Create temp directories
|
||||
const cfgDir = mkdtempSync(join(tmpdir(), 'xftp-cfg-'))
|
||||
const logDir = mkdtempSync(join(tmpdir(), 'xftp-log-'))
|
||||
const filesDir = mkdtempSync(join(tmpdir(), 'xftp-files-'))
|
||||
|
||||
// Copy certificates to cfgDir (xftp-server expects ca.crt, server.key, server.crt there)
|
||||
copyFileSync(join(fixtures, 'ca.crt'), join(cfgDir, 'ca.crt'))
|
||||
copyFileSync(join(fixtures, 'server.key'), join(cfgDir, 'server.key'))
|
||||
copyFileSync(join(fixtures, 'server.crt'), join(cfgDir, 'server.crt'))
|
||||
|
||||
// Write fingerprint file (checkSavedFingerprint reads this on startup)
|
||||
// Fingerprint = SHA-256 of DER-encoded certificate (not PEM)
|
||||
const pem = readFileSync(join(fixtures, 'ca.crt'), 'utf-8')
|
||||
const der = Buffer.from(pem.replace(/-----[^-]+-----/g, '').replace(/\s/g, ''), 'base64')
|
||||
const fp = createHash('sha256').update(der).digest('base64').replace(/\+/g, '-').replace(/\//g, '_')
|
||||
writeFileSync(join(cfgDir, 'fingerprint'), fp + '\n')
|
||||
|
||||
// Write INI config file
|
||||
const iniContent = `[STORE_LOG]
|
||||
enable: off
|
||||
|
||||
[TRANSPORT]
|
||||
host: localhost
|
||||
port: ${XFTP_PORT}
|
||||
|
||||
[FILES]
|
||||
path: ${filesDir}
|
||||
|
||||
[WEB]
|
||||
cert: ${join(fixtures, 'web.crt')}
|
||||
key: ${join(fixtures, 'web.key')}
|
||||
`
|
||||
writeFileSync(join(cfgDir, 'file-server.ini'), iniContent)
|
||||
|
||||
// Resolve binary path once (avoids cabal rebuild check on every run)
|
||||
const serverBin = execSync('cabal -v0 list-bin xftp-server', {encoding: 'utf-8'}).trim()
|
||||
|
||||
// Spawn xftp-server directly
|
||||
server = spawn(serverBin, ['start'], {
|
||||
env: {
|
||||
...process.env,
|
||||
XFTP_SERVER_CFG_PATH: cfgDir,
|
||||
XFTP_SERVER_LOG_PATH: logDir
|
||||
},
|
||||
stdio: ['ignore', 'pipe', 'pipe']
|
||||
})
|
||||
|
||||
server.stderr?.on('data', (data: Buffer) => {
|
||||
console.error('[xftp-server]', data.toString())
|
||||
})
|
||||
|
||||
// Poll-connect until the server is actually listening
|
||||
await waitForServerReady(server, XFTP_PORT)
|
||||
}
|
||||
|
||||
export async function teardown() {
|
||||
if (isOwner) {
|
||||
try { unlinkSync(LOCK_FILE) } catch (_) {}
|
||||
if (server) {
|
||||
server.kill('SIGTERM')
|
||||
await new Promise<void>(resolve => {
|
||||
server!.on('exit', () => resolve())
|
||||
setTimeout(resolve, 3000)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function waitForServerReady(proc: ChildProcess, port: number): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let settled = false
|
||||
const timeout = setTimeout(() => {
|
||||
settled = true
|
||||
reject(new Error('Server start timeout'))
|
||||
}, 15000)
|
||||
const settle = (fn: () => void) => { if (!settled) { settled = true; clearTimeout(timeout); fn() } }
|
||||
proc.on('error', (e) => settle(() => reject(e)))
|
||||
proc.on('exit', (code) => {
|
||||
if (code !== 0) settle(() => reject(new Error(`Server exited with code ${code}`)))
|
||||
})
|
||||
// printXFTPConfig prints "Listening on port" BEFORE bind, so poll-connect
|
||||
const poll = () => {
|
||||
if (settled) return
|
||||
const sock = createConnection({port, host: 'localhost'}, () => {
|
||||
sock.destroy()
|
||||
settle(() => resolve())
|
||||
})
|
||||
sock.on('error', () => {
|
||||
sock.destroy()
|
||||
setTimeout(poll, 100)
|
||||
})
|
||||
}
|
||||
setTimeout(poll, 200)
|
||||
})
|
||||
}
|
||||
|
||||
function waitForPort(port: number): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const deadline = Date.now() + 15000
|
||||
const poll = () => {
|
||||
if (Date.now() > deadline) return reject(new Error('Timed out waiting for server'))
|
||||
const sock = createConnection({port, host: 'localhost'}, () => {
|
||||
sock.destroy()
|
||||
resolve()
|
||||
})
|
||||
sock.on('error', () => {
|
||||
sock.destroy()
|
||||
setTimeout(poll, 100)
|
||||
})
|
||||
}
|
||||
poll()
|
||||
})
|
||||
}
|
||||
25
xftp-web/vitest.config.ts
Normal file
25
xftp-web/vitest.config.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import {defineConfig} from 'vitest/config'
|
||||
import {readFileSync} from 'fs'
|
||||
import {createHash} from 'crypto'
|
||||
|
||||
// Compute fingerprint from ca.crt (SHA-256 of DER, same as Haskell's loadFileFingerprint)
|
||||
const pem = readFileSync('../tests/fixtures/ca.crt', 'utf-8')
|
||||
const der = Buffer.from(pem.replace(/-----[^-]+-----/g, '').replace(/\s/g, ''), 'base64')
|
||||
const fingerprint = createHash('sha256').update(der).digest('base64').replace(/\+/g, '-').replace(/\//g, '_')
|
||||
const serverAddr = `xftp://${fingerprint}@localhost:7000`
|
||||
|
||||
export default defineConfig({
|
||||
define: {
|
||||
'import.meta.env.XFTP_SERVER': JSON.stringify(serverAddr)
|
||||
},
|
||||
test: {
|
||||
include: ['test/**/*.test.ts'],
|
||||
browser: {
|
||||
enabled: true,
|
||||
provider: 'playwright',
|
||||
instances: [{browser: 'chromium'}],
|
||||
headless: true
|
||||
},
|
||||
globalSetup: './test/globalSetup.ts'
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user