// Isolated real-component browser fixture, not a Proxmox integration test.
// Requires AppImage dependencies, esbuild, playwright + Chromium, and a completed frontend build.
// Run: node tests/test_borg_ssh_browser.cjs /absolute/path/to/evidence-directory
// All navigation/assets/API requests are intercepted BEFORE navigation; nothing reaches a host.
const fs = require('node:fs');
const path = require('node:path');
const assert = require('node:assert/strict');
const { createRequire } = require('node:module');
const app = path.resolve(__dirname, '../AppImage');
const req = createRequire(path.join(app, 'package.json'));
const { build } = req('esbuild');
const { chromium } = req('playwright');
const out = path.resolve(process.argv[2] || 'borg-browser-evidence');
fs.mkdirSync(out, { recursive: true });
const en = JSON.parse(fs.readFileSync(path.join(app, 'messages/en/common.json')));
const cssDir = process.env.BORG_SSH_CSS_DIR || path.join(app, 'out/_next/static/css');
const css = fs.readdirSync(cssDir).filter(p => p.endsWith('.css')).map(p => fs.readFileSync(path.join(cssDir, p), 'utf8')).join('\n');
const expanded = {
sshUserHelpMessage: 'FIXTURE REMOTE ACCOUNT: the account on the remote repository host, not a local command.',
sshKeyHelpMessage: '{action}: FIXTURE ACTION FIRST. Review the public-key line before adding it to the remote account authorized_keys file. The private key remains on this host.',
sshKeySetupTitle: 'FIXTURE SSH key setup',
sshKeySetupHelp: 'FIXTURE INITIAL: creates a missing local key, otherwise reads its public key. Nothing is installed on the remote host by this action.',
sshAuthorizedKeyHelp: '{user}: FIXTURE USER FIRST. Review the following public-key line before manually adding it to the remote account ~/.ssh/authorized_keys.',
};
const records = [], unexpected = [], errors = [], requests = [];
(async () => {
const bundle = await build({
stdin: { contents: `import React from 'react';
import {createRoot} from 'react-dom/client';
import {I18nProvider} from './lib/i18n/provider';
import {BorgFixtureDialog} from './components/host-backup';
const editing = new URLSearchParams(location.search).has('new') ? null : {kind:'borg',name:'fixture',repository:'ssh://borg@backup.example.invalid/backup/repo',ssh_key_path:'/root/.ssh/proxmenux_borg',encrypt_mode:'none'};
createRoot(document.getElementById('root')).render({}} onSaved={()=>{throw Error('Save must never be submitted')}} />);`,
resolveDir: app, sourcefile: 'borg-fixture.tsx', loader: 'tsx' },
bundle: true, write: false, outfile: 'fixture.js', format: 'iife', platform: 'browser', jsx: 'automatic',
define: { 'process.env.NODE_ENV': '"development"', 'process.env.NEXT_PUBLIC_API_PORT': '"8008"' },
plugins: [{ name: 'test-only-exports-and-synthetic-locale', setup(b) {
b.onLoad({ filter: /host-backup\.tsx$/ }, args => ({ contents: fs.readFileSync(args.path, 'utf8') + '\nexport { AddDestinationDialog as BorgFixtureDialog };', loader: 'tsx' }));
b.onLoad({ filter: /messages\/(de|it)\/common\.json$/ }, args => {
const locale = JSON.parse(fs.readFileSync(args.path));
if (args.path.endsWith('/de/common.json')) {
Object.assign(locale.backup.destinations, expanded);
locale.backup.actions.prepareSshKey = 'Prepare fixture key';
} else {
// Unconditional missing-key fixture survives future automated translations.
for (const key of Object.keys(expanded)) delete locale.backup.destinations[key];
delete locale.backup.actions.prepareSshKey;
}
return { contents: JSON.stringify(locale), loader: 'json' };
});
}}],
});
const javascript = bundle.outputFiles[0].text;
const browser = await chromium.launch({ headless: true });
try {
for (const viewport of [{ width: 1440, height: 1000 }, { width: 390, height: 844 }]) {
for (const theme of ['light', 'dark']) for (const language of ['en', 'it', 'de']) {
const id = `${viewport.width}-${theme}-${language}`;
const context = await browser.newContext({ viewport, colorScheme: theme, serviceWorkers: 'block' });
const page = await context.newPage();
page.on('pageerror', e => errors.push({ id, message: e.message }));
let release = null, calls = 0;
const fixtureLine = 'command="borg serve --restrict-to-path /backup/repo",restrict ssh-ed25519 AAAA-fixture-only \n';
// No route.continue() exists: unexpected traffic is aborted and fails the test.
await context.route('**/*', async route => {
const request = route.request(), url = new URL(request.url());
requests.push({ id, method: request.method(), url: request.url(), body: request.postData() });
if (url.origin === 'https://borg.fixture.invalid' && request.method() === 'GET') {
if (url.pathname === '/') return route.fulfill({ contentType: 'text/html', body: `` });
if (url.pathname === '/fixture.js') return route.fulfill({ contentType: 'application/javascript', body: javascript });
if (url.pathname === '/fixture.css') return route.fulfill({ contentType: 'text/css', body: css });
if (url.pathname === '/api/host-backups/destinations') return route.fulfill({ json: { pbs: [], borg: [], local: { entries: [] } } });
if (url.pathname === '/api/host-backups/usb-drives') return route.fulfill({ json: { drives: [] } });
}
if (url.origin === 'https://borg.fixture.invalid' && url.pathname === '/api/host-backups/ssh-keys/generate' && request.method() === 'POST') {
calls++;
const body = request.postDataJSON();
records.push({ id, fixture: calls === 1 ? 'fresh-key' : calls === 2 ? 'existing-key' : 'error', body });
await new Promise(resolve => { release = resolve; });
release = null;
if (calls === 3) return route.fulfill({ status: 500, json: { error: 'Fixture: public key unavailable' } });
return route.fulfill({ json: { public_key: 'ssh-ed25519 AAAA-fixture-only', authorized_keys_line: fixtureLine } });
}
unexpected.push({ id, method: request.method(), url: request.url() });
return route.abort('blockedbyclient');
});
await context.addInitScript(({ language }) => {
localStorage.setItem('proxmenux-ui-language', language);
}, { language });
await page.goto('https://borg.fixture.invalid/');
await page.waitForFunction(language => document.documentElement.lang === language, language);
assert.equal(await page.evaluate(() => document.documentElement.classList.contains('dark')), theme === 'dark');
await page.locator('#borgKeyPath').waitFor();
const action = language === 'de' ? 'Prepare fixture key' : 'Prepare key';
const button = page.getByRole('button', { name: action, exact: true });
await button.waitFor();
assert.equal(await page.locator('#borgKeyPath').inputValue(), '/root/.ssh/proxmenux_borg');
assert.ok((await page.locator('[role=dialog]').innerText()).includes(language === 'de' ? 'FIXTURE INITIAL' : en.backup.destinations.sshKeySetupHelp));
await button.evaluate(button => button.parentElement.parentElement.scrollIntoView({ block: 'center' }));
assert.equal(await button.evaluate(button => {
const panel = button.parentElement.parentElement;
return [panel, ...panel.querySelectorAll('p, button, span')].every(e => e.scrollWidth <= e.clientWidth + 1 || getComputedStyle(e).display === 'inline');
}), true, `${id}: initial setup guidance fits`);
await page.screenshot({ animations: 'disabled', path: path.join(out, `${id}-initial.png`) });
// Blank input remains disabled; no attempt is sent.
await page.locator('#borgKeyPath').fill('');
assert.equal(await button.isDisabled(), true);
assert.equal(calls, 0);
const longPath = '/fixture/long-local-private-key-directory/'.repeat(6) + 'key';
await page.locator('#borgKeyPath').fill(longPath);
await page.locator('#borgSshUser').fill('root');
await button.click();
await page.waitForFunction(() => document.querySelector('button svg.animate-spin'));
assert.equal(await button.isDisabled(), true);
assert.equal(await button.innerText(), action);
assert.ok(release, 'intercepted mock POST is pending');
release();
await page.locator('textarea[readonly]').waitFor();
assert.equal(await page.locator('textarea[readonly]').inputValue(), fixtureLine);
assert.ok((await page.locator('[role=dialog]').innerText()).includes(language === 'de' ? 'root: FIXTURE USER FIRST' : 'for root on the remote host.'));
// Changing input preserves the last response: characterize, do not change stale-response behavior.
await page.locator('#borgKeyPath').fill('/fixture/already-existing-private-key');
await page.locator('#borgSshUser').fill('');
assert.equal(await page.locator('textarea[readonly]').inputValue(), fixtureLine);
await button.click();
await page.waitForFunction(() => document.querySelector('button svg.animate-spin'));
assert.equal(await button.innerText(), action);
release();
await page.waitForFunction(() => !document.querySelector('button svg.animate-spin'));
assert.equal(await page.locator('textarea[readonly]').inputValue(), fixtureLine);
assert.equal(await page.locator('archive-user').count(), 0);
assert.ok((await page.locator('[role=dialog]').innerText()).includes(''));
await button.evaluate(button => button.parentElement.parentElement.scrollIntoView({ block: 'center' }));
// Measure the changed guidance, its action, and result area, not unrelated whole-dialog layout.
const layout = await button.evaluate(button => {
const panel = button.parentElement.parentElement;
const help = document.querySelector('#borgKeyPath').parentElement.querySelector('p');
const userHelp = document.querySelector('#borgSshUser').parentElement.querySelector('p');
const elements = [panel, button.parentElement, button, help, userHelp, panel.querySelector('p'), panel.querySelector('textarea')];
return elements.map(e => {
const r = e.getBoundingClientRect();
return { tag: e.tagName, text: e.tagName === 'TEXTAREA' ? '[fixture public-key line]' : e.textContent, client: e.clientWidth, scroll: e.scrollWidth, left: r.left, right: r.right, viewport: innerWidth };
});
});
for (const e of layout.filter(e => e.tag !== 'TEXTAREA')) {
assert.ok(e.scroll <= e.client + 1, `${id}: guidance overflow ${JSON.stringify(e)}`);
assert.ok(e.left >= 0 && e.right <= viewport.width + 1, `${id}: guidance outside viewport`);
}
await page.screenshot({ animations: 'disabled', path: path.join(out, `${id}-result.png`) });
records.push({ id, fixture: 'layout', layout });
await button.click();
await page.waitForFunction(() => document.querySelector('button svg.animate-spin'));
release();
await page.getByText('Fixture: public key unavailable', { exact: true }).waitFor();
assert.equal(await button.isEnabled(), true);
assert.equal(await button.innerText(), action);
assert.equal(await page.locator('textarea[readonly]').inputValue(), fixtureLine);
assert.equal(calls, 3);
assert.deepEqual(records.filter(r => r.id === id && r.body).map(r => r.body), [
{ key_path: longPath, remote_path: '/backup/repo' },
{ key_path: '/fixture/already-existing-private-key', remote_path: '/backup/repo' },
{ key_path: '/fixture/already-existing-private-key', remote_path: '/backup/repo' },
], 'real component submits the current path, not a fixed path or the previous response');
await context.close();
}
}
assert.deepEqual(unexpected, []);
assert.deepEqual(errors, []);
const posts = requests.filter(r => r.method === 'POST');
assert.equal(posts.length, 36);
assert.ok(posts.every(p => p.url.endsWith('/ssh-keys/generate')));
console.log('PASS: 12 real-component/provider browser scenarios; desktop/mobile × light/dark × English/Italian fallback/synthetic expanded translation; 36 mocked POSTs, 0 real API calls.');
} finally {
fs.writeFileSync(path.join(out, 'browser-results.json'), JSON.stringify({ records, requests, unexpected, errors }, null, 2));
await browser.close();
}
})().catch(e => { console.error(e); process.exitCode = 1; });