@element-hq/element-web-playwright-common 2.4.0 → 3.1.0

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.
Files changed (50) hide show
  1. package/Dockerfile +14 -8
  2. package/README.md +2 -2
  3. package/docker-entrypoint.sh +1 -8
  4. package/lib/expect/axe.d.ts +1 -1
  5. package/lib/expect/axe.d.ts.map +1 -1
  6. package/lib/expect/screenshot.d.ts +1 -1
  7. package/lib/expect/screenshot.d.ts.map +1 -1
  8. package/lib/fixtures/axe.d.ts +2 -2
  9. package/lib/fixtures/axe.d.ts.map +1 -1
  10. package/lib/fixtures/services.d.ts.map +1 -1
  11. package/lib/fixtures/services.js +2 -22
  12. package/lib/fixtures/toasts.d.ts +64 -0
  13. package/lib/fixtures/toasts.d.ts.map +1 -0
  14. package/lib/fixtures/toasts.js +97 -0
  15. package/lib/fixtures/user.d.ts +13 -2
  16. package/lib/fixtures/user.d.ts.map +1 -1
  17. package/lib/fixtures/user.js +4 -1
  18. package/lib/flaky-reporter.d.ts +24 -0
  19. package/lib/flaky-reporter.d.ts.map +1 -0
  20. package/lib/flaky-reporter.js +153 -0
  21. package/lib/index.d.ts +11 -0
  22. package/lib/index.d.ts.map +1 -1
  23. package/lib/stale-screenshot-reporter.d.ts +1 -0
  24. package/lib/stale-screenshot-reporter.d.ts.map +1 -1
  25. package/lib/stale-screenshot-reporter.js +9 -4
  26. package/lib/testcontainers/index.d.ts +2 -1
  27. package/lib/testcontainers/index.d.ts.map +1 -1
  28. package/lib/testcontainers/index.js +2 -1
  29. package/lib/testcontainers/mas.d.ts +5 -2
  30. package/lib/testcontainers/mas.d.ts.map +1 -1
  31. package/lib/testcontainers/mas.js +14 -2
  32. package/lib/testcontainers/postgres.d.ts +5 -0
  33. package/lib/testcontainers/postgres.d.ts.map +1 -0
  34. package/lib/testcontainers/postgres.js +31 -0
  35. package/lib/testcontainers/synapse.d.ts +6 -0
  36. package/lib/testcontainers/synapse.d.ts.map +1 -1
  37. package/lib/testcontainers/synapse.js +17 -1
  38. package/package.json +10 -11
  39. package/playwright-screenshots.sh +33 -126
  40. package/project.json +44 -0
  41. package/src/fixtures/services.ts +3 -22
  42. package/src/fixtures/toasts.ts +109 -0
  43. package/src/fixtures/user.ts +4 -1
  44. package/src/flaky-reporter.ts +188 -0
  45. package/src/stale-screenshot-reporter.ts +9 -5
  46. package/src/testcontainers/index.ts +2 -0
  47. package/src/testcontainers/mas.ts +21 -0
  48. package/src/testcontainers/postgres.ts +40 -0
  49. package/src/testcontainers/synapse.ts +24 -1
  50. package/tsconfig.json +8 -3
@@ -0,0 +1,188 @@
1
+ /*
2
+ Copyright 2024 New Vector Ltd.
3
+ Copyright 2024 The Matrix.org Foundation C.I.C.
4
+
5
+ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
6
+ Please see LICENSE files in the repository root for full details.
7
+ */
8
+
9
+ /**
10
+ * Flaky test reporter, creating & updating GitHub issues
11
+ * Only intended to run from within GitHub Actions
12
+ */
13
+
14
+ import type { Reporter, TestCase } from "@playwright/test/reporter";
15
+
16
+ const REPO = "element-hq/element-web";
17
+ const LABEL = "Z-Flaky-Test";
18
+ const ISSUE_TITLE_PREFIX = "Flaky playwright test: ";
19
+
20
+ type PaginationLinks = {
21
+ prev?: string;
22
+ next?: string;
23
+ last?: string;
24
+ first?: string;
25
+ };
26
+
27
+ const ANSI_COLOUR_REGEX = /[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g;
28
+
29
+ // We see quite a few test flakes which are caused by the app exploding
30
+ // so we have some magic strings we check the logs for to better track the flake with its cause
31
+ const SPECIAL_CASES: Record<string, string> = {
32
+ "ChunkLoadError": "ChunkLoadError",
33
+ "Unreachable code should not be executed": "Rust crypto panic",
34
+ "Out of bounds memory access": "Rust crypto memory error",
35
+ };
36
+
37
+ class FlakyReporter implements Reporter {
38
+ private flakes = new Map<string, TestCase[]>();
39
+
40
+ public onTestEnd(test: TestCase): void {
41
+ // Ignores flakes on Dendrite and Pinecone as they have their own flakes we do not track
42
+ if (["Dendrite", "Pinecone"].includes(test.parent.project()!.name!)) return;
43
+
44
+ if (test.outcome() === "flaky") {
45
+ const failures: string[] = [];
46
+
47
+ const timedOutRuns = test.results.filter((result) => result.status === "timedOut");
48
+ const pageLogs = timedOutRuns.flatMap((result) =>
49
+ result.attachments.filter((attachment) => attachment.name.startsWith("page-")),
50
+ );
51
+
52
+ // If a test failed due to a systemic fault then the test is not flaky, the app is, record it as such.
53
+ const specialCases = Object.keys(SPECIAL_CASES).filter((log) =>
54
+ pageLogs.some((attachment) => attachment.name.startsWith("page-") && attachment.body?.includes(log)),
55
+ );
56
+ if (specialCases.length > 0) {
57
+ failures.push(...specialCases.map((specialCase) => SPECIAL_CASES[specialCase]));
58
+ }
59
+
60
+ // Check for fixtures failing to set up
61
+ const errorMessages = timedOutRuns
62
+ .map((r) => r.error?.message?.replace(ANSI_COLOUR_REGEX, ""))
63
+ .filter(Boolean) as string[];
64
+ for (const error of errorMessages) {
65
+ if (error.startsWith("Fixture") && error.endsWith("exceeded during setup.")) {
66
+ failures.push(error);
67
+ }
68
+ }
69
+
70
+ if (failures.length < 1) {
71
+ failures.push(`${test.location.file.split("playwright/e2e/")[1]}: ${test.title}`);
72
+ }
73
+
74
+ for (const title of failures) {
75
+ if (!this.flakes.has(title)) {
76
+ this.flakes.set(title, []);
77
+ }
78
+ this.flakes.get(title)!.push(test);
79
+ }
80
+ }
81
+ }
82
+
83
+ /**
84
+ * Parse link header to retrieve pagination links
85
+ * @see https://docs.github.com/en/rest/using-the-rest-api/using-pagination-in-the-rest-api?apiVersion=2022-11-28#using-link-headers
86
+ * @param link link header from response or undefined
87
+ * @returns an empty object if link is undefined otherwise returns a map from type to link
88
+ */
89
+ private parseLinkHeader(link: string): PaginationLinks {
90
+ /**
91
+ * link looks like:
92
+ * <https://api.github.com/repositories/1300192/issues?page=2>; rel="prev", <https://api.github.com/repositories/1300192/issues?page=4>;
93
+ */
94
+ const map: PaginationLinks = {};
95
+ if (!link) return map;
96
+ const matches = link.matchAll(/(<(?<link>.+?)>; rel="(?<type>.+?)")/g);
97
+ for (const match of matches) {
98
+ const { link, type } = match.groups!;
99
+ map[type as keyof PaginationLinks] = link;
100
+ }
101
+ return map;
102
+ }
103
+
104
+ /**
105
+ * Fetch all flaky test issues that were updated since Jan-1-2024
106
+ * @returns A promise that resolves to a list of issues
107
+ */
108
+ async getAllIssues(): Promise<any[]> {
109
+ const issues = [];
110
+ const { GITHUB_TOKEN, GITHUB_API_URL } = process.env;
111
+ // See https://docs.github.com/en/rest/issues/issues?apiVersion=2022-11-28#list-repository-issues
112
+ let url = `${GITHUB_API_URL}/repos/${REPO}/issues?labels=${LABEL}&state=all&per_page=100&sort=updated&since=2024-01-01`;
113
+ const headers = {
114
+ Authorization: `Bearer ${GITHUB_TOKEN}`,
115
+ Accept: "application / vnd.github + json",
116
+ };
117
+ while (url) {
118
+ // Fetch issues and add to list
119
+ const issuesResponse = await fetch(url, { headers });
120
+ const fetchedIssues = await issuesResponse.json();
121
+ issues.push(...fetchedIssues);
122
+
123
+ // Get the next link for fetching more results
124
+ const linkHeader = issuesResponse.headers.get("Link")!;
125
+ const parsed = this.parseLinkHeader(linkHeader);
126
+ url = parsed.next!;
127
+ }
128
+ return issues;
129
+ }
130
+
131
+ public async onExit(): Promise<void> {
132
+ if (this.flakes.size === 0) {
133
+ console.log("No flakes found");
134
+ return;
135
+ }
136
+
137
+ console.log("Found flakes: ");
138
+ for (const flake of this.flakes) {
139
+ console.log(flake);
140
+ }
141
+
142
+ const { GITHUB_TOKEN, GITHUB_API_URL, GITHUB_SERVER_URL, GITHUB_REPOSITORY, GITHUB_RUN_ID } = process.env;
143
+ if (!GITHUB_TOKEN) return;
144
+
145
+ const issues = await this.getAllIssues();
146
+ for (const [flake, results] of this.flakes) {
147
+ const title = ISSUE_TITLE_PREFIX + "`" + flake + "`";
148
+ const existingIssue = issues.find((issue) => issue.title === title);
149
+ const headers = { Authorization: `Bearer ${GITHUB_TOKEN}` };
150
+ const body = `${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}`;
151
+
152
+ const labels = [LABEL, ...results.map((test) => `${LABEL}-${test.parent.project()?.name}`)];
153
+
154
+ if (existingIssue) {
155
+ console.log(`Found issue ${existingIssue.number} for ${flake}, adding comment...`);
156
+ // Ensure that the test is open
157
+ await fetch(existingIssue.url, {
158
+ method: "PATCH",
159
+ headers,
160
+ body: JSON.stringify({ state: "open" }),
161
+ });
162
+ await fetch(`${existingIssue.url}/labels`, {
163
+ method: "POST",
164
+ headers,
165
+ body: JSON.stringify({ labels }),
166
+ });
167
+ await fetch(`${existingIssue.url}/comments`, {
168
+ method: "POST",
169
+ headers,
170
+ body: JSON.stringify({ body }),
171
+ });
172
+ } else {
173
+ console.log(`Creating new issue for ${flake}...`);
174
+ await fetch(`${GITHUB_API_URL}/repos/${REPO}/issues`, {
175
+ method: "POST",
176
+ headers,
177
+ body: JSON.stringify({
178
+ title,
179
+ body,
180
+ labels: [...labels],
181
+ }),
182
+ });
183
+ }
184
+ }
185
+ }
186
+ }
187
+
188
+ export default FlakyReporter;
@@ -56,11 +56,7 @@ class StaleScreenshotReporter implements Reporter {
56
56
  this.success = false;
57
57
  }
58
58
 
59
- public async onExit(): Promise<void> {
60
- if (this.failing.size) {
61
- console.error(`${this.failing.size} tests failed, skipping stale screenshot reporter.`);
62
- }
63
-
59
+ private async checkStaleScreenshots(): Promise<void> {
64
60
  if (!this.snapshotRoots.size) {
65
61
  this.error("No snapshot directories found, did you set the snapshotDir in your Playwright config?", "");
66
62
  return;
@@ -90,6 +86,14 @@ class StaleScreenshotReporter implements Reporter {
90
86
  this.error("Stale screenshot file", screenshot);
91
87
  }
92
88
  }
89
+ }
90
+
91
+ public async onExit(): Promise<void> {
92
+ if (this.failing.size) {
93
+ this.error(`${this.failing.size} tests failed, skipping stale screenshot reporter.`, "");
94
+ } else {
95
+ await this.checkStaleScreenshots();
96
+ }
93
97
 
94
98
  if (!this.success) {
95
99
  process.exit(1);
@@ -6,11 +6,13 @@ Please see LICENSE files in the repository root for full details.
6
6
  */
7
7
 
8
8
  export { PostgreSqlContainer, StartedPostgreSqlContainer } from "@testcontainers/postgresql";
9
+ export { makePostgres } from "./postgres.js";
9
10
  export type { HomeserverInstance, HomeserverContainer, StartedHomeserverContainer } from "./HomeserverContainer.js";
10
11
  export { type SynapseConfig, SynapseContainer, StartedSynapseContainer } from "./synapse.js";
11
12
  export {
12
13
  type MasConfig,
13
14
  MatrixAuthenticationServiceContainer,
14
15
  StartedMatrixAuthenticationServiceContainer,
16
+ makeMas,
15
17
  } from "./mas.js";
16
18
  export { type MailpitClient, MailpitContainer, StartedMailpitContainer } from "./mailpit.js";
@@ -11,6 +11,7 @@ import {
11
11
  type StartedTestContainer,
12
12
  Wait,
13
13
  type ExecResult,
14
+ type StartedNetwork,
14
15
  } from "testcontainers";
15
16
  import { type StartedPostgreSqlContainer } from "@testcontainers/postgresql";
16
17
  import * as YAML from "yaml";
@@ -23,6 +24,7 @@ import { type Credentials } from "../utils/api.js";
23
24
  // curl -sL https://element-hq.github.io/matrix-authentication-service/config.schema.json \
24
25
  // | npx json-schema-to-typescript -o packages/element-web-playwright-common/src/testconainers/mas-config.ts
25
26
  import type { RootConfig as MasConfig } from "./mas-config.js";
27
+ import type { Logger } from "../utils/logger.js";
26
28
 
27
29
  export { type MasConfig };
28
30
 
@@ -156,6 +158,7 @@ export class MatrixAuthenticationServiceContainer extends GenericContainer {
156
158
  super(image);
157
159
 
158
160
  const initialConfig = deepCopy(DEFAULT_CONFIG);
161
+ initialConfig.database.host = db.getHostname();
159
162
  initialConfig.database.username = db.getUsername();
160
163
  initialConfig.database.password = db.getPassword();
161
164
 
@@ -205,6 +208,7 @@ export class MatrixAuthenticationServiceContainer extends GenericContainer {
205
208
  await super.start(),
206
209
  `http://localhost:${port}`,
207
210
  this.args,
211
+ this.config.matrix.secret,
208
212
  );
209
213
  }
210
214
  }
@@ -219,6 +223,7 @@ export class StartedMatrixAuthenticationServiceContainer extends AbstractStarted
219
223
  container: StartedTestContainer,
220
224
  public readonly baseUrl: string,
221
225
  private readonly args: string[],
226
+ public readonly sharedSecret: string,
222
227
  ) {
223
228
  super(container);
224
229
  }
@@ -346,3 +351,19 @@ export class StartedMatrixAuthenticationServiceContainer extends AbstractStarted
346
351
  await this.manage("add-email", username, address);
347
352
  }
348
353
  }
354
+
355
+ export async function makeMas(
356
+ postgres: StartedPostgreSqlContainer,
357
+ network: StartedNetwork,
358
+ logger: Logger,
359
+ config: Partial<MasConfig>,
360
+ name = "mas",
361
+ ): Promise<StartedMatrixAuthenticationServiceContainer> {
362
+ const container = await new MatrixAuthenticationServiceContainer(postgres)
363
+ .withNetwork(network)
364
+ .withNetworkAliases(name)
365
+ .withLogConsumer(logger.getConsumer(name))
366
+ .withConfig(config)
367
+ .start();
368
+ return container;
369
+ }
@@ -0,0 +1,40 @@
1
+ /*
2
+ Copyright 2026 Element Creations Ltd.
3
+
4
+ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
5
+ Please see LICENSE files in the repository root for full details.
6
+ */
7
+
8
+ import { PostgreSqlContainer, type StartedPostgreSqlContainer } from "@testcontainers/postgresql";
9
+ import { type StartedNetwork } from "testcontainers";
10
+
11
+ import { type Logger } from "../utils/logger.js";
12
+
13
+ export async function makePostgres(
14
+ network: StartedNetwork,
15
+ logger: Logger,
16
+ name = "postgres",
17
+ ): Promise<StartedPostgreSqlContainer> {
18
+ const container = await new PostgreSqlContainer("postgres:13.3-alpine")
19
+ .withNetwork(network)
20
+ .withNetworkAliases(name)
21
+ .withLogConsumer(logger.getConsumer(name))
22
+ .withTmpFs({
23
+ "/dev/shm/pgdata/data": "",
24
+ })
25
+ .withEnvironment({
26
+ PG_DATA: "/dev/shm/pgdata/data",
27
+ })
28
+ .withCommand([
29
+ "-c",
30
+ "shared_buffers=128MB",
31
+ "-c",
32
+ `fsync=off`,
33
+ "-c",
34
+ `synchronous_commit=off`,
35
+ "-c",
36
+ "full_page_writes=off",
37
+ ])
38
+ .start();
39
+ return container;
40
+ }
@@ -184,6 +184,14 @@ const DEFAULT_CONFIG = {
184
184
  },
185
185
  room_list_publication_rules: [{ action: "allow" }],
186
186
  modules: [] as Array<{ module: string; config?: Record<string, unknown> }>,
187
+ matrix_authentication_service: undefined as
188
+ | undefined
189
+ | {
190
+ enabled?: boolean;
191
+ endpoint?: string;
192
+ secret?: string | null;
193
+ secret_path?: string | null;
194
+ },
187
195
  };
188
196
 
189
197
  /**
@@ -278,7 +286,22 @@ export class SynapseContainer extends GenericContainer implements HomeserverCont
278
286
  }
279
287
 
280
288
  public withMatrixAuthenticationService(mas?: StartedMatrixAuthenticationServiceContainer): this {
281
- this.mas = mas;
289
+ if (mas) {
290
+ this.mas = mas;
291
+ this.withConfig({
292
+ matrix_authentication_service: {
293
+ enabled: true,
294
+ endpoint: `http://${mas.getHostname()}:8080/`,
295
+ secret: mas.sharedSecret,
296
+ },
297
+ // Must be disabled when using MAS.
298
+ password_config: {
299
+ enabled: false,
300
+ },
301
+ // Must be disabled when using MAS.
302
+ enable_registration: false,
303
+ });
304
+ }
282
305
  return this;
283
306
  }
284
307
 
package/tsconfig.json CHANGED
@@ -1,11 +1,16 @@
1
1
  {
2
2
  "$schema": "http://json.schemastore.org/tsconfig",
3
- "extends": "../../tsconfig.json",
4
3
  "compilerOptions": {
4
+ "target": "esnext",
5
+ "lib": ["dom", "es2022", "esnext"],
6
+ "esModuleInterop": true,
7
+ "strict": true,
8
+ "declaration": true,
5
9
  "outDir": "lib",
6
10
  "declarationMap": true,
7
- "module": "node16",
8
- "moduleResolution": "node16",
11
+ "module": "es2022",
12
+ "moduleResolution": "bundler",
13
+ "types": [],
9
14
  "allowImportingTsExtensions": false
10
15
  },
11
16
  "include": ["src"]