// Copyright (C) 2026 Doan Luu Duc Tai // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . import assert from 'node:assert/strict'; import type { ServerResponse } from 'node:http'; import { readFile, writeFile, mkdir, mkdtemp, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join, resolve } from 'node:path'; import { chromium, firefox, type BrowserContext, type Page } from 'playwright'; import { asyncServer, libreJSAddon, required, serverOrigin } from '../../../tests/browser-support.js'; const libre = process.env['CHAT_LIBREJS'] === '1'; const profile = await mkdtemp(join(tmpdir(),'stack-chat-progress-')); const bytes = await readFile(resolve('target/wasm32-wasip1/release/stack-chat-crypto.wasm')); let mode = 'known'; let transfer: ServerResponse | undefined; let configuration: ServerResponse | undefined; let configReady: Promise | undefined; let signalConfig: (() => void) | undefined; let origin = ''; const errors: string[] = []; const server = asyncServer(async (request,response) => { response.setHeader('Cache-Control','no-store'); if (request.url === '/crypto.wasm') { if (mode === 'failure') { response.writeHead(503); response.end(); return; } response.setHeader('Content-Type','application/wasm'); if (mode !== 'unknown') response.setHeader('Content-Length',bytes.length); transfer = response; response.write(bytes.subarray(0,1048576)); return; } if (request.url === '/api') { configuration = response; required(signalConfig, 'Missing configuration signal')(); return; } const assets: Record = { '/':'crates/chat-server/web/index.html','/app.js':'var/build/typescript/crates/chat-server/web/app.js','/style.css':'crates/chat-server/web/style.css' }; const path = assets[(request.url ?? '')]; if (path === undefined || path.length === 0) { response.writeHead(404); response.end(); return; } const mime = request.url === '/' ? 'text/html' : (request.url?.endsWith('.js') === true) ? 'text/javascript' : 'text/css'; response.setHeader('Content-Type',mime); let source = await readFile(resolve(path),'utf8'); if (request.url === '/') source = source.replaceAll('{{SITE_ORIGIN}}',origin); response.end(source); }); await new Promise(done => server.listen(0,'127.0.0.1',done)); origin = serverOrigin(server); let browser; let context: BrowserContext; if (libre) { await mkdir(join(profile,'extensions')); const addon = await readFile(resolve('.tools/librejs.xpi')); await writeFile(join(profile,'extensions','jid1-KtlZuoiikVfFew@jetpack.xpi'),addon); context = await firefox.launchPersistentContext(profile,{headless:true,firefoxUserPrefs:{'extensions.autoDisableScopes':0,'extensions.enabledScopes':15,'extensions.startupScanScopes':15,'extensions.update.enabled':false}}); browser = {close:(): Promise => context.close()}; } else { browser = await chromium.launch({headless:true,channel:'chromium'}); context = await browser.newContext(); } async function open(selected: 'known' | 'unknown' | 'failure'): Promise { mode = selected; configuration = undefined; configReady = new Promise(done => { signalConfig = done; }); const page = await context.newPage(); page.on('pageerror',error => errors.push(error.message)); await page.clock.install(); await page.goto(origin); return page; } async function waitStatus(page: Page, part: string): Promise { await page.waitForFunction(value => document.getElementById('status')?.textContent.includes(value),part); return (await page.locator('#status').textContent() ?? ''); } async function complete(page: Page, offset: number): Promise { required(transfer, 'Missing transfer').end(bytes.subarray(offset)); await waitStatus(page,'Connecting to chat'); await configReady; required(configuration, 'Missing configuration').setHeader('Content-Type','application/json'); required(configuration, 'Missing configuration').end(JSON.stringify({origin,owner:'',protocol:'stack-chat/v1'})); await page.locator('#owner-choice').waitFor({state:'visible'}); assert.equal(await page.locator('#status').textContent(),''); assert.equal(await page.evaluate(async () => (await wasm).byteLength),bytes.length); await page.close(); } try { const known = await open('known'); assert.match(await waitStatus(known,'1.0 /'),/\[#{3}-{17}\] 19%/); await known.clock.fastForward(44000); required(transfer, 'Missing transfer').write(bytes.subarray(1048576,2097152)); assert.match(await waitStatus(known,'2.0 /'),/39%/); await known.screenshot({path:resolve('.tools/chat-download-progress.png'),fullPage:true}); await known.clock.fastForward(44000); assert.ok(!((await known.locator('#status').textContent() ?? '')).includes('stalled')); await complete(known,2097152); const unknown = await open('unknown'); assert.equal(await waitStatus(unknown,'1.0 MiB received'),'Downloading cryptographic module... 1.0 MiB received'); await complete(unknown,1048576); const failure = await open('failure'); await waitStatus(failure,'HTTP 503'); assert.equal(await failure.locator('#owner-choice').isVisible(),false); await failure.close(); const stalled = await open('known'); await waitStatus(stalled,'1.0 /'); await stalled.clock.fastForward(45001); await waitStatus(stalled,'stalled for 45 seconds'); assert.match((await stalled.locator('#status').textContent() ?? ''),/reload/); assert.equal(await stalled.locator('#owner-choice').isVisible(),false); await stalled.close(); assert.deepEqual(errors,[]); if (libre) { assert.ok(libreJSAddon(await readFile(join(profile,'extensions.json'),'utf8')).active); } console.log(`PASS: ${libre ? 'Firefox/LibreJS' : 'Chromium'} streamed percentage/bar/MiB, unknown length, exact byte recovery, slow continuous transfer, idle timeout, HTTP failure, and connection phase.`); } finally { await browser.close(); server.closeAllConnections(); await new Promise(done => server.close(done)); await rm(profile,{recursive:true,force:true}); }