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.
@@ -26,6 +26,10 @@ jobs:
26
26
  with:
27
27
  repository: ether/etherpad-lite
28
28
  path: etherpad-lite
29
+ - uses: actions/setup-node@v4
30
+ name: Install Node.js
31
+ with:
32
+ node-version: 22
29
33
  - uses: pnpm/action-setup@v6
30
34
  name: Install pnpm
31
35
  with:
@@ -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: Check out the plugin
40
+ name: Checkout plugin repository
36
41
  uses: actions/checkout@v6
37
42
  with:
38
- path: ./node_modules/__tmp
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
- curl -sSfo /dev/null http://localhost:9001/ || return 1
85
- connected=true
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 15 ] && ! can_connect; do
90
- sleep 1
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 --with-deps
71
+ pnpm exec playwright install chromium --with-deps
94
72
  pnpm run test-ui --project=chromium
package/package.json CHANGED
@@ -5,7 +5,7 @@
5
5
  "url": "git@github.com:ether/ep_webrtc.git",
6
6
  "type": "git"
7
7
  },
8
- "version": "2.5.35",
8
+ "version": "2.5.37",
9
9
  "description": "WebRTC based audio/video chat to Etherpad",
10
10
  "author": "John McLear <john@mclear.co.uk>",
11
11
  "contributors": [],
@@ -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;
@@ -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
- if ($('#options-enablertc').prop('checked')) {
511
- await this.activate();
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
+ });