mirror of
https://github.com/m1k1o/neko.git
synced 2026-11-03 00:11:48 -06:00
3191dbb058
- Deleted the legacy WebSocket client implementation (`wstoclient.go`) and its test file (`wstoclient_test.go`). - Removed legacy handling from the HTTP manager, including legacy proxy setup and routing. - Cleaned up session management by removing legacy host checks and related functions. - Eliminated the legacy WebRTC handler and its associated logic. - Updated session interface to remove legacy methods. - Removed legacy API documentation and configuration options from migration guide and help files.
571 lines
27 KiB
JavaScript
571 lines
27 KiB
JavaScript
import { chromium } from 'playwright'
|
|
// Two-user smoke test against a running v3 server with the multiuser provider:
|
|
// NEKO_URL=https://host/ NEKO_ADMIN_PASSWORD=admin NEKO_USER_PASSWORD=neko npm run test:e2e
|
|
// Screenshots land in ./e2e-out. Needs a KDE/X desktop that opens KRunner on Alt+F2 for the typing step.
|
|
import fs from 'node:fs'
|
|
const out = 'e2e-out'
|
|
fs.mkdirSync(out, { recursive: true })
|
|
const URL = process.env.NEKO_URL || 'http://localhost:8080/'
|
|
const ADMIN = process.env.NEKO_ADMIN_PASSWORD || 'admin'
|
|
const USER = process.env.NEKO_USER_PASSWORD || 'neko'
|
|
// NEKO_CDP=http://127.0.0.1:9222 runs the same steps in a real Chromium-based browser (Brave, Chrome, Edge)
|
|
// started with --remote-debugging-port and the three flags below
|
|
const browser = process.env.NEKO_CDP
|
|
? await chromium.connectOverCDP(process.env.NEKO_CDP)
|
|
: await chromium.launch({
|
|
args: [
|
|
'--autoplay-policy=no-user-gesture-required',
|
|
'--use-fake-ui-for-media-stream',
|
|
'--use-fake-device-for-media-stream',
|
|
],
|
|
})
|
|
const errors = []
|
|
let A, B
|
|
let fails = 0
|
|
const log = (s) => console.log(new Date().toISOString().slice(11, 19), s)
|
|
async function step(name, fn) {
|
|
try {
|
|
await fn()
|
|
log('PASS ' + name)
|
|
} catch (e) {
|
|
fails++
|
|
log('FAIL ' + name + ' :: ' + e.message.split('\n')[0])
|
|
// evidence for flaky failures: both users' screens and member lists
|
|
const slug = name.replace(/\W+/g, '-')
|
|
for (const [who, p] of [
|
|
['alice', A],
|
|
['bob', B],
|
|
]) {
|
|
if (!p) continue
|
|
await p.screenshot({ path: `${out}/fail-${slug}-${who}.png` }).catch(() => {})
|
|
const members = await p
|
|
.$$eval('[data-testid=member]', (els) => els.map((e) => e.getAttribute('aria-label') || 'self'))
|
|
.catch(() => '?')
|
|
log(` ${who} members: ${JSON.stringify(members)}`)
|
|
}
|
|
}
|
|
}
|
|
async function user(name, password, init) {
|
|
const ctx = await browser.newContext({
|
|
ignoreHTTPSErrors: true,
|
|
viewport: { width: 1400, height: 850 },
|
|
permissions: ['clipboard-read', 'clipboard-write', 'microphone'],
|
|
})
|
|
if (init) await ctx.addInitScript(init) // runs before the app: a browser quirk to emulate
|
|
const page = await ctx.newPage()
|
|
page.on('pageerror', (e) => errors.push(`${name} pageerror: ${e.message}`))
|
|
page.on(
|
|
'console',
|
|
(m) =>
|
|
m.type() === 'error' &&
|
|
// the outage steps make the socket fail on purpose; Brave reports that without the ERR_ text
|
|
!/401|STUN|ERR_INTERNET_DISCONNECTED|WebSocket connection to .* failed/.test(m.text()) &&
|
|
errors.push(`${name} console: ${m.text()}`),
|
|
)
|
|
await page.goto(URL)
|
|
await page.fill('input[placeholder="Enter your display name"]', name)
|
|
await page.fill('input[type=password]', password)
|
|
await page.click('button[type=submit]')
|
|
await page.waitForSelector('[data-testid=connect]', { state: 'detached', timeout: 20000 })
|
|
await page.waitForFunction(
|
|
() => {
|
|
const v = document.querySelector('video')
|
|
return v && !v.paused && v.readyState >= 2
|
|
},
|
|
null,
|
|
{ timeout: 30000 },
|
|
)
|
|
return page
|
|
}
|
|
const api = (p, method, path, body) =>
|
|
p.evaluate(
|
|
async ([method, path, body]) => {
|
|
const headers = {
|
|
'Content-Type': 'application/json',
|
|
Authorization: 'Bearer ' + localStorage.getItem('neko_session'),
|
|
}
|
|
const r = await fetch('api' + path, { method, headers, body: body && JSON.stringify(body) })
|
|
if (!r.ok) throw new Error(r.status + ' ' + (await r.text()))
|
|
return r.text()
|
|
},
|
|
[method, path, body],
|
|
)
|
|
const openSide = async (p) => {
|
|
if (!(await p.locator('[data-testid=side]').count())) await p.click('[data-testid=side-toggle]')
|
|
await p.waitForSelector('[data-testid=side]', { timeout: 3000 })
|
|
}
|
|
const playing = (p) =>
|
|
p.evaluate(() => {
|
|
const v = document.querySelector('video')
|
|
return `${v.videoWidth}x${v.videoHeight} paused=${v.paused} muted=${v.muted}`
|
|
})
|
|
|
|
await step('alice (admin) logs in, video plays', async () => {
|
|
A = await user('alice', ADMIN)
|
|
log(' alice video ' + (await playing(A)))
|
|
})
|
|
await step('bob (user) logs in, video plays', async () => {
|
|
B = await user('bob', USER)
|
|
log(' bob video ' + (await playing(B)))
|
|
})
|
|
await step('bob sees pre-existing alice in members', async () => {
|
|
await B.waitForFunction(() => document.querySelectorAll('[data-testid=member]').length === 2, null, {
|
|
timeout: 5000,
|
|
})
|
|
})
|
|
await step('alice sees 2 members, admin shield', async () => {
|
|
await A.waitForFunction(() => document.querySelectorAll('[data-testid=member]').length === 2, null, {
|
|
timeout: 5000,
|
|
})
|
|
await A.waitForSelector('[data-testid=admin-badge]', { timeout: 5000 })
|
|
})
|
|
await step('side panel: bob joined event line', async () => {
|
|
await openSide(A)
|
|
await A.waitForSelector('[data-testid=chat]', { timeout: 3000 })
|
|
await A.waitForFunction(
|
|
() => [...document.querySelectorAll('[data-testid=chat-event]')].some((e) => /bob\s+connected/.test(e.textContent)),
|
|
null,
|
|
{ timeout: 5000 },
|
|
)
|
|
})
|
|
await step('chat: bob -> alice', async () => {
|
|
await openSide(B)
|
|
await B.fill('[data-testid=chat-input]', 'hi from bob')
|
|
await B.press('[data-testid=chat-input]', 'Enter')
|
|
await A.waitForFunction(
|
|
() => [...document.querySelectorAll('[data-testid=chat-body]')].some((e) => e.textContent === 'hi from bob'),
|
|
null,
|
|
{ timeout: 5000 },
|
|
)
|
|
})
|
|
await step('viewer typing in chat keeps the focus when the mouse crosses the video', async () => {
|
|
await B.click('[data-testid=chat-input]')
|
|
await B.keyboard.type('hello ')
|
|
const box = await B.locator('[data-testid=player]').boundingBox()
|
|
await B.mouse.move(box.x + box.width / 2, box.y + box.height / 2)
|
|
await B.mouse.move(box.x + box.width / 2 + 5, box.y + box.height / 2 + 5)
|
|
await B.keyboard.type('world')
|
|
const v = await B.inputValue('[data-testid=chat-input]')
|
|
if (v !== 'hello world') throw new Error('chat box has ' + JSON.stringify(v))
|
|
await B.fill('[data-testid=chat-input]', '')
|
|
})
|
|
await step('bob takes control, alice sees host + event', async () => {
|
|
await B.click('[data-testid=control-request]')
|
|
await A.waitForFunction(() => document.querySelector('[data-testid=member][data-host]:not([data-self])'), null, {
|
|
timeout: 5000,
|
|
})
|
|
await A.waitForFunction(
|
|
() =>
|
|
[...document.querySelectorAll('[data-testid=chat-event]')].some((e) =>
|
|
/bob\s+took the controls/.test(e.textContent),
|
|
),
|
|
null,
|
|
{ timeout: 5000 },
|
|
)
|
|
})
|
|
await step('alice: right-click bob -> Force Release', async () => {
|
|
await A.click('[data-testid=member]:not([data-self])', { button: 'right' })
|
|
await A.click('[data-testid=member-menu] >> text=Force Release Controls')
|
|
await B.waitForFunction(() => !document.querySelector('[data-testid=member][data-self][data-host]'), null, {
|
|
timeout: 5000,
|
|
})
|
|
})
|
|
await step('alice sends emote, bob sees animation', async () => {
|
|
await A.click('[data-testid=emotes-open]')
|
|
await A.click('[data-testid=emotes-menu] [data-emote=wave]')
|
|
await B.waitForSelector('[data-testid=emote-animation]', { timeout: 3000 })
|
|
})
|
|
await step('files: alice uploads, the list shows the file', async () => {
|
|
if (!(await A.locator('[data-testid=tabs] >> text=Files').count()))
|
|
throw new Error('no Files tab: start the server with NEKO_FILETRANSFER_ENABLED=true')
|
|
await A.click('[data-testid=tabs] >> text=Files')
|
|
await A.setInputFiles('[data-testid=upload-input]', {
|
|
name: 'react-e2e.txt',
|
|
mimeType: 'text/plain',
|
|
buffer: Buffer.from('hello'),
|
|
})
|
|
await A.waitForSelector('[data-testid=transfer][data-status=completed]', { timeout: 10000 })
|
|
await A.waitForSelector('[data-testid=file-name] >> text=react-e2e.txt', { timeout: 5000 })
|
|
})
|
|
await step('files: download link works', async () => {
|
|
const href = await A.getAttribute('[data-testid=file]:has-text("react-e2e.txt") a[download]', 'href')
|
|
const r = await A.request.get(href)
|
|
if ((await r.text()) !== 'hello') throw new Error('download body mismatch: ' + r.status())
|
|
await A.click('[data-testid=file]:has-text("react-e2e.txt") [data-testid=file-delete]')
|
|
await A.click('[data-testid=dialog-confirm]')
|
|
await A.waitForSelector('[data-testid=file-name] >> text=react-e2e.txt', { state: 'detached', timeout: 5000 })
|
|
})
|
|
await step('markdown: bob -> alice renders safely', async () => {
|
|
await B.click('[data-testid=tabs] >> text=Chat')
|
|
await B.fill('[data-testid=chat-input]', '**bold** ||secret|| :smile: https://example.com [x](javascript:alert(1))')
|
|
await B.press('[data-testid=chat-input]', 'Enter')
|
|
await A.click('[data-testid=tabs] >> text=Chat')
|
|
const last = A.locator('[data-testid=chat-body]').last()
|
|
await last.locator('strong >> text=bold').waitFor({ timeout: 5000 })
|
|
await last.locator('[data-testid=spoiler]:not([data-shown])').click()
|
|
await last.locator('[data-testid=spoiler][data-shown]').waitFor({ timeout: 2000 })
|
|
await last.locator('[data-emoji="smile"]').waitFor({ timeout: 5000 })
|
|
if ((await last.locator('a').count()) !== 1) throw new Error('expected exactly one (safe) link')
|
|
if ((await last.locator('a').getAttribute('href')) !== 'https://example.com/') throw new Error('bad href')
|
|
})
|
|
await step('narrow screen: the member menu opens from a chat author', async () => {
|
|
const ctx = await browser.newContext({ ignoreHTTPSErrors: true, viewport: { width: 700, height: 900 } })
|
|
const N = await ctx.newPage()
|
|
await N.goto(URL + '?usr=nina&pwd=' + USER + '&show_side=1')
|
|
await N.waitForSelector('[data-testid=connect]', { state: 'detached', timeout: 20000 })
|
|
await B.fill('[data-testid=chat-input]', 'hi nina')
|
|
await B.press('[data-testid=chat-input]', 'Enter')
|
|
await N.waitForSelector('[data-testid=chat-author]', { timeout: 5000 })
|
|
await N.click('[data-testid=chat-author]', { button: 'right' })
|
|
await N.waitForSelector('[data-testid=member-menu]', { state: 'visible', timeout: 3000 })
|
|
await ctx.close()
|
|
})
|
|
await step('muting the sound keeps the desktop usable', async () => {
|
|
await A.click('[data-testid=mute]')
|
|
await A.waitForFunction(() => document.querySelector('video').muted, null, { timeout: 3000 })
|
|
if (await A.locator('[data-testid=player-overlay]').count()) throw new Error('unmute overlay covers the video')
|
|
await A.click('[data-testid=mute]')
|
|
await A.waitForFunction(() => !document.querySelector('video').muted, null, { timeout: 3000 })
|
|
})
|
|
await step('emoji picker inserts :name:', async () => {
|
|
await A.click('[data-testid=emoji-open]')
|
|
await A.fill('[data-testid=emoji-search]', 'thumbsup')
|
|
await A.click('[data-testid=emoji-list] [data-emoji] >> nth=0')
|
|
const v = await A.inputValue('[data-testid=chat-input]')
|
|
if (!/^:[^:]+:$/.test(v)) throw new Error('textarea is ' + JSON.stringify(v))
|
|
// the keyboard stays in the message box after a pick (the picker's close must not focus its button)
|
|
await A.keyboard.type('x')
|
|
const v2 = await A.inputValue('[data-testid=chat-input]')
|
|
if (!/^:[^:]+:x$/.test(v2)) throw new Error('after the pick the keyboard went elsewhere: ' + JSON.stringify(v2))
|
|
await A.fill('[data-testid=chat-input]', '')
|
|
})
|
|
await step('language switch (de) and back', async () => {
|
|
await A.selectOption('[data-testid=lang]', 'de')
|
|
await A.waitForSelector('[data-testid=tabs] >> text=Einstellungen', { timeout: 3000 })
|
|
await A.selectOption('[data-testid=lang]', 'en')
|
|
await A.waitForSelector('[data-testid=tabs] >> text=Settings', { timeout: 3000 })
|
|
})
|
|
await step('admin locks controls, bob sees lock + event', async () => {
|
|
await A.click('[data-testid=lock-control]')
|
|
await B.waitForSelector('[data-testid=lock-control][data-locked]', { timeout: 5000 })
|
|
await B.waitForFunction(
|
|
() =>
|
|
[...document.querySelectorAll('[data-testid=chat-event]')].some((e) =>
|
|
/alice\s+locked controls/.test(e.textContent),
|
|
),
|
|
null,
|
|
{ timeout: 5000 },
|
|
)
|
|
await A.click('[data-testid=lock-control]')
|
|
await B.waitForSelector('[data-testid=lock-control]:not([data-locked])', { timeout: 5000 })
|
|
})
|
|
await step('admin mutes bob: input gone, event line; then unmute', async () => {
|
|
await A.click('[data-testid=member]:not([data-self])', { button: 'right' })
|
|
await A.click('[data-testid=member-menu] >> text=Mute')
|
|
await A.click('[data-testid=dialog-confirm]')
|
|
await B.waitForSelector('[data-testid=chat-input]', { state: 'detached', timeout: 5000 })
|
|
await A.waitForFunction(
|
|
() => [...document.querySelectorAll('[data-testid=chat-event]')].some((e) => /You\s+muted bob/.test(e.textContent)),
|
|
null,
|
|
{ timeout: 5000 },
|
|
)
|
|
await A.click('[data-testid=member]:not([data-self])', { button: 'right' })
|
|
await A.click('[data-testid=member-menu] >> text=Unmute')
|
|
await A.click('[data-testid=dialog-confirm]')
|
|
await B.waitForSelector('[data-testid=chat-input]', { timeout: 5000 })
|
|
})
|
|
await step("a display name with # and ? cannot redirect the admin's mute", async () => {
|
|
// multiuser session ids start with the login name; unencoded, this id would turn
|
|
// POST /api/members/<id> into a request for a different path
|
|
const E = await user('evil#?x', USER)
|
|
await openSide(E)
|
|
await E.waitForSelector('[data-testid=chat-input]', { timeout: 5000 })
|
|
await A.waitForFunction(() => document.querySelectorAll('[data-testid=member]').length === 3, null, {
|
|
timeout: 5000,
|
|
})
|
|
await A.click('[data-testid=member][aria-label="evil#?x"]', { button: 'right' })
|
|
await A.click('[data-testid=member-menu] >> text=Mute')
|
|
await A.click('[data-testid=dialog-confirm]')
|
|
await E.waitForSelector('[data-testid=chat-input]', { state: 'detached', timeout: 5000 })
|
|
if (await A.locator('[data-testid=connect]').count()) throw new Error('admin lost the session')
|
|
await E.context().close()
|
|
await A.waitForFunction(() => document.querySelectorAll('[data-testid=member]').length === 2, null, {
|
|
timeout: 5000,
|
|
})
|
|
})
|
|
await step('keyboard: Enter on focused icon toggles side panel', async () => {
|
|
await B.focus('[data-testid=side-toggle]')
|
|
await B.keyboard.press('Enter')
|
|
await B.waitForSelector('[data-testid=side]', { state: 'detached', timeout: 3000 })
|
|
await B.keyboard.press('Enter')
|
|
await B.waitForSelector('[data-testid=side]', { timeout: 3000 })
|
|
})
|
|
await step('a member who may not watch gets the room without video, not a spinner', async () => {
|
|
const bobId = JSON.parse(await api(A, 'GET', '/sessions')).find(
|
|
(s) => s.profile.name === 'bob' && s.state.is_connected,
|
|
).id
|
|
await api(A, 'POST', '/members/' + encodeURIComponent(bobId), { can_watch: false })
|
|
try {
|
|
await B.reload()
|
|
await B.waitForSelector('[data-testid=connect]', { state: 'detached', timeout: 20000 }) // the offer timeout (8 s) ends the spinner
|
|
await openSide(B)
|
|
await B.waitForSelector('[data-testid=chat-input]', { timeout: 5000 })
|
|
} finally {
|
|
await api(A, 'POST', '/members/' + encodeURIComponent(bobId), { can_watch: true })
|
|
}
|
|
await B.reload()
|
|
await B.waitForFunction(
|
|
() => {
|
|
const v = document.querySelector('video')
|
|
return v && !v.paused && v.readyState >= 2
|
|
},
|
|
null,
|
|
{ timeout: 30000 },
|
|
)
|
|
await openSide(B)
|
|
})
|
|
await step('admin changes resolution, bob sees event; revert', async () => {
|
|
await A.click('[data-testid=resolution-open]')
|
|
const current = (await A.locator('[data-testid=resolution-item][data-active]').textContent()) ?? ''
|
|
const width = await A.evaluate(() => document.querySelector('video').videoWidth)
|
|
await A.locator('[data-testid=resolution-item]').filter({ hasNotText: current }).first().click()
|
|
await B.waitForFunction(
|
|
() =>
|
|
[...document.querySelectorAll('[data-testid=chat-event]')].some((e) =>
|
|
/changed the resolution/.test(e.textContent),
|
|
),
|
|
null,
|
|
{ timeout: 8000 },
|
|
)
|
|
await A.click('[data-testid=resolution-open]')
|
|
await A.locator('[data-testid=resolution-item]', { hasText: current }).first().click()
|
|
await A.mouse.click(5, 5)
|
|
await A.waitForFunction((w) => document.querySelector('video').videoWidth === w, width, { timeout: 15000 })
|
|
})
|
|
await step('settings: keyboard layouts loaded, about opens', async () => {
|
|
await A.click('[data-testid=tabs] >> text=Settings')
|
|
const n = await A.locator('[data-testid=settings] select option').count()
|
|
if (n < 20) throw new Error('only ' + n + ' keyboard layouts')
|
|
await A.click('[data-testid=about-open]')
|
|
await A.waitForSelector('[data-testid=about] >> text=m1k1o/neko', { timeout: 3000 })
|
|
await A.click('[data-testid=about-close]')
|
|
await A.waitForSelector('[data-testid=about]', { state: 'detached', timeout: 3000 })
|
|
await A.click('[data-testid=tabs] >> text=Chat')
|
|
})
|
|
await step('implicit hosting: bob gets control by clicking', async () => {
|
|
await api(A, 'POST', '/room/settings', { implicit_hosting: true })
|
|
await B.waitForSelector('[data-testid=control-implicit]', { timeout: 5000 })
|
|
const box = await B.locator('[data-testid=player]').boundingBox()
|
|
await B.mouse.click(box.x + box.width / 2, box.y + box.height / 2)
|
|
await B.waitForSelector('[data-testid=member][data-self][data-host]', { timeout: 5000 })
|
|
await api(A, 'POST', '/room/settings', { implicit_hosting: false })
|
|
await A.waitForSelector('[data-testid=control-request]', { timeout: 5000 })
|
|
await api(A, 'POST', '/room/control/reset')
|
|
await B.waitForSelector('[data-testid=member][data-self][data-host]', { state: 'detached', timeout: 5000 })
|
|
})
|
|
await step('clipboard: local clipboard reaches the remote when hovering', async () => {
|
|
await A.click('[data-testid=control-request]')
|
|
await A.waitForSelector('[data-testid=member][data-self][data-host]', { timeout: 5000 })
|
|
// give the X clipboard an owner first: reading it while nothing owns it is a server 500 (xclip
|
|
// exits) that the browser logs as an error, which is not what this step is about
|
|
await api(A, 'POST', '/room/clipboard', { text: 'seed' })
|
|
const text = 'neko-clip-' + Date.now()
|
|
await A.evaluate((t) => navigator.clipboard.writeText(t), text)
|
|
await A.mouse.move(5, 5)
|
|
const box = await A.locator('[data-testid=player]').boundingBox()
|
|
await A.mouse.move(box.x + 50, box.y + 50)
|
|
await A.waitForFunction(
|
|
async (t) => {
|
|
const headers = { Authorization: 'Bearer ' + localStorage.getItem('neko_session') }
|
|
const r = await fetch('api/room/clipboard', { headers })
|
|
return r.ok && (await r.json()).text === t
|
|
},
|
|
text,
|
|
{ timeout: 8000, polling: 500 },
|
|
)
|
|
})
|
|
await step('microphone: host enables and disables (fake device)', async () => {
|
|
await A.click('[data-testid=mic]:not([data-on])')
|
|
await A.waitForSelector('[data-testid=mic][data-on]', { timeout: 5000 })
|
|
if (await A.locator('[data-testid=dialog]').count())
|
|
throw new Error(await A.locator('[data-testid=dialog]').textContent())
|
|
await A.click('[data-testid=mic][data-on]')
|
|
await A.waitForSelector('[data-testid=mic]:not([data-on])', { timeout: 5000 })
|
|
await api(A, 'POST', '/room/control/release')
|
|
})
|
|
// the textarea fallback shows when the browser cannot read the local clipboard (Firefox, Safari):
|
|
// emulated by removing readText before the app loads
|
|
await step('clipboard fallback: the popover keeps the focus while the mouse crosses the video', async () => {
|
|
const C = await user('carol', ADMIN, () =>
|
|
Object.defineProperty(Clipboard.prototype, 'readText', { value: undefined }),
|
|
)
|
|
try {
|
|
await C.click('[data-testid=control-request]')
|
|
await C.waitForSelector('[data-testid=member][data-self][data-host]', { timeout: 5000 })
|
|
await C.click('[data-testid=clipboard-open]')
|
|
const inPopover = () => document.activeElement?.closest('[data-testid=clipboard] textarea') != null
|
|
await C.waitForFunction(inPopover, null, { timeout: 3000 })
|
|
const box = await C.locator('[data-testid=player]').boundingBox()
|
|
await C.mouse.move(box.x + box.width / 2, box.y + box.height / 2, { steps: 12 })
|
|
await C.mouse.move(box.x + 40, box.y + box.height - 40, { steps: 12 })
|
|
await C.waitForTimeout(500)
|
|
if (!(await C.locator('[data-testid=clipboard]').count())) throw new Error('the clipboard popover closed')
|
|
if (!(await C.evaluate(inPopover))) throw new Error('the clipboard textarea lost the focus')
|
|
// what is typed lands in the textarea, not on the remote, and can be selected to copy
|
|
await C.keyboard.type('neko-fallback')
|
|
await C.keyboard.press('Control+A')
|
|
const sel = await C.evaluate(() => {
|
|
const t = document.activeElement
|
|
return t.value === 'neko-fallback' && t.selectionEnd - t.selectionStart === t.value.length
|
|
})
|
|
if (!sel) throw new Error('typing or select-all did not reach the clipboard textarea')
|
|
await C.keyboard.press('Escape')
|
|
await C.waitForSelector('[data-testid=clipboard]', { state: 'detached', timeout: 3000 })
|
|
} finally {
|
|
await api(C, 'POST', '/room/control/release').catch(() => {})
|
|
await C.context().close()
|
|
}
|
|
})
|
|
// offline emulation only freezes an open websocket in Chromium (WebKit and Firefox keep delivering
|
|
// heartbeats to it, measured); elsewhere these two steps have nothing to notice and are skipped
|
|
const offlineWorks = browser.browserType().name() === 'chromium'
|
|
await step('network drop: bob notices the dead socket and reconnects', async () => {
|
|
if (!offlineWorks) return log(' skipped: offline emulation does not reach the websocket here')
|
|
// offline emulation freezes the websocket but not WebRTC: only the stale check (~25s) notices
|
|
await B.context().setOffline(true)
|
|
try {
|
|
await B.waitForSelector('[data-testid=connect]', { timeout: 45000 })
|
|
} finally {
|
|
await B.context().setOffline(false)
|
|
}
|
|
await B.waitForSelector('[data-testid=connect]', { state: 'detached', timeout: 30000 })
|
|
await B.waitForFunction(
|
|
() => {
|
|
const v = document.querySelector('video')
|
|
return v && !v.paused && v.readyState >= 2
|
|
},
|
|
null,
|
|
{ timeout: 20000 },
|
|
)
|
|
})
|
|
await step('long outage: session kept, Connect button instead of the login form', async () => {
|
|
if (!offlineWorks) return log(' skipped: offline emulation does not reach the websocket here')
|
|
// the core gives up after RECONNECT_MAX attempts (about 80 s); the session is still valid
|
|
await B.context().setOffline(true)
|
|
try {
|
|
await B.waitForSelector('[data-testid=connect] form button[type=submit]', { timeout: 150000 })
|
|
} finally {
|
|
await B.context().setOffline(false)
|
|
}
|
|
if (await B.locator('[data-testid=connect] input[type=password]').count())
|
|
throw new Error('thrown back to the login form')
|
|
await B.click('[data-testid=dialog-confirm]') // "Disconnected: connection lost"
|
|
await B.click('[data-testid=connect] form button[type=submit]')
|
|
await B.waitForSelector('[data-testid=connect]', { state: 'detached', timeout: 30000 })
|
|
await B.waitForFunction(
|
|
() => {
|
|
const v = document.querySelector('video')
|
|
return v && !v.paused && v.readyState >= 2
|
|
},
|
|
null,
|
|
{ timeout: 20000 },
|
|
)
|
|
})
|
|
await step('alice takes control and types into KDE', async () => {
|
|
await A.click('[data-testid=control-request]')
|
|
await A.waitForSelector('[data-testid=member][data-self][data-host]', { timeout: 5000 })
|
|
const box = await A.locator('[data-testid=player]').boundingBox()
|
|
await A.mouse.move(box.x + box.width / 2, box.y + box.height / 2)
|
|
await A.mouse.move(box.x + box.width / 2 + 3, box.y + box.height / 2 + 3)
|
|
// what arrived: select all and copy inside the remote, then read the remote clipboard
|
|
const copyAll = async (settle = 600) => {
|
|
await A.keyboard.press('Control+a')
|
|
await A.keyboard.press('Control+c')
|
|
await A.waitForTimeout(settle)
|
|
return JSON.parse(await api(A, 'GET', '/room/clipboard')).text
|
|
}
|
|
await A.keyboard.press('Alt+F2')
|
|
// KRunner is started by that shortcut and takes the keyboard focus only once it is up; a key
|
|
// that lands on the focus change is lost. So type a probe and read it back until KRunner
|
|
// demonstrably inserts keys, then type the text over the still selected probe
|
|
const deadline = Date.now() + 15000
|
|
for (;;) {
|
|
await A.keyboard.type('x')
|
|
if ((await copyAll(300).catch(() => '')) === 'x') break
|
|
if (Date.now() > deadline) throw new Error('KRunner did not take the keyboard')
|
|
}
|
|
await A.keyboard.type('react gui works', { delay: 30 })
|
|
await A.waitForTimeout(1200)
|
|
await A.screenshot({ path: out + '/e2e-alice.png' })
|
|
await B.screenshot({ path: out + '/e2e-bob.png' })
|
|
const typed = await copyAll()
|
|
if (typed !== 'react gui works') throw new Error('remote received ' + JSON.stringify(typed))
|
|
// Ctrl+V inside the remote pastes what was copied there, with the real Ctrl held. Home collapses
|
|
// the selection to the start; End would not: with the caret already at the end, KRunner's results
|
|
// list takes it (milou ResultsView.qml navigationKeyHandler) and swallows the following Ctrl+V
|
|
await A.keyboard.press('Home')
|
|
await A.keyboard.press('Control+v')
|
|
await A.waitForTimeout(800)
|
|
const pasted = await copyAll()
|
|
if (pasted !== 'react gui worksreact gui works')
|
|
throw new Error('after Ctrl+V the field has ' + JSON.stringify(pasted))
|
|
await A.keyboard.press('Escape')
|
|
})
|
|
await step('alice kicks bob -> bob back at login', async () => {
|
|
await A.click('[data-testid=member]:not([data-self])', { button: 'right' })
|
|
await A.click('[data-testid=member-menu] >> text=Kick')
|
|
await A.click('[data-testid=dialog-confirm]')
|
|
await B.waitForSelector('[data-testid=connect] input[type=password]', { timeout: 8000 })
|
|
await A.click('[data-testid=tabs] >> text=Chat')
|
|
await A.waitForFunction(
|
|
() =>
|
|
[...document.querySelectorAll('[data-testid=chat-event]')].some((e) => /bob\s+disconnected/.test(e.textContent)),
|
|
null,
|
|
{ timeout: 5000 },
|
|
)
|
|
await B.screenshot({ path: out + '/e2e-bob-kicked.png' })
|
|
})
|
|
await step('url params: ?usr&pwd&lang&show_side auto-login, url cleaned, logout', async () => {
|
|
const ctx = await browser.newContext({ ignoreHTTPSErrors: true })
|
|
const C = await ctx.newPage()
|
|
await C.goto(URL + '?usr=carol&pwd=' + USER + '&lang=de&show_side=1')
|
|
await C.waitForSelector('[data-testid=connect]', { state: 'detached', timeout: 20000 })
|
|
await C.waitForSelector('[data-testid=side] >> text=Einstellungen', { timeout: 5000 })
|
|
if (/pwd=/.test(C.url())) throw new Error('password left in url: ' + C.url())
|
|
// the same browser profile opens another invite link while its session is still valid:
|
|
// the new login must win on both the websocket and REST (no split identity)
|
|
await C.goto(URL + '?usr=carol2&pwd=' + USER + '&show_side=1')
|
|
await C.waitForSelector('[data-testid=connect]', { state: 'detached', timeout: 20000 })
|
|
// the browser closes the first page's socket on navigation; Firefox does so without a close
|
|
// code and the server then keeps that session for its 5 s reconnect grace
|
|
let connected
|
|
for (let i = 0; i < 16; i++) {
|
|
await C.waitForTimeout(500)
|
|
connected = JSON.parse(await api(A, 'GET', '/sessions'))
|
|
.filter((s) => s.state.is_connected)
|
|
.map((s) => s.profile.name)
|
|
if (connected.includes('carol2') && !connected.includes('carol')) break
|
|
}
|
|
if (!connected.includes('carol2') || connected.includes('carol'))
|
|
throw new Error('connected sessions after the second invite: ' + JSON.stringify(connected))
|
|
await C.click('[data-testid=tabs] >> text=Einstellungen')
|
|
await C.click('[data-testid=logout]')
|
|
await C.waitForSelector('[data-testid=connect] input[type=password]', { timeout: 8000 })
|
|
await ctx.close()
|
|
})
|
|
await step('url params: ?embed=1 hides header and room bar', async () => {
|
|
const ctx = await browser.newContext({ ignoreHTTPSErrors: true })
|
|
const C = await ctx.newPage()
|
|
await C.goto(URL + '?embed=1&usr=dave&pwd=' + USER)
|
|
await C.waitForSelector('[data-testid=connect]', { state: 'detached', timeout: 20000 })
|
|
if ((await C.locator('[data-testid=header]').count()) || (await C.locator('[data-testid=room-bar]').count()))
|
|
throw new Error('chrome visible in embed mode')
|
|
await ctx.close()
|
|
})
|
|
console.log(
|
|
`\n${fails} failed. page errors:`,
|
|
errors.length ? '\n ' + [...new Set(errors)].slice(0, 12).join('\n ') : 'none',
|
|
)
|
|
await browser.close()
|
|
process.exit(fails || errors.length ? 1 : 0)
|