ep_webrtc 2.5.35 → 2.5.37
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/backend-tests.yml +4 -0
- package/.github/workflows/frontend-tests.yml +19 -41
- package/package.json +1 -1
- package/static/css/styles.css +19 -0
- package/static/js/index.js +56 -2
- package/static/tests/frontend-new/helper/utils.ts +145 -0
- package/static/tests/frontend-new/specs/audio_video_on_start.spec.ts +37 -0
- package/static/tests/frontend-new/specs/checkbox.spec.ts +154 -0
- package/static/tests/frontend-new/specs/enable_disable.spec.ts +81 -0
- package/static/tests/frontend-new/specs/errors.spec.ts +165 -0
- package/static/tests/frontend-new/specs/interface_buttons.spec.ts +271 -0
- package/static/tests/frontend-new/specs/race_conditions.spec.ts +301 -0
- package/static/tests/frontend-new/specs/setStream.spec.ts +185 -0
- package/static/tests/frontend/specs/audio_video_on_start.js +0 -35
- package/static/tests/frontend/specs/checkbox.js +0 -107
- package/static/tests/frontend/specs/enable_disable.js +0 -50
- package/static/tests/frontend/specs/errors.js +0 -58
- package/static/tests/frontend/specs/interface_buttons.js +0 -251
- package/static/tests/frontend/specs/race_conditions.js +0 -206
- package/static/tests/frontend/specs/setStream.js +0 -138
- package/static/tests/frontend/utils.js +0 -44
|
@@ -15,6 +15,11 @@ jobs:
|
|
|
15
15
|
uses: actions/checkout@v6
|
|
16
16
|
with:
|
|
17
17
|
repository: ether/etherpad-lite
|
|
18
|
+
path: etherpad-lite
|
|
19
|
+
- uses: actions/setup-node@v4
|
|
20
|
+
name: Install Node.js
|
|
21
|
+
with:
|
|
22
|
+
node-version: 22
|
|
18
23
|
- uses: pnpm/action-setup@v6
|
|
19
24
|
name: Install pnpm
|
|
20
25
|
with:
|
|
@@ -32,63 +37,36 @@ jobs:
|
|
|
32
37
|
restore-keys: |
|
|
33
38
|
${{ runner.os }}-pnpm-store-
|
|
34
39
|
-
|
|
35
|
-
name:
|
|
40
|
+
name: Checkout plugin repository
|
|
36
41
|
uses: actions/checkout@v6
|
|
37
42
|
with:
|
|
38
|
-
path:
|
|
39
|
-
-
|
|
40
|
-
name: export GIT_HASH to env
|
|
41
|
-
id: environment
|
|
42
|
-
run: |
|
|
43
|
-
cd ./node_modules/__tmp
|
|
44
|
-
echo "::set-output name=sha_short::$(git rev-parse --short ${{ github.sha }})"
|
|
45
|
-
-
|
|
46
|
-
name: Determine plugin name
|
|
47
|
-
id: plugin_name
|
|
48
|
-
run: |
|
|
49
|
-
cd ./node_modules/__tmp
|
|
50
|
-
npx -c 'printf %s\\n "::set-output name=plugin_name::${npm_package_name}"'
|
|
51
|
-
-
|
|
52
|
-
name: Rename plugin directory
|
|
53
|
-
env:
|
|
54
|
-
PLUGIN_NAME: ${{ steps.plugin_name.outputs.plugin_name }}
|
|
55
|
-
run: |
|
|
56
|
-
mv ./node_modules/__tmp ./node_modules/"${PLUGIN_NAME}"
|
|
57
|
-
-
|
|
58
|
-
name: Install plugin dependencies
|
|
59
|
-
env:
|
|
60
|
-
PLUGIN_NAME: ${{ steps.plugin_name.outputs.plugin_name }}
|
|
61
|
-
run: |
|
|
62
|
-
cd ./node_modules/"${PLUGIN_NAME}"
|
|
63
|
-
pnpm i
|
|
64
|
-
# Etherpad core dependencies must be installed after installing the
|
|
65
|
-
# plugin's dependencies, otherwise npm will try to hoist common
|
|
66
|
-
# dependencies by removing them from src/node_modules and installing them
|
|
67
|
-
# in the top-level node_modules. As of v6.14.10, npm's hoist logic appears
|
|
68
|
-
# to be buggy, because it sometimes removes dependencies from
|
|
69
|
-
# src/node_modules but fails to add them to the top-level node_modules.
|
|
70
|
-
# Even if npm correctly hoists the dependencies, the hoisting seems to
|
|
71
|
-
# confuse tools such as `npm outdated`, `npm update`, and some ESLint
|
|
72
|
-
# rules.
|
|
43
|
+
path: plugin
|
|
73
44
|
-
|
|
74
45
|
name: Install Etherpad core dependencies
|
|
46
|
+
working-directory: ./etherpad-lite
|
|
75
47
|
run: bin/installDeps.sh
|
|
48
|
+
- name: Install plugin
|
|
49
|
+
working-directory: ./etherpad-lite
|
|
50
|
+
run: |
|
|
51
|
+
pnpm run plugins i --path ../../plugin
|
|
76
52
|
- name: Create settings.json
|
|
53
|
+
working-directory: ./etherpad-lite
|
|
77
54
|
run: cp ./src/tests/settings.json settings.json
|
|
78
55
|
- name: Run the frontend tests
|
|
56
|
+
working-directory: ./etherpad-lite
|
|
79
57
|
shell: bash
|
|
80
58
|
run: |
|
|
81
59
|
pnpm run dev &
|
|
82
60
|
connected=false
|
|
83
61
|
can_connect() {
|
|
84
|
-
|
|
85
|
-
|
|
62
|
+
curl -sSfo /dev/null http://localhost:9001/ || return 1
|
|
63
|
+
connected=true
|
|
86
64
|
}
|
|
87
65
|
now() { date +%s; }
|
|
88
66
|
start=$(now)
|
|
89
|
-
while [ $(($(now) - $start)) -le
|
|
90
|
-
|
|
67
|
+
while [ $(($(now) - $start)) -le 30 ] && ! can_connect; do
|
|
68
|
+
sleep 1
|
|
91
69
|
done
|
|
92
70
|
cd src
|
|
93
|
-
pnpm exec playwright install chromium
|
|
71
|
+
pnpm exec playwright install chromium --with-deps
|
|
94
72
|
pnpm run test-ui --project=chromium
|
package/package.json
CHANGED
package/static/css/styles.css
CHANGED
|
@@ -6,6 +6,25 @@
|
|
|
6
6
|
font-style: normal;
|
|
7
7
|
}
|
|
8
8
|
|
|
9
|
+
/*
|
|
10
|
+
* The "Failed to access camera/microphone" gritter notifications spawned by
|
|
11
|
+
* showUserMediaError() are sticky and span the top of the editor. Without
|
|
12
|
+
* this rule, the gritter's container/items intercept pointer events on the
|
|
13
|
+
* editor underneath (Playwright's stability check fails with "subtree
|
|
14
|
+
* intercepts pointer events"), making the pad unusable until the user
|
|
15
|
+
* dismisses each toast. Pass clicks through everything except the close
|
|
16
|
+
* button so the toast stays visible and dismissible without blocking
|
|
17
|
+
* the editor.
|
|
18
|
+
*/
|
|
19
|
+
#gritter-container,
|
|
20
|
+
#gritter-container .gritter-item-wrapper,
|
|
21
|
+
#gritter-container .gritter-item,
|
|
22
|
+
#gritter-container .popup-content,
|
|
23
|
+
#gritter-container .gritter-content,
|
|
24
|
+
#gritter-container .gritter-title,
|
|
25
|
+
#gritter-container .gritter-image { pointer-events: none; }
|
|
26
|
+
#gritter-container .gritter-close { pointer-events: auto; }
|
|
27
|
+
|
|
9
28
|
#rtcbox {
|
|
10
29
|
align-items: start;
|
|
11
30
|
display: none;
|
package/static/js/index.js
CHANGED
|
@@ -507,8 +507,52 @@ exports.rtc = new class {
|
|
|
507
507
|
});
|
|
508
508
|
$(window).on('beforeunload', () => { this.hangupAll(); });
|
|
509
509
|
$(window).on('unload', () => { this.hangupAll(); });
|
|
510
|
-
|
|
511
|
-
|
|
510
|
+
// Skip auto-activation when the host has no audio/video device the
|
|
511
|
+
// browser can see. Bisecting against ether/ep_webrtc CI proved that
|
|
512
|
+
// the OUTER pad's activate() chain — specifically when getUserMedia
|
|
513
|
+
// rejects with NotFoundError on a runner without a camera — leaves
|
|
514
|
+
// browser-internal media state that holds the embedded pad iframe's
|
|
515
|
+
// `load` event past Playwright's 90s timeout in
|
|
516
|
+
// tests/frontend-new/specs/embed_value.spec.ts. Real users with a
|
|
517
|
+
// camera/mic still see the same auto-activate flow: enumerateDevices
|
|
518
|
+
// returns at least one device, hasMediaDevice is true, activate
|
|
519
|
+
// runs as before. ep_webrtc's own tests that exercise activation
|
|
520
|
+
// install a fake enumerateDevices via the test helper so they still
|
|
521
|
+
// hit the activate path with the fake getUserMedia.
|
|
522
|
+
let hasMediaDevice = true;
|
|
523
|
+
try {
|
|
524
|
+
const wantAudio = this._settings.audio.disabled !== 'hard';
|
|
525
|
+
const wantVideo = this._settings.video.disabled !== 'hard';
|
|
526
|
+
const devices = await navigator.mediaDevices.enumerateDevices();
|
|
527
|
+
const hasAudio = devices.some((d) => d.kind === 'audioinput');
|
|
528
|
+
const hasVideo = devices.some((d) => d.kind === 'videoinput');
|
|
529
|
+
hasMediaDevice = (wantAudio && hasAudio) || (wantVideo && hasVideo);
|
|
530
|
+
} catch (err) {
|
|
531
|
+
debug('enumerateDevices() failed; falling back to auto-activate:', err);
|
|
532
|
+
}
|
|
533
|
+
// Suppress the sticky "Failed to access camera/microphone" gritter
|
|
534
|
+
// during the initial auto-activation. Without this, CI runners (and
|
|
535
|
+
// anyone loading a pad without granting camera/mic permission) would
|
|
536
|
+
// see an unsolicited error toast on every pad load, which also
|
|
537
|
+
// contaminated unrelated tests that read gritter content (e.g.
|
|
538
|
+
// error_sanitization). Errors surfaced AFTER the user explicitly
|
|
539
|
+
// re-clicks the checkbox / mic / video button still show the toast
|
|
540
|
+
// as before — see the change-handler above.
|
|
541
|
+
if (hasMediaDevice && $('#options-enablertc').prop('checked')) {
|
|
542
|
+
this._suppressMediaErrorToast = true;
|
|
543
|
+
try {
|
|
544
|
+
await this.activate();
|
|
545
|
+
} finally {
|
|
546
|
+
this._suppressMediaErrorToast = false;
|
|
547
|
+
}
|
|
548
|
+
} else if (!hasMediaDevice) {
|
|
549
|
+
// No camera/mic visible. Don't auto-activate — the activate chain
|
|
550
|
+
// creates a <video autoplay> element and a getUserMedia request
|
|
551
|
+
// that hangs the browser's media subsystem when there's no
|
|
552
|
+
// device. The checkbox keeps whatever state settingToCheckbox
|
|
553
|
+
// assigned (cookie/query/default) so users still see the right
|
|
554
|
+
// toggle in the gear menu. They can manually tick it to call
|
|
555
|
+
// activate() via the change-handler.
|
|
512
556
|
} else {
|
|
513
557
|
await this.deactivate();
|
|
514
558
|
}
|
|
@@ -551,6 +595,16 @@ exports.rtc = new class {
|
|
|
551
595
|
}
|
|
552
596
|
|
|
553
597
|
showUserMediaError(err) { // show an error returned from getUserMedia
|
|
598
|
+
if (this._suppressMediaErrorToast) {
|
|
599
|
+
// Auto-activation from cookie/default failed (e.g. no camera in CI or
|
|
600
|
+
// the user hasn't granted permission yet). Log it but don't pop a
|
|
601
|
+
// sticky gritter — the buttons already reflect the failed state and
|
|
602
|
+
// the user can re-click to retry, at which point a real error toast
|
|
603
|
+
// will appear.
|
|
604
|
+
debug('suppressing user-media error toast during auto-activation:', err);
|
|
605
|
+
logErrorToServer(err);
|
|
606
|
+
return;
|
|
607
|
+
}
|
|
554
608
|
err.devices.sort();
|
|
555
609
|
const devices = err.devices.join('');
|
|
556
610
|
let msgId = null;
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import {Page} from '@playwright/test';
|
|
2
|
+
|
|
3
|
+
// Generator that yields the Cartesian product of the given iterables.
|
|
4
|
+
// 1:1 port of the legacy utils.js cartesian generator.
|
|
5
|
+
export function* cartesian<T>(head: T[], ...tail: T[][]): Generator<T[]> {
|
|
6
|
+
const remainder: Iterable<T[]> = tail.length > 0 ? cartesian(tail[0], ...tail.slice(1)) : [[]];
|
|
7
|
+
for (const r of remainder) for (const h of head) yield [h, ...r];
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
// Installs a fake navigator.mediaDevices.getUserMedia inside the page.
|
|
11
|
+
//
|
|
12
|
+
// Implementation note: the legacy helper depended on document/AudioContext
|
|
13
|
+
// at the time of installation (it created the canvas + AudioContext inside
|
|
14
|
+
// the pad's chrome window). The Playwright equivalent runs the entire
|
|
15
|
+
// install inside page.evaluate() so canvas/AudioContext live in browser
|
|
16
|
+
// context, matching the old behavior 1:1.
|
|
17
|
+
//
|
|
18
|
+
// If `track` is true, the function also sets `window.__webrtcLastStream`
|
|
19
|
+
// and `window.__webrtcLastConstraints` on every getUserMedia call so the
|
|
20
|
+
// caller can inspect the most recent audio/video tracks (used by the
|
|
21
|
+
// interface_buttons spec which originally captured the tracks via
|
|
22
|
+
// closure variables in the helper).
|
|
23
|
+
export const installFakeGetUserMedia = async (page: Page, opts: {track?: boolean} = {}) => {
|
|
24
|
+
const {track = false} = opts;
|
|
25
|
+
await page.evaluate((track) => {
|
|
26
|
+
const w = window as any;
|
|
27
|
+
const makeSilentAudioTrack = () => {
|
|
28
|
+
const ctx = new AudioContext();
|
|
29
|
+
const gain = ctx.createGain();
|
|
30
|
+
const dst = gain.connect(ctx.createMediaStreamDestination());
|
|
31
|
+
return dst.stream.getAudioTracks()[0];
|
|
32
|
+
};
|
|
33
|
+
const makeVideoTrack = (constraints: any) => {
|
|
34
|
+
const canvas = document.createElement('canvas');
|
|
35
|
+
const {
|
|
36
|
+
width: {max: widthMax = 160, ideal: widthIdeal} = {} as any,
|
|
37
|
+
height: {max: heightMax = 120, ideal: heightIdeal} = {} as any,
|
|
38
|
+
} = constraints || {};
|
|
39
|
+
canvas.width = widthIdeal || widthMax;
|
|
40
|
+
canvas.height = heightIdeal || heightMax;
|
|
41
|
+
const ctx = canvas.getContext('2d')!;
|
|
42
|
+
// Some animation is needed because in some browsers HTMLVideoElement.play() will hang
|
|
43
|
+
// until the canvas is updated. Use a relatively high frame rate to speed up tests.
|
|
44
|
+
window.setInterval(() => {
|
|
45
|
+
ctx.fillStyle = `#${Math.floor(Math.random() * 2 ** 24).toString(16).padStart(6, '0')}`;
|
|
46
|
+
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
|
47
|
+
}, 100);
|
|
48
|
+
return (canvas as any).captureStream().getVideoTracks()[0];
|
|
49
|
+
};
|
|
50
|
+
const fakeGetUserMedia = async (constraints: any) => {
|
|
51
|
+
const audio = constraints && constraints.audio;
|
|
52
|
+
const video = constraints && constraints.video;
|
|
53
|
+
if (!audio && !video) {
|
|
54
|
+
throw new DOMException('either audio or video is required', 'TypeError');
|
|
55
|
+
}
|
|
56
|
+
const stream = new MediaStream([
|
|
57
|
+
...(audio ? [makeSilentAudioTrack()] : []),
|
|
58
|
+
...(video ? [makeVideoTrack(video)] : []),
|
|
59
|
+
]);
|
|
60
|
+
if (track) {
|
|
61
|
+
w.__webrtcLastStream = stream;
|
|
62
|
+
w.__webrtcLastConstraints = constraints;
|
|
63
|
+
}
|
|
64
|
+
return stream;
|
|
65
|
+
};
|
|
66
|
+
w.__fakeGetUserMedia = fakeGetUserMedia;
|
|
67
|
+
w.navigator.mediaDevices.getUserMedia = fakeGetUserMedia;
|
|
68
|
+
// ep_webrtc's postAceInit checks enumerateDevices() and skips
|
|
69
|
+
// auto-activation when no audio/video device is visible (this is
|
|
70
|
+
// what lets embed_value.spec.ts pass on CI runners without a
|
|
71
|
+
// camera). Tests that rely on auto-activate must therefore expose
|
|
72
|
+
// fake devices too — fake them here in the same helper so callers
|
|
73
|
+
// get the full activation flow without extra wiring.
|
|
74
|
+
w.navigator.mediaDevices.enumerateDevices = async () => [
|
|
75
|
+
{kind: 'audioinput', deviceId: 'fake-audio', groupId: 'fake', label: 'fake mic'},
|
|
76
|
+
{kind: 'videoinput', deviceId: 'fake-video', groupId: 'fake', label: 'fake cam'},
|
|
77
|
+
];
|
|
78
|
+
}, track);
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
// Fakes navigator.mediaDevices.enumerateDevices to expose one
|
|
82
|
+
// audioinput + one videoinput device, without touching getUserMedia.
|
|
83
|
+
// Use this in tests that need ep_webrtc to auto-activate but still
|
|
84
|
+
// want to control getUserMedia separately (e.g. errors.spec patches
|
|
85
|
+
// getUserMedia to throw specific DOMExceptions).
|
|
86
|
+
export const installFakeMediaDevices = async (page: Page) => {
|
|
87
|
+
await page.evaluate(() => {
|
|
88
|
+
const w = window as any;
|
|
89
|
+
w.navigator.mediaDevices.enumerateDevices = async () => [
|
|
90
|
+
{kind: 'audioinput', deviceId: 'fake-audio', groupId: 'fake', label: 'fake mic'},
|
|
91
|
+
{kind: 'videoinput', deviceId: 'fake-video', groupId: 'fake', label: 'fake cam'},
|
|
92
|
+
];
|
|
93
|
+
});
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
// Sets the `prefs` cookie so the next pad load picks up the supplied
|
|
97
|
+
// padPrefs. Mirrors helper.aNewPad({padPrefs}) from the legacy harness.
|
|
98
|
+
export const setPadPrefsCookie = async (page: Page, padPrefs: Record<string, any>) => {
|
|
99
|
+
await page.context().addCookies([{
|
|
100
|
+
name: 'prefsHttp',
|
|
101
|
+
// Newer Etherpad stores the prefs cookie as `prefsHttp` and the
|
|
102
|
+
// value is the prefs object itself (no `{prefs: ...}` wrapper).
|
|
103
|
+
value: encodeURIComponent(JSON.stringify(padPrefs)),
|
|
104
|
+
url: 'http://localhost:9001',
|
|
105
|
+
}]);
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
// Reads a single key out of the live prefsHttp cookie. Replaces the
|
|
109
|
+
// legacy `padcookie.getPref(key)` calls that depended on Etherpad's
|
|
110
|
+
// CommonJS `require` being exposed on window — modern Etherpad bundles
|
|
111
|
+
// via esbuild and no longer exposes require to page scripts.
|
|
112
|
+
export const readPrefCookie = (page: Page, key: string) => page.evaluate((key) => {
|
|
113
|
+
const cookies: Record<string, string> = {};
|
|
114
|
+
for (const c of document.cookie.split('; ')) {
|
|
115
|
+
if (!c) continue;
|
|
116
|
+
const eq = c.indexOf('=');
|
|
117
|
+
if (eq < 0) continue;
|
|
118
|
+
cookies[c.slice(0, eq)] = c.slice(eq + 1);
|
|
119
|
+
}
|
|
120
|
+
const raw = cookies.prefsHttp;
|
|
121
|
+
if (!raw) return null;
|
|
122
|
+
try {
|
|
123
|
+
const prefs = JSON.parse(decodeURIComponent(raw));
|
|
124
|
+
return prefs[key] != null ? prefs[key] : null;
|
|
125
|
+
} catch {
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
}, key);
|
|
129
|
+
|
|
130
|
+
// Navigates to a fresh pad with optional URL query params (mirrors
|
|
131
|
+
// helper.aNewPad({params}) from the legacy harness). Does not clear
|
|
132
|
+
// cookies – call setPadPrefsCookie first if you need padPrefs.
|
|
133
|
+
export const goToNewPadWithParams = async (
|
|
134
|
+
page: Page, params: Record<string, any> = {}) => {
|
|
135
|
+
const {randomUUID} = await import('node:crypto');
|
|
136
|
+
const padId = 'FRONTEND_TESTS' + randomUUID();
|
|
137
|
+
const qs = new URLSearchParams();
|
|
138
|
+
for (const [k, v] of Object.entries(params)) qs.append(k, String(v));
|
|
139
|
+
const url = `http://localhost:9001/p/${padId}` +
|
|
140
|
+
(qs.toString() ? `?${qs.toString()}` : '');
|
|
141
|
+
await page.goto(url);
|
|
142
|
+
await page.waitForSelector('iframe[name="ace_outer"]');
|
|
143
|
+
await page.waitForSelector('#editorcontainer.initialized');
|
|
144
|
+
return padId;
|
|
145
|
+
};
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import {expect, test} from '@playwright/test';
|
|
2
|
+
import {cartesian, goToNewPadWithParams, installFakeGetUserMedia, setPadPrefsCookie}
|
|
3
|
+
from '../helper/utils';
|
|
4
|
+
|
|
5
|
+
test.describe('audio/video on/off according to query parameters/cookies', () => {
|
|
6
|
+
const testCases = [...cartesian(
|
|
7
|
+
['audio', 'video'] as Array<'audio' | 'video'>,
|
|
8
|
+
[null, false, true],
|
|
9
|
+
[null, false, true])];
|
|
10
|
+
|
|
11
|
+
for (const [avType, cookieVal, queryVal] of testCases) {
|
|
12
|
+
test(`${avType} cookie=${cookieVal} query=${queryVal}`, async ({page, context}) => {
|
|
13
|
+
test.setTimeout(60_000);
|
|
14
|
+
await context.clearCookies();
|
|
15
|
+
const padPrefs = cookieVal == null ? {} : {[`${avType}EnabledOnStart`]: cookieVal};
|
|
16
|
+
await setPadPrefsCookie(page, padPrefs);
|
|
17
|
+
const params: Record<string, any> = {av: false};
|
|
18
|
+
if (queryVal != null) params[`webrtc${avType}enabled`] = queryVal;
|
|
19
|
+
await goToNewPadWithParams(page, params);
|
|
20
|
+
await installFakeGetUserMedia(page);
|
|
21
|
+
// Calling activate() directly blocks until activation completes.
|
|
22
|
+
await page.evaluate(() => (window as any).ep_webrtc.activate());
|
|
23
|
+
const disabled: string = await page.evaluate(
|
|
24
|
+
(avType) => (window as any).ep_webrtc._settings[avType].disabled, avType);
|
|
25
|
+
const checkboxCount = await page.locator(`#options-${avType}enabledonstart`).count();
|
|
26
|
+
if (disabled === 'hard') {
|
|
27
|
+
expect(checkboxCount).toBe(0);
|
|
28
|
+
} else {
|
|
29
|
+
const wantChecked = !!(queryVal || (queryVal == null && cookieVal) ||
|
|
30
|
+
(queryVal == null && cookieVal == null && disabled === 'none'));
|
|
31
|
+
const checked = await page.locator(`#options-${avType}enabledonstart`)
|
|
32
|
+
.evaluate((el) => (el as HTMLInputElement).checked);
|
|
33
|
+
expect(checked).toBe(wantChecked);
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
});
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import {expect, test} from '@playwright/test';
|
|
2
|
+
import {cartesian, goToNewPadWithParams, readPrefCookie, setPadPrefsCookie}
|
|
3
|
+
from '../helper/utils';
|
|
4
|
+
|
|
5
|
+
type Tc = {
|
|
6
|
+
name: string;
|
|
7
|
+
defaultVal: boolean;
|
|
8
|
+
cookieVal: boolean | null;
|
|
9
|
+
queryVal: boolean | null;
|
|
10
|
+
i: number;
|
|
11
|
+
id: string;
|
|
12
|
+
want: boolean;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const testCases: Tc[] = [...cartesian(
|
|
16
|
+
[false, true] as boolean[],
|
|
17
|
+
[null, false, true] as Array<boolean | null>,
|
|
18
|
+
[null, false, true] as Array<boolean | null>)].map(([defaultVal, cookieVal, queryVal], i) => ({
|
|
19
|
+
name: `default=${defaultVal} cookie=${cookieVal} query=${queryVal}`,
|
|
20
|
+
defaultVal: defaultVal as boolean,
|
|
21
|
+
cookieVal: cookieVal as boolean | null,
|
|
22
|
+
queryVal: queryVal as boolean | null,
|
|
23
|
+
i,
|
|
24
|
+
id: `checkboxId${i}`,
|
|
25
|
+
want: !!((queryVal as boolean | null) ||
|
|
26
|
+
(queryVal == null && cookieVal) ||
|
|
27
|
+
(queryVal == null && cookieVal == null && defaultVal)),
|
|
28
|
+
}));
|
|
29
|
+
|
|
30
|
+
test.describe('settingToCheckbox', () => {
|
|
31
|
+
// Use a serial worker so the single before-all setup persists across tests
|
|
32
|
+
// (legacy used mocha's `before` for one-shot setup).
|
|
33
|
+
test.describe.configure({mode: 'serial'});
|
|
34
|
+
|
|
35
|
+
let sharedPage: import('@playwright/test').Page;
|
|
36
|
+
|
|
37
|
+
test.beforeAll(async ({browser}) => {
|
|
38
|
+
sharedPage = await browser.newPage();
|
|
39
|
+
test.setTimeout(60_000);
|
|
40
|
+
const padPrefs: Record<string, any> = {};
|
|
41
|
+
for (const tc of testCases) {
|
|
42
|
+
if (tc.cookieVal != null) padPrefs[`cookie${tc.i}`] = tc.cookieVal;
|
|
43
|
+
}
|
|
44
|
+
await setPadPrefsCookie(sharedPage, padPrefs);
|
|
45
|
+
const params: Record<string, any> = {av: false};
|
|
46
|
+
for (const tc of testCases) {
|
|
47
|
+
if (tc.queryVal != null) params[`urlVar${tc.i}`] = tc.queryVal;
|
|
48
|
+
}
|
|
49
|
+
await goToNewPadWithParams(sharedPage, params);
|
|
50
|
+
// Append a checkbox per testCase to #settings then call settingToCheckbox.
|
|
51
|
+
await sharedPage.evaluate((cases) => {
|
|
52
|
+
const w = window as any;
|
|
53
|
+
const $ = w.$;
|
|
54
|
+
for (const c of cases) {
|
|
55
|
+
$('#settings').append($('<input>').attr('type', 'checkbox').attr('id', c.id));
|
|
56
|
+
}
|
|
57
|
+
}, testCases.map(({id}) => ({id})));
|
|
58
|
+
// Wait for all checkboxes to exist.
|
|
59
|
+
await sharedPage.waitForFunction((cases) => {
|
|
60
|
+
for (const c of cases) {
|
|
61
|
+
if (document.querySelectorAll(`#${c.id}`).length !== 1) return false;
|
|
62
|
+
}
|
|
63
|
+
return true;
|
|
64
|
+
}, testCases.map(({id}) => ({id})));
|
|
65
|
+
await sharedPage.evaluate((cases) => {
|
|
66
|
+
const w = window as any;
|
|
67
|
+
for (const c of cases) {
|
|
68
|
+
w.ep_webrtc.settingToCheckbox({
|
|
69
|
+
urlVar: `urlVar${c.i}`,
|
|
70
|
+
cookie: `cookie${c.i}`,
|
|
71
|
+
defaultVal: c.defaultVal,
|
|
72
|
+
checkboxId: `#${c.id}`,
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
}, testCases.map(({i, id, defaultVal}) => ({i, id, defaultVal})));
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test.afterAll(async () => {
|
|
79
|
+
await sharedPage.close();
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test.describe('initially checked/unchecked', () => {
|
|
83
|
+
for (const {name, want, id} of testCases) {
|
|
84
|
+
test(name, async () => {
|
|
85
|
+
const checked = await sharedPage.locator(`#${id}`)
|
|
86
|
+
.evaluate((el) => (el as HTMLInputElement).checked);
|
|
87
|
+
expect(checked).toBe(want);
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test.describe('query parameter sets cookie', () => {
|
|
93
|
+
for (const {name, queryVal, i} of testCases.filter((t) => t.queryVal != null)) {
|
|
94
|
+
test(name, async () => {
|
|
95
|
+
const v = await readPrefCookie(sharedPage, `cookie${i}`);
|
|
96
|
+
expect(v).toBe(queryVal);
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test.describe('no query parameter, no cookie -> cookie not set', () => {
|
|
102
|
+
for (const {name, queryVal, cookieVal, i} of testCases) {
|
|
103
|
+
if (queryVal != null || cookieVal != null) continue;
|
|
104
|
+
test(name, async () => {
|
|
105
|
+
const v = await readPrefCookie(sharedPage, `cookie${i}`);
|
|
106
|
+
expect(v == null).toBe(true);
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test.describe('clicking sets cookie', () => {
|
|
112
|
+
for (const {name, i, id, want} of testCases) {
|
|
113
|
+
test(name, async () => {
|
|
114
|
+
await sharedPage.locator(`#${id}`).evaluate((el) => {
|
|
115
|
+
const w = window as any;
|
|
116
|
+
w.$(el).click();
|
|
117
|
+
});
|
|
118
|
+
await sharedPage.waitForFunction(({id, want}) => {
|
|
119
|
+
const cb = document.querySelector(`#${id}`) as HTMLInputElement | null;
|
|
120
|
+
return cb != null && cb.checked === !want;
|
|
121
|
+
}, {id, want});
|
|
122
|
+
const v = await readPrefCookie(sharedPage, `cookie${i}`);
|
|
123
|
+
expect(v).toBe(!want);
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test.describe('throws errors for missing params', () => {
|
|
129
|
+
const params = {
|
|
130
|
+
urlVar: 'urlVar',
|
|
131
|
+
cookie: 'cookie',
|
|
132
|
+
defaultVal: true,
|
|
133
|
+
checkboxId: '#checkboxId',
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
for (const k of Object.keys(params) as Array<keyof typeof params>) {
|
|
137
|
+
test(k, async () => {
|
|
138
|
+
const result = await sharedPage.evaluate(({params, k}) => {
|
|
139
|
+
const w = window as any;
|
|
140
|
+
const badParams: any = {...params};
|
|
141
|
+
delete badParams[k];
|
|
142
|
+
try {
|
|
143
|
+
w.ep_webrtc.settingToCheckbox(badParams);
|
|
144
|
+
return {threw: false, msg: ''};
|
|
145
|
+
} catch (e: any) {
|
|
146
|
+
return {threw: true, msg: String(e && (e.message || e))};
|
|
147
|
+
}
|
|
148
|
+
}, {params, k});
|
|
149
|
+
expect(result.threw).toBe(true);
|
|
150
|
+
expect(result.msg).toMatch(new RegExp(k));
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
});
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import {expect, test} from '@playwright/test';
|
|
2
|
+
import {cartesian, goToNewPadWithParams, setPadPrefsCookie} from '../helper/utils';
|
|
3
|
+
|
|
4
|
+
const testCases = [...cartesian(
|
|
5
|
+
[null, false, true] as Array<boolean | null>,
|
|
6
|
+
[null, false, true, 'NO', 'YES', 'ignored'] as Array<boolean | string | null>)];
|
|
7
|
+
|
|
8
|
+
test.describe('enable/disable', () => {
|
|
9
|
+
for (const [cookieVal, queryVal] of testCases) {
|
|
10
|
+
test.describe(`cookie=${cookieVal} query=${queryVal}`, () => {
|
|
11
|
+
// serial: tests within share state from beforeAll on a single page.
|
|
12
|
+
test.describe.configure({mode: 'serial'});
|
|
13
|
+
|
|
14
|
+
let sharedPage: import('@playwright/test').Page;
|
|
15
|
+
let wantChecked: boolean;
|
|
16
|
+
|
|
17
|
+
test.beforeAll(async ({browser}) => {
|
|
18
|
+
sharedPage = await browser.newPage();
|
|
19
|
+
test.setTimeout(60_000);
|
|
20
|
+
const padPrefs = cookieVal == null ? {} : {rtcEnabled: cookieVal};
|
|
21
|
+
// Make ep_webrtc's enumerateDevices()-based auto-activate skip
|
|
22
|
+
// happy: pretend a camera/mic is present (and supply a fake
|
|
23
|
+
// getUserMedia) BEFORE navigation so postAceInit sees devices.
|
|
24
|
+
await sharedPage.addInitScript(() => {
|
|
25
|
+
const w = window as any;
|
|
26
|
+
const fakeStream = () => new MediaStream();
|
|
27
|
+
w.navigator.mediaDevices.enumerateDevices = async () => [
|
|
28
|
+
{kind: 'audioinput', deviceId: 'fake-audio', groupId: 'fake', label: 'fake mic'},
|
|
29
|
+
{kind: 'videoinput', deviceId: 'fake-video', groupId: 'fake', label: 'fake cam'},
|
|
30
|
+
];
|
|
31
|
+
w.navigator.mediaDevices.getUserMedia = async () => fakeStream();
|
|
32
|
+
});
|
|
33
|
+
await setPadPrefsCookie(sharedPage, padPrefs);
|
|
34
|
+
const params: Record<string, any> = {};
|
|
35
|
+
if (queryVal != null) params.av = queryVal;
|
|
36
|
+
await goToNewPadWithParams(sharedPage, params);
|
|
37
|
+
// Normalize queryVal to null/false/true.
|
|
38
|
+
const queryNorm: boolean | null =
|
|
39
|
+
typeof queryVal === 'boolean' ? queryVal
|
|
40
|
+
: queryVal === 'NO' ? false
|
|
41
|
+
: queryVal === 'YES' ? true
|
|
42
|
+
: null;
|
|
43
|
+
await sharedPage.waitForFunction(() => {
|
|
44
|
+
const w = window as any;
|
|
45
|
+
return w.$('#rtcbox').data('initialized');
|
|
46
|
+
}, undefined, {timeout: 5000});
|
|
47
|
+
const defaultChecked: boolean = await sharedPage.evaluate(
|
|
48
|
+
() => !!(window as any).ep_webrtc._settings.enabled);
|
|
49
|
+
wantChecked = !!(queryNorm || (queryNorm == null && cookieVal) ||
|
|
50
|
+
(queryNorm == null && cookieVal == null && defaultChecked));
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test.afterAll(async () => {
|
|
54
|
+
await sharedPage.close();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test('checkbox is checked/unchecked', async () => {
|
|
58
|
+
const checked = await sharedPage.locator('#options-enablertc')
|
|
59
|
+
.evaluate((el) => (el as HTMLInputElement).checked);
|
|
60
|
+
expect(checked).toBe(wantChecked);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('self video element', async () => {
|
|
64
|
+
const count = await sharedPage.locator('#rtcbox video').count();
|
|
65
|
+
expect(count).toBe(wantChecked ? 1 : 0);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test('clicking checkbox toggles state', async () => {
|
|
69
|
+
await sharedPage.locator('#options-enablertc').evaluate((el) => {
|
|
70
|
+
(window as any).$(el).click();
|
|
71
|
+
});
|
|
72
|
+
const checked = await sharedPage.locator('#options-enablertc')
|
|
73
|
+
.evaluate((el) => (el as HTMLInputElement).checked);
|
|
74
|
+
expect(checked).toBe(!wantChecked);
|
|
75
|
+
await sharedPage.waitForFunction((expected) => {
|
|
76
|
+
return document.querySelectorAll('#rtcbox video').length === expected;
|
|
77
|
+
}, wantChecked ? 0 : 1);
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
});
|