diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ba70c923..81dd1bf6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -65,3 +65,33 @@ jobs: run: | $HOME/.cargo/bin/cargo install cargo-audit $HOME/.cargo/bin/cargo audit + + e2e: + name: Browser tests + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v7 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: Install Playwright + working-directory: tests/e2e + run: | + npm ci + npx playwright install --with-deps chromium + + - name: End-to-end tests + # The real static pages against a stubbed API: see tests/e2e/README.md. + working-directory: tests/e2e + run: npm test + + - name: Keep traces of failures + if: failure() + uses: actions/upload-artifact@v4 + with: + name: playwright-traces + path: tests/e2e/test-results/ + if-no-files-found: ignore diff --git a/docs/reverse-proxies.md b/docs/reverse-proxies.md index 5fc65aae..1b9d58c6 100644 --- a/docs/reverse-proxies.md +++ b/docs/reverse-proxies.md @@ -218,3 +218,36 @@ trusted_proxies = ["127.0.0.1/32"] Without this setting, all requests from behind the proxy appear to come from the proxy's own IP. + +## Content-Security-Policy + +rustguac sends its own `Content-Security-Policy` on every response. Leave +it alone: the Guacamole client needs `img-src 'self' data: blob:` because +browsers without WebCodecs `ImageDecoder` (Safari, Firefox) draw every +image tile from a `data:` URI. If your proxy sets a CSP of its own, keep +that directive, or SSH/RDP sessions render black in those browsers while +Chrome looks fine. + +Releases up to v1.10.5 shipped a policy with no `img-src`, which is +exactly that failure (#249). If you can't upgrade, have the proxy replace +the header. The whole policy has to be repeated because the header is +replaced, not merged: + +HAProxy (backend section): + +``` +http-response set-header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; connect-src 'self' wss: ws:; img-src 'self' data: blob:" +``` + +nginx: + +```nginx +proxy_hide_header Content-Security-Policy; +add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; connect-src 'self' wss: ws:; img-src 'self' data: blob:" always; +``` + +Caddy: + +``` +header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; connect-src 'self' wss: ws:; img-src 'self' data: blob:" +``` diff --git a/src/main.rs b/src/main.rs index 4bd2991b..06f018b9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -653,6 +653,23 @@ fn cmd_map_group(database: &Db, group: &str, role: &str, force: bool) { #[derive(Clone)] struct TlsEnabled(bool); +/// Content-Security-Policy sent with every response. +/// +/// `img-src` must allow `data:` and `blob:`. The Guacamole client draws each +/// `img` stream by loading a `data:image/...;base64,...` URI into an `Image` +/// (the `DataURIReader` path in `Display.drawStream`) on browsers without +/// WebCodecs `ImageDecoder`, which is Safari and Firefox. Without `img-src` +/// the `default-src 'self'` fallback refuses those loads, the client silently +/// skips the draw, and SSH/RDP sessions render black with only rect fills +/// showing (#249). Chromium decodes straight from the stream bytes and never +/// fetches a URL, so it hid the problem. The remote mouse cursor +/// (`cursor: url(data:...)`) and blob: image URLs need the same allowance. +const CONTENT_SECURITY_POLICY: &str = "default-src 'self'; \ + script-src 'self' 'unsafe-inline'; \ + style-src 'self' 'unsafe-inline'; \ + connect-src 'self' wss: ws:; \ + img-src 'self' data: blob:"; + async fn security_headers( tls: Extension, request: Request, @@ -672,7 +689,7 @@ async fn security_headers( ); headers.insert( "Content-Security-Policy", - "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; connect-src 'self' wss: ws:".parse().unwrap(), + CONTENT_SECURITY_POLICY.parse().unwrap(), ); if tls.0 .0 { headers.insert( @@ -1818,6 +1835,24 @@ mod tests { use super::*; + #[test] + fn test_csp_allows_data_and_blob_images() { + // Safari and Firefox draw Guacamole image streams through data: URIs + // (#249). Chromium uses ImageDecoder and would not catch a regression. + let directives: Vec<&str> = CONTENT_SECURITY_POLICY.split(';').map(str::trim).collect(); + let img_src = directives + .iter() + .find(|d| d.starts_with("img-src ")) + .expect("CSP must carry an explicit img-src directive"); + let sources: Vec<&str> = img_src.split_whitespace().skip(1).collect(); + assert!(sources.contains(&"'self'"), "img-src must allow 'self'"); + assert!(sources.contains(&"data:"), "img-src must allow data: URIs"); + assert!(sources.contains(&"blob:"), "img-src must allow blob: URLs"); + // The string continuation must not leave stray whitespace runs. + assert!(!CONTENT_SECURITY_POLICY.contains(" ")); + assert!(!CONTENT_SECURITY_POLICY.contains('\n')); + } + #[test] fn test_version_assets_rewrites_local_js_and_css() { // Tests run from the crate root, so this is the real static tree. diff --git a/static/client.html b/static/client.html index cf477195..d5f71664 100644 --- a/static/client.html +++ b/static/client.html @@ -1466,14 +1466,37 @@

Session Ended

if (x) { try { x.call(document); } catch (e) {} } } function rgArmFullscreenGesture() { - var fire = function() { + var fire = function(e) { document.removeEventListener('mousedown', fire, true); document.removeEventListener('keydown', fire, true); + // This press is the browser's fullscreen gesture, not + // input for the remote. The transition it starts re-lays + // out the display and swallows the matching release, and + // a press guacd never sees released turns every later + // pointer move into a drag selection (#256). Keep it from + // the display's handlers; rgReleaseAllInput() covers a + // release lost for any other reason once fullscreen lands. + e.stopPropagation(); + e.preventDefault(); rgEnterFullscreen(); }; document.addEventListener('mousedown', fire, true); document.addEventListener('keydown', fire, true); } + // Tell the remote that nothing is held: every key the keyboard + // tracks as down, and all mouse buttons at the last pointer + // position. Used after a fullscreen transition, during which the + // browser may not deliver the releases to the display element. + function rgReleaseAllInput() { + try { if (keyboard) keyboard.reset(); } catch (e) {} + try { + if (!mouse || !mouse.currentState) return; + var st = new Guacamole.Mouse.State(mouse.currentState); + st.left = st.middle = st.right = st.up = st.down = false; + if (fillX !== 1 || fillY !== 1) { st.x /= fillX; st.y /= fillY; } + client.sendMouseState(st, true); + } catch (e) {} + } function rgShowEscNoticeOnce() { if (sessionStorage.getItem('rustguac_esc_notice_seen')) return; sessionStorage.setItem('rustguac_esc_notice_seen', '1'); @@ -1526,6 +1549,7 @@

Session Ended

function rgOnFullscreenChange() { var inFs = !!rgFullscreenEl(); var bar = document.getElementById('fs-bar'); + if (inFs) rgReleaseAllInput(); if (bar) { if (inFs) { bar.classList.add('visible'); diff --git a/static/connections.html b/static/connections.html index 8802f42e..ef9561a0 100644 --- a/static/connections.html +++ b/static/connections.html @@ -117,6 +117,9 @@ .entries-table tbody tr:last-child td { border-bottom: none; } .entries-table td:last-child { text-align: right; white-space: nowrap; } + .entries-table .actions-cell { white-space: nowrap; text-align: right; } + .entries-table .actions-cell .btn-small { margin-left: var(--s-1); } + /* Bulk selection (admins). */ .entries-table .sel-cell { width: 2em; text-align: center; padding-right: 0; } .entries-table .sel-cell input { width: auto; margin: 0; cursor: pointer; } @@ -165,6 +168,28 @@ color: var(--text-muted); font-size: var(--fz-md); font-style: italic; } + /* Narrow desktops (a 1710px-wide MacBook display, 1600px monitors): + the 18px monospace table plus three padded action cells overran + the main column by ~300px, with "delete" cut off at the right. + Tighten the chrome rather than hide columns: smaller side + padding and sidebar, 16px cells with less padding, a narrower + Connect button. Everything then fits with ~250px to spare. */ + @media (max-width: 1800px) { + /* page padding at this width lives in rustguac.css, shared by + every page, so switching tabs does not shift the gutter */ + .layout { gap: var(--s-4); } + .sidebar { min-width: 240px; max-width: 280px; padding-right: var(--s-3); } + .entries-table th, .entries-table td { + padding: var(--s-2) var(--s-3); + font-size: var(--fz-sm); + } + .entries-table th { font-size: var(--fz-xs); letter-spacing: 0.08em; } + .btn-connect { min-width: 110px; padding: 0 var(--s-3); } + .btn-connect-win { min-width: 0; padding: 0 var(--s-1); } + .folder-actions strong { font-size: var(--fz-lg); } + .folder-actions .desc { font-size: var(--fz-sm); } + } + /* Active sessions strip */ .active-sessions { margin-bottom: var(--s-5); } .session-grid { display: flex; gap: var(--s-3); overflow-x: auto; padding-bottom: var(--s-2); } @@ -2394,7 +2419,7 @@

Enter Credentials

var html = ''; if (isAdmin) html += ''; html += ''; - if (isAdmin) html += ''; + if (isAdmin) html += ''; html += ''; entries.forEach(function(e, idx) { @@ -2435,9 +2460,14 @@

Enter Credentials

html += ''; if (isAdmin) { - html += ''; - html += ''; - html += ''; + // One cell for the three admin actions: three cells cost + // two extra runs of cell padding on every row, which is + // what pushed "delete" off the right edge at 1710px wide. + html += ''; } html += ''; }); diff --git a/static/recordings.html b/static/recordings.html index f9aae965..64366c3c 100644 --- a/static/recordings.html +++ b/static/recordings.html @@ -102,6 +102,19 @@ #player-time { color: var(--text-muted); font-size: var(--fz-sm); white-space: nowrap; } .btn-delete { color: var(--primary); } .btn-delete:hover { color: var(--primary-hover); } + /* Narrow desktops (a 1710px MacBook display): the 18px table with + 20px cell padding ran past the right edge. Same treatment as the + connections table: 16px cells, tighter padding, 14px headers, and + the actions kept on one line. Nothing is hidden. */ + @media (max-width: 1800px) { + table th, table td { + padding: var(--s-2) var(--s-3); + font-size: var(--fz-sm); + } + table th { font-size: var(--fz-xs); letter-spacing: 0.08em; } + table td:last-child { white-space: nowrap; } + .btn-small { padding: 0 var(--s-1); } + } diff --git a/static/reports.html b/static/reports.html index 6b9715ce..fe7e78c2 100644 --- a/static/reports.html +++ b/static/reports.html @@ -48,6 +48,21 @@ font-size: var(--fz-sm); } .panel th { background: transparent; padding-top: 0; } + /* Narrow desktops (a 1710px MacBook display): the 18px table with + 20px cell padding ran past the right edge. Same treatment as the + connections table: 16px cells, tighter padding, 14px headers, and + the actions kept on one line. Nothing is hidden. */ + @media (max-width: 1800px) { + #history-table th, #history-table td, + #recordings-table th, #recordings-table td, + table.list th, table.list td { + padding: var(--s-2) var(--s-3); + font-size: var(--fz-sm); + } + #history-table th, #recordings-table th, table.list th { font-size: var(--fz-xs); letter-spacing: 0.08em; } + #history-table td:last-child, #recordings-table td:last-child { white-space: nowrap; } + .btn-small { padding: 0 var(--s-1); } + } diff --git a/static/rustguac.css b/static/rustguac.css index 1b384eeb..22b865a4 100644 --- a/static/rustguac.css +++ b/static/rustguac.css @@ -57,6 +57,13 @@ body { margin: 0; } +/* Narrow desktops (a 1710px MacBook display, 1600px monitors): a tighter + page gutter. Shared here so every page shifts together and switching + tabs does not snap the content sideways. */ +@media (max-width: 1800px) { + body { padding: var(--s-4) var(--s-4); } +} + h1 { color: var(--primary); margin: 0; font-size: 30px; letter-spacing: 0.02em; font-weight: bold; } h2 { color: var(--primary); font-size: var(--fz-lg); margin-top: var(--s-5); letter-spacing: 0.02em; } h3 { color: var(--text); font-size: var(--fz-md); margin-top: var(--s-4); letter-spacing: 0.02em; } diff --git a/tests/e2e/.gitignore b/tests/e2e/.gitignore new file mode 100644 index 00000000..dbd64df8 --- /dev/null +++ b/tests/e2e/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +test-results/ +playwright-report/ diff --git a/tests/e2e/README.md b/tests/e2e/README.md new file mode 100644 index 00000000..7b44a3a6 --- /dev/null +++ b/tests/e2e/README.md @@ -0,0 +1,40 @@ +# End-to-end tests + +Playwright specs that drive the real pages in `static/` against a stubbed +API. Nothing here needs Vault, guacd, Docker or a browser session: `serve.mjs` +serves the static tree over HTTP and every spec installs `lib/api.mjs`, which +answers `/api/*` from a small fixture address book (two named tenants and +twenty generic ones, two sessions, two recordings) and records every request +the page makes so a spec can assert on what was sent. + +```sh +cd tests/e2e +npm ci +npx playwright install chromium # once +npm test # all specs +npx playwright test specs/global-search.spec.mjs +npx playwright test --ui # step through a spec +``` + +`PW_CHROMIUM=/path/to/chrome` runs against an already installed Chromium +instead of the one Playwright downloads. CI runs the suite in the `e2e` job. + +One spec per change, named after the branch that introduced it: + +| spec | pins | +|---|---| +| `tables-1710` | #252: no horizontal overflow, admin table fits at 1710px | +| `connections-sidebar` | #254: independent sidebar scroll, selected row marked and kept in view | +| `connections-folder-filter` | #254: live filter box, ARIA tree, arrow keys, `f` shortcut | +| `connections-app-shell` | #254: clipped body, two scroll panes, folder change scrolls the pane not the page | +| `role-nav` | one nav on every page, gated by `data-role-level` | +| `sessions-adhoc-modal` | ad-hoc form opens as a modal | +| `facet-filters` | shared facets narrow lists, persist, combine with search | +| `folder-display-name` | display name over the key, modal field round-trips | +| `themed-dialogs` | `rgConfirm`/`rgAlert` replace native dialogs | +| `global-search` | `/` opens the overlay on every page, folder view untouched | +| `search-under-modals` | a page modal covers the search box | +| `zero-click-fullscreen` | #257: same-tab iframe sized from the measured viewport | + +The fixture suppresses the first-visit onboarding tour (`options.onboarding` +re-enables it) and defaults to the admin role; pass `role` to test gating. diff --git a/tests/e2e/lib/api.mjs b/tests/e2e/lib/api.mjs new file mode 100644 index 00000000..b9c8a8f3 --- /dev/null +++ b/tests/e2e/lib/api.mjs @@ -0,0 +1,216 @@ +/* + * Stubbed rustguac API for the end-to-end tests. + * + * installApi(page, options) routes every /api/* request the page makes to a + * JSON answer built from a small fixture address book, so the real pages run + * against data the test controls and nothing needs Vault, guacd or a browser + * session. Every request is recorded in the returned `calls` array so a spec + * can assert on what the page sent (method, path, JSON body). + * + * options.role 'admin' (default) | 'poweruser' | 'operator' | 'viewer' + * options.folders replace the fixture folders (see fixtureFolders()) + * options.sessions /api/sessions list (default: two sessions) + * options.recordings /api/recordings list + * options.routes { 'METHOD /api/path': body | (req, url) => body|Response-like } + * exact-path overrides, checked before the defaults + */ + +export function entry(name, session_type, extra = {}) { + return { + name, + session_type, + hostname: extra.hostname ?? (session_type === 'web' ? null : `${name}.acme.internal`), + port: extra.port ?? (session_type === 'ssh' ? 22 : session_type === 'rdp' ? 3389 : null), + username: extra.username ?? (session_type === 'web' ? null : 'root'), + url: extra.url ?? (session_type === 'web' ? `https://${name}.example.com/` : null), + display_name: extra.display_name ?? null, + domain: null, + security: null, + ignore_cert: null, + has_credentials: extra.has_credentials ?? true, + inherited_credentials: extra.inherited_credentials ?? false, + fullscreen_on_connect: extra.fullscreen_on_connect ?? false, + ...extra.fields + }; +} + +export function folder(name, opts = {}) { + return { + name, + scope: opts.scope ?? 'shared', + path: opts.path ?? name, + description: opts.description ?? '', + display_name: opts.display_name ?? '', + allowed_groups: opts.allowed_groups ?? [], + has_children: opts.has_children ?? false, + entries: opts.entries ?? [] + }; +} + +/* Two named tenants plus twenty generic ones, so the sidebar is long enough + * for the scrolling, filtering and keyboard tests to mean something. */ +export function fixtureFolders() { + const folders = [ + folder('acme', { + display_name: "Dani & Fiori's Gourmet Pizzeria", + description: 'Acme tenant (NetBox)', + entries: [ + entry('web01', 'ssh'), + entry('dc01', 'rdp', { username: 'administrator' }), + entry('kiosk', 'rdp', { username: 'kiosk', fullscreen_on_connect: true }), + entry('portal', 'web') + ] + }), + folder('beta', { + description: 'Beta Industries', + entries: [entry('app01', 'ssh'), entry('files', 'rdp', { has_credentials: false })] + }) + ]; + for (let i = 1; i <= 20; i++) { + const n = `tenant-${String(i).padStart(2, '0')}`; + folders.push(folder(n, { description: `Tenant ${i}`, entries: [entry(`${n}-ssh`, 'ssh')] })); + } + return folders; +} + +export function fixtureSessions() { + return [ + { session_id: '11111111-1111-4111-8111-111111111111', session_type: 'ssh', status: 'Connected', + created_at: '2026-10-11T01:00:00Z', client_url: '/client.html?session=1111', ws_url: '/ws/1111', + hostname: 'web01.acme.internal', username: 'root', active_connections: 1, created_by: 'alice@example.com' }, + { session_id: '22222222-2222-4222-8222-222222222222', session_type: 'rdp', status: 'Pending', + created_at: '2026-10-11T01:05:00Z', client_url: '/client.html?session=2222', ws_url: '/ws/2222', + hostname: 'dc01.acme.internal', username: 'administrator', active_connections: 0, created_by: 'bob@example.com' } + ]; +} + +export function fixtureRecordings() { + return [ + { name: 'ssh-web01-20261011-010000.guac', modified: '2026-10-11T01:30:00Z', size_bytes: 120_000, + session_type: 'ssh', user: 'alice@example.com', folder: 'shared/acme', address_book_entry: 'web01', + entry_display_name: 'web01 (Acme)' }, + { name: 'rdp-dc01-20261011-020000.guac', modified: '2026-10-11T02:30:00Z', size_bytes: 9_000_000, + session_type: 'rdp', user: 'bob@example.com', folder: 'shared/acme', address_book_entry: 'dc01', + entry_display_name: 'dc01 (Acme)' } + ]; +} + +function json(body, status = 200) { + return { status, contentType: 'application/json', body: JSON.stringify(body) }; +} + +export async function installApi(page, options = {}) { + const role = options.role ?? 'admin'; + const folders = options.folders ?? fixtureFolders(); + const sessions = options.sessions ?? fixtureSessions(); + const recordings = options.recordings ?? fixtureRecordings(); + const state = { version: 1, folders, sessions, recordings, calls: [] }; + + // The first visit to Connections opens the onboarding tour over the page; + // mark it seen so it never sits over the elements a test clicks. + if (!options.onboarding) { + await page.addInitScript(() => { + try { localStorage.setItem('rustguac_onboarding_dismissed', '1'); } catch (e) { /* no storage */ } + }); + } + + const findFolder = (scope, path) => folders.find(f => f.scope === scope && (f.path ?? f.name) === path); + const searchIndex = () => ({ + entries: folders.flatMap(f => f.entries.map(e => ({ + scope: f.scope, folder_path: f.path ?? f.name, folder_display_name: f.display_name || '', entry: e + }))) + }); + + await page.route('**/api/**', async (route) => { + const req = route.request(); + const url = new URL(req.url()); + const path = url.pathname; + const method = req.method(); + let body = null; + try { body = req.postDataJSON(); } catch { body = req.postData(); } + state.calls.push({ method, path, query: Object.fromEntries(url.searchParams), body }); + + const key = `${method} ${path}`; + if (options.routes && key in options.routes) { + const v = options.routes[key]; + const out = typeof v === 'function' ? await v(req, url, state) : v; + if (out && typeof out === 'object' && 'status' in out && 'body' in out) return route.fulfill(out); + return route.fulfill(json(out)); + } + + // ── identity ── + if (path === '/api/me') return route.fulfill(json({ + name: 'tester@example.com', role, groups: ['everyone'], vault_enabled: true, vault_configured: true + })); + if (path === '/api/auth/status') return route.fulfill(json({ + oidc_enabled: false, drive_configured: false, theme: 'dark' + })); + if (path === '/api/me/credentials') return route.fulfill(json({ credentials: {} })); + if (path === '/api/credential-variables' || path === '/api/login-scripts' || path === '/api/auth/known-groups') + return route.fulfill(json([])); + + // ── address book ── + if (path === '/api/addressbook') return route.fulfill(json({ folders, unavailable_scopes: [] })); + if (path === '/api/addressbook/version') return route.fulfill(json({ version: state.version, changed_at: 0 })); + if (path === '/api/addressbook/search-index') return route.fulfill(json(searchIndex())); + if (path === '/api/addressbook/folder-paths') return route.fulfill(json({ + paths: folders.map(f => ({ scope: f.scope, path: f.path ?? f.name })) + })); + if (path === '/api/addressbook/folders' && method === 'GET') return route.fulfill(json({ folders })); + let m = path.match(/^\/api\/addressbook\/folders\/([^/]+)\/(.+?)\/(entries|config|defaults|subfolders)$/); + if (m) { + const f = findFolder(decodeURIComponent(m[1]), decodeURIComponent(m[2])); + if (!f) return route.fulfill(json({ error: 'no such folder' }, 404)); + if (m[3] === 'entries') return route.fulfill(json(f.entries)); + if (m[3] === 'subfolders') return route.fulfill(json([])); + if (m[3] === 'config') return route.fulfill(json({ + allowed_groups: f.allowed_groups, allowed_users: [], description: f.description, display_name: f.display_name + })); + if (m[3] === 'defaults') return route.fulfill(json({ defaults: {} })); + } + m = path.match(/^\/api\/addressbook\/folders\/([^/]+)\/(.+?)\/entries\/([^/]+)\/connect$/); + if (m && method === 'POST') { + const id = '33333333-3333-4333-8333-333333333333'; + return route.fulfill(json({ + session_id: id, client_url: `/client.html?session=${id}`, ws_url: `/ws/${id}`, session_type: 'rdp' + })); + } + if (path.startsWith('/api/addressbook/') && method !== 'GET') { + state.version += 1; + return route.fulfill(json({ ok: true })); + } + + // ── sessions / recordings / reports ── + if (path === '/api/sessions' && method === 'GET') return route.fulfill(json(sessions)); + if (path.startsWith('/api/sessions/') && method === 'DELETE') return route.fulfill(json({ ok: true })); + if (path === '/api/vdi/containers') return route.fulfill(json([])); + if (path === '/api/recordings' && method === 'GET') return route.fulfill(json(recordings)); + if (path.startsWith('/api/recordings/') && method === 'DELETE') return route.fulfill(json({ ok: true })); + if (path === '/api/typescripts') return route.fulfill(json({ items: [] })); + if (path === '/api/reports/summary') return route.fulfill(json({ + total_sessions: 42, total_hours: 17.5, unique_users: 3, active_now: 1 + })); + if (path === '/api/reports/sessions') return route.fulfill(json({ + total: 2, limit: 100, offset: 0, + sessions: [ + { session_id: '1111', session_type: 'ssh', hostname: 'web01.acme.internal', username: 'root', + created_by: 'alice@example.com', status: 'Ended', started_at: '2026-10-11T01:00:00Z', + ended_at: '2026-10-11T01:30:00Z', duration_secs: 1800, entry_name: 'web01 (Acme)' }, + { session_id: '2222', session_type: 'rdp', hostname: 'dc01.acme.internal', username: 'administrator', + created_by: 'bob@example.com', status: 'Ended', started_at: '2026-10-11T02:00:00Z', + ended_at: '2026-10-11T02:30:00Z', duration_secs: 1800, entry_name: 'dc01 (Acme)' } + ] + })); + if (path === '/api/reports/top-connections' || path === '/api/reports/top-users') return route.fulfill(json([])); + + return route.fulfill(json({})); + }); + return state; +} + +/* Wait until the page has finished its initial loads: no pending fetches for + * a short while and no spinner-type skeletons left. */ +export async function settle(page, ms = 250) { + await page.waitForLoadState('networkidle'); + await page.waitForTimeout(ms); +} diff --git a/tests/e2e/package-lock.json b/tests/e2e/package-lock.json new file mode 100644 index 00000000..a94a60d6 --- /dev/null +++ b/tests/e2e/package-lock.json @@ -0,0 +1,76 @@ +{ + "name": "rustguac-e2e", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "rustguac-e2e", + "devDependencies": { + "@playwright/test": "1.58.2" + } + }, + "node_modules/@playwright/test": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz", + "integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.58.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/playwright": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz", + "integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.58.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz", + "integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + } + } +} diff --git a/tests/e2e/package.json b/tests/e2e/package.json new file mode 100644 index 00000000..88ea545f --- /dev/null +++ b/tests/e2e/package.json @@ -0,0 +1,13 @@ +{ + "name": "rustguac-e2e", + "private": true, + "description": "Playwright end-to-end tests for the rustguac pages, run against the static tree with a stubbed API.", + "type": "module", + "scripts": { + "test": "playwright test", + "serve": "node serve.mjs" + }, + "devDependencies": { + "@playwright/test": "1.58.2" + } +} diff --git a/tests/e2e/playwright.config.mjs b/tests/e2e/playwright.config.mjs new file mode 100644 index 00000000..bb0ad7d1 --- /dev/null +++ b/tests/e2e/playwright.config.mjs @@ -0,0 +1,31 @@ +import { defineConfig, devices } from '@playwright/test'; + +const PORT = process.env.E2E_PORT || '8098'; +const baseURL = `http://127.0.0.1:${PORT}`; + +export default defineConfig({ + testDir: './specs', + timeout: 30_000, + expect: { timeout: 5_000 }, + fullyParallel: true, + workers: process.env.CI ? 2 : undefined, + retries: process.env.CI ? 1 : 0, + reporter: process.env.CI ? [['list'], ['github']] : 'list', + use: { + baseURL, + colorScheme: 'dark', + // The 1710px MacBook display the layout issues were reported on. + viewport: { width: 1710, height: 1112 }, + trace: 'retain-on-failure', + ...(process.env.PW_CHROMIUM ? { launchOptions: { executablePath: process.env.PW_CHROMIUM } } : {}) + }, + webServer: { + command: `node serve.mjs ${PORT}`, + url: `${baseURL}/index.html`, + reuseExistingServer: !process.env.CI, + timeout: 20_000 + }, + projects: [ + { name: 'chromium', use: { ...devices['Desktop Chrome'], viewport: { width: 1710, height: 1112 } } } + ] +}); diff --git a/tests/e2e/serve.mjs b/tests/e2e/serve.mjs new file mode 100644 index 00000000..2191d3a3 --- /dev/null +++ b/tests/e2e/serve.mjs @@ -0,0 +1,52 @@ +#!/usr/bin/env node +/* + * Serves the real static tree (../../static) over HTTP for the end-to-end + * tests. No API: each spec stubs /api/* in the browser with page.route(), + * so the pages run exactly as shipped against data the test controls. + * + * Usage: node tests/e2e/serve.mjs [port] (default 8098) + */ + +import { createServer } from 'node:http'; +import { createReadStream } from 'node:fs'; +import { stat } from 'node:fs/promises'; +import { dirname, resolve, join, extname, normalize } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), '../../static'); +const port = parseInt(process.argv[2] || '8098', 10); + +const TYPES = { + '.html': 'text/html; charset=utf-8', + '.js' : 'text/javascript; charset=utf-8', + '.mjs' : 'text/javascript; charset=utf-8', + '.css' : 'text/css; charset=utf-8', + '.svg' : 'image/svg+xml', + '.png' : 'image/png', + '.ico' : 'image/x-icon', + '.json': 'application/json', + '.woff2': 'font/woff2', + '.woff': 'font/woff' +}; + +createServer(async (req, res) => { + const url = new URL(req.url, 'http://localhost'); + let path = url.pathname === '/' ? '/index.html' : url.pathname; + const file = join(root, normalize(path).replace(/^(\.\.[/\\])+/, '')); + if (!file.startsWith(root)) { res.writeHead(403); res.end(); return; } + try { + const s = await stat(file); + if (!s.isFile()) throw new Error('not a file'); + } catch { + res.writeHead(404, { 'Content-Type': 'text/plain' }); + res.end('not found: ' + path); + return; + } + res.writeHead(200, { + 'Content-Type': TYPES[extname(file)] || 'application/octet-stream', + 'Cache-Control': 'no-store' + }); + createReadStream(file).pipe(res); +}).listen(port, '127.0.0.1', () => { + console.log(`e2e static server: http://127.0.0.1:${port}/ (root ${root})`); +}); diff --git a/tests/e2e/specs/tables-1710.spec.mjs b/tests/e2e/specs/tables-1710.spec.mjs new file mode 100644 index 00000000..689239f0 --- /dev/null +++ b/tests/e2e/specs/tables-1710.spec.mjs @@ -0,0 +1,56 @@ +// #252: the admin table overran the page at 1710px wide and the page scrolled +// sideways. Each list page must fit its table inside the viewport at 1710px +// with nothing cut off, and the page must not scroll horizontally. +import { test, expect } from '@playwright/test'; +import { installApi, settle } from '../lib/api.mjs'; + +test.use({ viewport: { width: 1710, height: 1112 } }); + +async function noHorizontalOverflow(page) { + const m = await page.evaluate(() => ({ + scrollWidth: document.documentElement.scrollWidth, + clientWidth: document.documentElement.clientWidth, + bodyScroll: document.body.scrollWidth + })); + expect(m.scrollWidth, 'document must not be wider than the viewport').toBeLessThanOrEqual(m.clientWidth); + expect(m.bodyScroll).toBeLessThanOrEqual(m.clientWidth); +} + +async function tableFits(page, selector) { + const table = page.locator(selector).first(); + await expect(table).toBeVisible(); + const box = await table.boundingBox(); + const vw = page.viewportSize().width; + expect(box.x + box.width, `${selector} right edge must be inside the viewport`).toBeLessThanOrEqual(vw + 0.5); +} + +test('connections: the entries table with admin actions fits at 1710px', async ({ page }) => { + await installApi(page, { role: 'admin' }); + await page.goto('/connections.html'); + await settle(page); + // Open the first tenant folder so the admin table (edit/clone/delete) renders. + await page.locator('.folder-row').filter({ hasText: /beta/ }).first().click(); + await expect(page.locator('#entries-title')).toHaveText(/beta/); + await expect(page.locator('.entries-table')).toBeVisible(); + await expect(page.locator('.entries-table tbody tr').first()).toBeVisible(); + await tableFits(page, '.entries-table'); + await noHorizontalOverflow(page); +}); + +test('recordings: the table fits at 1710px', async ({ page }) => { + await installApi(page); + await page.goto('/recordings.html'); + await settle(page); + await expect(page.locator('#recording-list tbody tr').first()).toBeVisible(); + await tableFits(page, '#recording-list'); + await noHorizontalOverflow(page); +}); + +test('reports: the session history table fits at 1710px', async ({ page }) => { + await installApi(page); + await page.goto('/reports.html'); + await settle(page); + await expect(page.locator('#history-body tr').first()).toBeVisible(); + await tableFits(page, '#history-table'); + await noHorizontalOverflow(page); +});
NameTypeHostUser
' + '' + + '' + + '' + + '' + + '