ep_webrtc 2.5.35 → 2.5.36

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/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.36",
9
9
  "description": "WebRTC based audio/video chat to Etherpad",
10
10
  "author": "John McLear <john@mclear.co.uk>",
11
11
  "contributors": [],
@@ -0,0 +1,96 @@
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
+ }, track);
69
+ };
70
+
71
+ // Sets the `prefs` cookie so the next pad load picks up the supplied
72
+ // padPrefs. Mirrors helper.aNewPad({padPrefs}) from the legacy harness.
73
+ export const setPadPrefsCookie = async (page: Page, padPrefs: Record<string, any>) => {
74
+ await page.context().addCookies([{
75
+ name: 'prefs',
76
+ value: encodeURIComponent(JSON.stringify({prefs: padPrefs})),
77
+ url: 'http://localhost:9001',
78
+ }]);
79
+ };
80
+
81
+ // Navigates to a fresh pad with optional URL query params (mirrors
82
+ // helper.aNewPad({params}) from the legacy harness). Does not clear
83
+ // cookies – call setPadPrefsCookie first if you need padPrefs.
84
+ export const goToNewPadWithParams = async (
85
+ page: Page, params: Record<string, any> = {}) => {
86
+ const {randomUUID} = await import('node:crypto');
87
+ const padId = 'FRONTEND_TESTS' + randomUUID();
88
+ const qs = new URLSearchParams();
89
+ for (const [k, v] of Object.entries(params)) qs.append(k, String(v));
90
+ const url = `http://localhost:9001/p/${padId}` +
91
+ (qs.toString() ? `?${qs.toString()}` : '');
92
+ await page.goto(url);
93
+ await page.waitForSelector('iframe[name="ace_outer"]');
94
+ await page.waitForSelector('#editorcontainer.initialized');
95
+ return padId;
96
+ };
@@ -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,165 @@
1
+ import {expect, test} from '@playwright/test';
2
+ import {cartesian, goToNewPadWithParams, setPadPrefsCookie} from '../helper/utils';
3
+
4
+ type Tc = {
5
+ name: string;
6
+ defaultVal: boolean;
7
+ cookieVal: boolean | null;
8
+ queryVal: boolean | null;
9
+ i: number;
10
+ id: string;
11
+ want: boolean;
12
+ };
13
+
14
+ const testCases: Tc[] = [...cartesian(
15
+ [false, true] as boolean[],
16
+ [null, false, true] as Array<boolean | null>,
17
+ [null, false, true] as Array<boolean | null>)].map(([defaultVal, cookieVal, queryVal], i) => ({
18
+ name: `default=${defaultVal} cookie=${cookieVal} query=${queryVal}`,
19
+ defaultVal: defaultVal as boolean,
20
+ cookieVal: cookieVal as boolean | null,
21
+ queryVal: queryVal as boolean | null,
22
+ i,
23
+ id: `checkboxId${i}`,
24
+ want: !!((queryVal as boolean | null) ||
25
+ (queryVal == null && cookieVal) ||
26
+ (queryVal == null && cookieVal == null && defaultVal)),
27
+ }));
28
+
29
+ test.describe('settingToCheckbox', () => {
30
+ // Use a serial worker so the single before-all setup persists across tests
31
+ // (legacy used mocha's `before` for one-shot setup).
32
+ test.describe.configure({mode: 'serial'});
33
+
34
+ let sharedPage: import('@playwright/test').Page;
35
+
36
+ test.beforeAll(async ({browser}) => {
37
+ sharedPage = await browser.newPage();
38
+ test.setTimeout(60_000);
39
+ const padPrefs: Record<string, any> = {};
40
+ for (const tc of testCases) {
41
+ if (tc.cookieVal != null) padPrefs[`cookie${tc.i}`] = tc.cookieVal;
42
+ }
43
+ await setPadPrefsCookie(sharedPage, padPrefs);
44
+ const params: Record<string, any> = {av: false};
45
+ for (const tc of testCases) {
46
+ if (tc.queryVal != null) params[`urlVar${tc.i}`] = tc.queryVal;
47
+ }
48
+ await goToNewPadWithParams(sharedPage, params);
49
+ // Append a checkbox per testCase to #settings then call settingToCheckbox.
50
+ await sharedPage.evaluate((cases) => {
51
+ const w = window as any;
52
+ const $ = w.$;
53
+ for (const c of cases) {
54
+ $('#settings').append($('<input>').attr('type', 'checkbox').attr('id', c.id));
55
+ }
56
+ }, testCases.map(({id}) => ({id})));
57
+ // Wait for all checkboxes to exist.
58
+ await sharedPage.waitForFunction((cases) => {
59
+ for (const c of cases) {
60
+ if (document.querySelectorAll(`#${c.id}`).length !== 1) return false;
61
+ }
62
+ return true;
63
+ }, testCases.map(({id}) => ({id})));
64
+ await sharedPage.evaluate((cases) => {
65
+ const w = window as any;
66
+ for (const c of cases) {
67
+ w.ep_webrtc.settingToCheckbox({
68
+ urlVar: `urlVar${c.i}`,
69
+ cookie: `cookie${c.i}`,
70
+ defaultVal: c.defaultVal,
71
+ checkboxId: `#${c.id}`,
72
+ });
73
+ }
74
+ }, testCases.map(({i, id, defaultVal}) => ({i, id, defaultVal})));
75
+ });
76
+
77
+ test.afterAll(async () => {
78
+ await sharedPage.close();
79
+ });
80
+
81
+ test.describe('initially checked/unchecked', () => {
82
+ for (const {name, want, id} of testCases) {
83
+ test(name, async () => {
84
+ const checked = await sharedPage.locator(`#${id}`)
85
+ .evaluate((el) => (el as HTMLInputElement).checked);
86
+ expect(checked).toBe(want);
87
+ });
88
+ }
89
+ });
90
+
91
+ test.describe('query parameter sets cookie', () => {
92
+ for (const {name, queryVal, i} of testCases.filter((t) => t.queryVal != null)) {
93
+ test(name, async () => {
94
+ const v = await sharedPage.evaluate((i) => {
95
+ const w = window as any;
96
+ const padcookie = w.require('ep_etherpad-lite/static/js/pad_cookie').padcookie;
97
+ return padcookie.getPref(`cookie${i}`);
98
+ }, i);
99
+ expect(v).toBe(queryVal);
100
+ });
101
+ }
102
+ });
103
+
104
+ test.describe('no query parameter, no cookie -> cookie not set', () => {
105
+ for (const {name, queryVal, cookieVal, i} of testCases) {
106
+ if (queryVal != null || cookieVal != null) continue;
107
+ test(name, async () => {
108
+ const v = await sharedPage.evaluate((i) => {
109
+ const w = window as any;
110
+ const padcookie = w.require('ep_etherpad-lite/static/js/pad_cookie').padcookie;
111
+ return padcookie.getPref(`cookie${i}`);
112
+ }, i);
113
+ expect(v == null).toBe(true);
114
+ });
115
+ }
116
+ });
117
+
118
+ test.describe('clicking sets cookie', () => {
119
+ for (const {name, i, id, want} of testCases) {
120
+ test(name, async () => {
121
+ await sharedPage.locator(`#${id}`).evaluate((el) => {
122
+ const w = window as any;
123
+ w.$(el).click();
124
+ });
125
+ await sharedPage.waitForFunction(({id, want}) => {
126
+ const cb = document.querySelector(`#${id}`) as HTMLInputElement | null;
127
+ return cb != null && cb.checked === !want;
128
+ }, {id, want});
129
+ const v = await sharedPage.evaluate((i) => {
130
+ const w = window as any;
131
+ const padcookie = w.require('ep_etherpad-lite/static/js/pad_cookie').padcookie;
132
+ return padcookie.getPref(`cookie${i}`);
133
+ }, i);
134
+ expect(v).toBe(!want);
135
+ });
136
+ }
137
+ });
138
+
139
+ test.describe('throws errors for missing params', () => {
140
+ const params = {
141
+ urlVar: 'urlVar',
142
+ cookie: 'cookie',
143
+ defaultVal: true,
144
+ checkboxId: '#checkboxId',
145
+ };
146
+
147
+ for (const k of Object.keys(params) as Array<keyof typeof params>) {
148
+ test(k, async () => {
149
+ const result = await sharedPage.evaluate(({params, k}) => {
150
+ const w = window as any;
151
+ const badParams: any = {...params};
152
+ delete badParams[k];
153
+ try {
154
+ w.ep_webrtc.settingToCheckbox(badParams);
155
+ return {threw: false, msg: ''};
156
+ } catch (e: any) {
157
+ return {threw: true, msg: String(e && (e.message || e))};
158
+ }
159
+ }, {params, k});
160
+ expect(result.threw).toBe(true);
161
+ expect(result.msg).toMatch(new RegExp(k));
162
+ });
163
+ }
164
+ });
165
+ });
@@ -0,0 +1,69 @@
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
+ await setPadPrefsCookie(sharedPage, padPrefs);
22
+ const params: Record<string, any> = {};
23
+ if (queryVal != null) params.av = queryVal;
24
+ await goToNewPadWithParams(sharedPage, params);
25
+ // Normalize queryVal to null/false/true.
26
+ const queryNorm: boolean | null =
27
+ typeof queryVal === 'boolean' ? queryVal
28
+ : queryVal === 'NO' ? false
29
+ : queryVal === 'YES' ? true
30
+ : null;
31
+ await sharedPage.waitForFunction(() => {
32
+ const w = window as any;
33
+ return w.$('#rtcbox').data('initialized');
34
+ }, undefined, {timeout: 5000});
35
+ const defaultChecked: boolean = await sharedPage.evaluate(
36
+ () => !!(window as any).ep_webrtc._settings.enabled);
37
+ wantChecked = !!(queryNorm || (queryNorm == null && cookieVal) ||
38
+ (queryNorm == null && cookieVal == null && defaultChecked));
39
+ });
40
+
41
+ test.afterAll(async () => {
42
+ await sharedPage.close();
43
+ });
44
+
45
+ test('checkbox is checked/unchecked', async () => {
46
+ const checked = await sharedPage.locator('#options-enablertc')
47
+ .evaluate((el) => (el as HTMLInputElement).checked);
48
+ expect(checked).toBe(wantChecked);
49
+ });
50
+
51
+ test('self video element', async () => {
52
+ const count = await sharedPage.locator('#rtcbox video').count();
53
+ expect(count).toBe(wantChecked ? 1 : 0);
54
+ });
55
+
56
+ test('clicking checkbox toggles state', async () => {
57
+ await sharedPage.locator('#options-enablertc').evaluate((el) => {
58
+ (window as any).$(el).click();
59
+ });
60
+ const checked = await sharedPage.locator('#options-enablertc')
61
+ .evaluate((el) => (el as HTMLInputElement).checked);
62
+ expect(checked).toBe(!wantChecked);
63
+ await sharedPage.waitForFunction((expected) => {
64
+ return document.querySelectorAll('#rtcbox video').length === expected;
65
+ }, wantChecked ? 0 : 1);
66
+ });
67
+ });
68
+ }
69
+ });
@@ -0,0 +1,92 @@
1
+ import {expect, test} from '@playwright/test';
2
+ import {goToNewPadWithParams} from '../helper/utils';
3
+
4
+ const testCases: Array<[string, string]> = [
5
+ // Hard to test the version of NotAllowedError that is the SSL error
6
+ // because it requires changing window.location
7
+ ['NotAllowedError', 'Failed to get permission to access'],
8
+ ['NotFoundError', 'Failed to access'],
9
+ ['NotReadableError', 'hardware error occurred'],
10
+ ['AbortError', 'not a hardware error'],
11
+ ];
12
+
13
+ test.describe('error handling', () => {
14
+ test.describe.configure({mode: 'serial'});
15
+ let sharedPage: import('@playwright/test').Page;
16
+ let videoBtnSelector: string;
17
+
18
+ test.beforeAll(async ({browser}) => {
19
+ sharedPage = await browser.newPage();
20
+ test.setTimeout(60_000);
21
+ await goToNewPadWithParams(sharedPage, {
22
+ av: true,
23
+ webrtcaudioenabled: false,
24
+ webrtcvideoenabled: false,
25
+ });
26
+ await sharedPage.waitForFunction(
27
+ () => (window as any).$('#rtcbox').data('initialized'));
28
+ const ownInterfaceId: string = await sharedPage.evaluate(() => {
29
+ const w = window as any;
30
+ const ownUserId = w.ep_webrtc.getUserId();
31
+ const ownVideoId = `video_${ownUserId.replace(/\./g, '_')}`;
32
+ return `interface_${ownVideoId}`;
33
+ });
34
+ videoBtnSelector = `#${ownInterfaceId} .video-btn`;
35
+ // Save the original getUserMedia for restore in afterAll.
36
+ await sharedPage.evaluate(() => {
37
+ const w = window as any;
38
+ w.__getUserMediaBackup = w.navigator.mediaDevices.getUserMedia;
39
+ });
40
+ });
41
+
42
+ test.afterAll(async () => {
43
+ await sharedPage.evaluate(() => {
44
+ const w = window as any;
45
+ w.navigator.mediaDevices.getUserMedia = w.__getUserMediaBackup;
46
+ });
47
+ await sharedPage.close();
48
+ });
49
+
50
+ test.beforeEach(async () => {
51
+ // No idea why but this needs to be called twice to actually make
52
+ // #gritter-container hidden.
53
+ await sharedPage.evaluate(() => {
54
+ const w = window as any;
55
+ w.gritter.removeAll({fade: false});
56
+ w.gritter.removeAll({fade: false});
57
+ });
58
+ const off = await sharedPage.locator(videoBtnSelector).evaluate(
59
+ (el) => el.classList.contains('off'));
60
+ expect(off).toBe(true);
61
+ });
62
+
63
+ for (const [errName, checkString] of testCases) {
64
+ test(errName, async () => {
65
+ await sharedPage.evaluate((errName) => {
66
+ const w = window as any;
67
+ w.navigator.mediaDevices.getUserMedia = async () => {
68
+ const err: any = new Error();
69
+ err.name = errName;
70
+ throw err;
71
+ };
72
+ }, errName);
73
+ await sharedPage.waitForFunction(() => {
74
+ const w = window as any;
75
+ return w.$('#gritter-container:visible').length === 0;
76
+ }, undefined, {timeout: 1000});
77
+ await sharedPage.locator(videoBtnSelector).evaluate((el) => {
78
+ (window as any).$(el).click();
79
+ });
80
+ await sharedPage.waitForFunction(() => {
81
+ const w = window as any;
82
+ return w.$('#gritter-container:visible').length === 1;
83
+ }, undefined, {timeout: 1000});
84
+ const titleHtml = await sharedPage.evaluate(
85
+ () => (window as any).$('.gritter-title').html());
86
+ expect(titleHtml).toBe('Error');
87
+ const contentHtml = await sharedPage.evaluate(
88
+ () => (window as any).$('.gritter-content p').html());
89
+ expect(contentHtml).toContain(checkString);
90
+ });
91
+ }
92
+ });