Skip to content

Commit 8fc6c40

Browse files
Merge origin/master into e2e/05-downloads
Resolve harness/hook conflicts by keeping merged #3855 changes. Co-authored-by: Cursor <cursoragent@cursor.com>
2 parents 37a6356 + 08129c4 commit 8fc6c40

17 files changed

Lines changed: 157 additions & 121 deletions
Lines changed: 9 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,44 +1,21 @@
11
name: CMT Provisioner
22

3-
# Lightweight trigger workflow for Compatibility Matrix Testing (CMT).
4-
#
5-
# Matterwick listens for this workflow's workflow_run:requested event, then provisions one
6-
# Mattermost cloud instance per version in its hardcoded CMT version set (Matterwick's
7-
# CMTServerVersions config / CMTVersions, e.g. the active ESR plus the current feature
8-
# release) and dispatches compatibility-matrix-testing.yml with the CMT_MATRIX input.
9-
# When compatibility-matrix-testing.yml completes, Matterwick destroys the provisioned
10-
# instances (cleanup is keyed off the completed workflow_run, matched by commit SHA).
11-
#
12-
# The server-version set lives in Matterwick config (managed via gitops), so this workflow
13-
# takes no inputs. Run it manually from the Actions tab, or let it fire automatically on
14-
# release branches (below).
15-
#
16-
# NOTE (intentional duplicate coverage): a release-v* push ALSO triggers Matterwick's normal
17-
# release E2E flow (whole suite against the single latest server version). CMT additionally
18-
# runs the whole suite against its multi-version matrix, whose set includes the latest server.
19-
# So on a release push the latest server version gets the suite run twice (once by the normal
20-
# release flow, once by the CMT latest-version leg). This is expected and accepted.
3+
# Lightweight signal workflow for Compatibility Matrix Testing (CMT).
4+
# When this runs, matterwick detects the workflow_run:requested event, provisions one
5+
# cloud server per version in its CMT set, and dispatches compatibility-matrix-testing.yml.
6+
# Triggers: RC tag pushes (e.g. v6.2.2-rc.1) and manual workflow_dispatch.
217

228
on:
23-
# Run CMT when a release branch is cut and on every subsequent push to it. The first push
24-
# that creates release-v* is the "cut"; later pushes to it re-run CMT.
259
push:
26-
branches:
27-
- "release-v*"
28-
# Manual runs from the Actions tab (against any branch).
10+
tags:
11+
- "v[0-9]+.[0-9]+.[0-9]+-rc.[0-9]+"
2912
workflow_dispatch: {}
3013

31-
# This workflow does not use GITHUB_TOKEN. Its only purpose is to emit a workflow_run event
32-
# that Matterwick reacts to; it does not check out the repo or call the GitHub API. Drop all
33-
# GITHUB_TOKEN scopes so the runner-issued token cannot modify the repo if a step is added later.
3414
permissions: {}
3515

3616
jobs:
37-
signal:
17+
trigger-matterwick:
3818
runs-on: ubuntu-22.04
3919
steps:
40-
- name: Signal Matterwick to provision CMT servers
41-
run: |
42-
echo "CMT trigger started."
43-
echo "Matterwick provisions one server per configured version and then"
44-
echo "dispatches compatibility-matrix-testing.yml automatically."
20+
- name: Signal matterwick
21+
run: echo "CMT provisioning requested — matterwick will handle provisioning and dispatch."

.github/workflows/compatibility-matrix-testing.yml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,8 @@ on:
1313

1414
# Supersede an in-flight CMT run for the same ref (e.g. rapid release-branch pushes) so we don't
1515
# pay for overlapping multi-version matrices. Matterwick reaps the cancelled run's provisioned
16-
# servers on its workflow_run "completed" (cancelled) event, matched by commit SHA.
16+
# servers on its workflow_run "completed" (cancelled) event, matched by the dispatched test
17+
# workflow run id (not commit SHA).
1718
concurrency:
1819
group: ${{ github.workflow }}-${{ github.ref }}
1920
cancel-in-progress: true

.github/workflows/e2e-functional-template.yml

Lines changed: 7 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -243,13 +243,7 @@ jobs:
243243

244244
- name: e2e/use-gnu-tar-macos
245245
if: runner.os == 'macOS'
246-
run: |
247-
# BSD tar on macOS can corrupt actions/cache v5 archives; prefer GNU tar when present.
248-
if [ -x /opt/homebrew/opt/gnu-tar/libexec/gnubin/gtar ]; then
249-
echo "/opt/homebrew/opt/gnu-tar/libexec/gnubin" >> $GITHUB_PATH
250-
elif [ -x /usr/local/opt/gnu-tar/libexec/gnubin/gtar ]; then
251-
echo "/usr/local/opt/gnu-tar/libexec/gnubin" >> $GITHUB_PATH
252-
fi
246+
run: echo "/opt/homebrew/opt/gnu-tar/libexec/gnubin" >> $GITHUB_PATH
253247

254248
- name: e2e/cache-node-modules
255249
id: cache-node-modules
@@ -272,25 +266,18 @@ jobs:
272266
restore-keys: |
273267
${{ runner.os }}-electron-gyp-v7-${{ hashFiles('**/package-lock.json') }}
274268
275-
# Clear pip's HTTP cache on macOS-26 runners before setup-python. The pre-baked
276-
# image cache contains entries serialized by an older pip that the newer pip
277-
# shipped with Python 3.10 cannot deserialize, producing the noisy
278-
# "WARNING: Cache entry deserialization failed, entry ignored" lines in CI.
279-
# See actions/setup-python#1317.
280-
- name: e2e/clear-pip-cache-macos
281-
if: runner.os == 'macOS'
282-
run: |
283-
rm -rf "${HOME}/Library/Caches/pip" || true
284-
python3 -m pip cache purge >/dev/null 2>&1 || true
285-
286269
- name: e2e/setup-python
287270
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
288271
env:
289272
# Bypass pip's on-disk HTTP cache entirely so a stale/corrupt entry on the
290-
# runner image cannot trigger deserialization warnings.
273+
# runner image cannot trigger deserialization warnings (actions/setup-python#1317).
291274
PIP_NO_CACHE_DIR: "1"
292275
with:
293-
python-version: "3.10"
276+
# Use a version pre-installed on macOS-26 (3.13 is in its toolcache), so
277+
# setup-python doesn't install a mismatched interpreter whose pip can't
278+
# deserialize the image's pre-baked cache. PIP_NO_CACHE_DIR above is the
279+
# remaining guard for non-macOS runners.
280+
python-version: "3.13"
294281
# Omit `cache` — setup-python only accepts pip/pipenv/poetry, not `false`.
295282

296283
- name: e2e/install-os-dependencies

.github/workflows/e2e-functional.yml

Lines changed: 8 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -247,10 +247,10 @@ jobs:
247247
if: runner.os == 'macOS'
248248
run: |
249249
# BSD tar on macOS can corrupt actions/cache v5 archives; prefer GNU tar when present.
250+
# All current macOS runners are arm64 (Homebrew under /opt/homebrew); keep the
251+
# -x guard so a future non-arm64 matrix entry falls back to BSD tar safely.
250252
if [ -x /opt/homebrew/opt/gnu-tar/libexec/gnubin/gtar ]; then
251253
echo "/opt/homebrew/opt/gnu-tar/libexec/gnubin" >> $GITHUB_PATH
252-
elif [ -x /usr/local/opt/gnu-tar/libexec/gnubin/gtar ]; then
253-
echo "/usr/local/opt/gnu-tar/libexec/gnubin" >> $GITHUB_PATH
254254
fi
255255
256256
- name: e2e/cache-node-modules
@@ -274,25 +274,18 @@ jobs:
274274
restore-keys: |
275275
${{ runner.os }}-electron-gyp-v7-${{ hashFiles('**/package-lock.json') }}
276276
277-
# Clear pip's HTTP cache on macOS-26 runners before setup-python. The pre-baked
278-
# image cache contains entries serialized by an older pip that the newer pip
279-
# shipped with Python 3.10 cannot deserialize, producing the noisy
280-
# "WARNING: Cache entry deserialization failed, entry ignored" lines in CI.
281-
# See actions/setup-python#1317.
282-
- name: e2e/clear-pip-cache-macos
283-
if: runner.os == 'macOS'
284-
run: |
285-
rm -rf "${HOME}/Library/Caches/pip" || true
286-
python3 -m pip cache purge >/dev/null 2>&1 || true
287-
288277
- name: e2e/setup-python
289278
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
290279
env:
291280
# Bypass pip's on-disk HTTP cache entirely so a stale/corrupt entry on the
292-
# runner image cannot trigger deserialization warnings.
281+
# runner image cannot trigger deserialization warnings (actions/setup-python#1317).
293282
PIP_NO_CACHE_DIR: "1"
294283
with:
295-
python-version: "3.10"
284+
# Use a version pre-installed on macOS-26 (3.13 is in its toolcache), so
285+
# setup-python doesn't install a mismatched interpreter whose pip can't
286+
# deserialize the image's pre-baked cache. PIP_NO_CACHE_DIR above is the
287+
# remaining guard for non-macOS runners.
288+
python-version: "3.13"
296289
# Omit `cache` — setup-python only accepts pip/pipenv/poetry, not `false`.
297290

298291
- name: e2e/install-os-dependencies

e2e/fixtures/index.ts

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -61,10 +61,20 @@ type WorkerFixtures = {
6161
export const test = base.extend<Fixtures, WorkerFixtures>({
6262
workerElectronCleanup: [async ({}, use) => {
6363
await use();
64-
await Promise.race([
65-
cleanupRegisteredElectronProcesses(),
66-
new Promise<void>((resolve) => setTimeout(resolve, 20_000)),
67-
]);
64+
let timeoutHandle: NodeJS.Timeout | undefined;
65+
try {
66+
await Promise.race([
67+
cleanupRegisteredElectronProcesses(),
68+
new Promise<void>((resolve) => {
69+
timeoutHandle = setTimeout(resolve, 20_000);
70+
timeoutHandle.unref?.();
71+
}),
72+
]);
73+
} finally {
74+
if (timeoutHandle) {
75+
clearTimeout(timeoutHandle);
76+
}
77+
}
6878
}, {scope: 'worker'}],
6979

7080
appConfig: async ({}, use) => {

e2e/helpers/cleanup.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ export async function waitForLockFileRelease(userDataDir: string): Promise<void>
3131
},
3232
).toBe(true);
3333
} catch {
34-
if (fs.existsSync(lockFile)) {
34+
if (process.platform === 'win32' && fs.existsSync(lockFile)) {
3535
try {
3636
fs.unlinkSync(lockFile);
3737
} catch {

e2e/helpers/electronApp.ts

Lines changed: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,17 @@ async function reapPids(pids: number[]): Promise<void> {
180180
}
181181
await sleep(200);
182182
}
183+
184+
// SIGTERM alone can leave a blocked macOS process alive past the poll,
185+
// leaking orphans across runs. Linux already SIGKILL'd above; Windows used
186+
// taskkill /F. Escalate survivors on macOS to SIGKILL.
187+
if (process.platform === 'darwin') {
188+
for (const pid of alive) {
189+
if (isProcessAlive(pid)) {
190+
signalProcessGroup(pid, 'SIGKILL');
191+
}
192+
}
193+
}
183194
}
184195

185196
function sleep(ms: number) {
@@ -227,10 +238,14 @@ function forceKillLinuxProcessTree(pid: number): void {
227238
signalProcessGroup(pid, 'SIGKILL');
228239
}
229240

230-
async function drainPlaywrightClose(closePromise: Promise<void>): Promise<void> {
241+
async function drainPlaywrightClose(closePromise: Promise<void>, remainingMs: number): Promise<void> {
231242
// Playwright keeps a gracefullyClose entry until app.close() settles (#29431).
232-
// Cap the wait so worker teardown does not hang for 90s when close never settles.
233-
await Promise.race([closePromise, sleep(3_000)]);
243+
// Drain within the remaining budget so teardown never exceeds the caller's
244+
// timeout (previously +3s on top of the race budget).
245+
if (remainingMs <= 0) {
246+
return;
247+
}
248+
await Promise.race([closePromise, sleep(remainingMs)]);
234249
}
235250

236251
async function forceShutdownLinux(pid: number): Promise<void> {
@@ -266,11 +281,15 @@ function signalShutdownAndReturn(pid: number): void {
266281

267282
async function attemptClose(app: ElectronApplication, timeoutMs: number): Promise<boolean> {
268283
let closed = false;
269-
const closePromise = app.close().catch(() => {}).then(() => {
284+
285+
// Only mark closed on successful resolve; a rejected close() must leave
286+
// closed=false so the caller's SIGTERM/SIGKILL fallback runs.
287+
const closePromise = app.close().then(() => {
270288
closed = true;
271-
});
289+
}, () => {});
290+
const start = Date.now();
272291
await Promise.race([closePromise, sleep(timeoutMs)]);
273-
await drainPlaywrightClose(closePromise);
292+
await drainPlaywrightClose(closePromise, timeoutMs - (Date.now() - start));
274293
return closed;
275294
}
276295

e2e/helpers/login.ts

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,35 +3,47 @@
33

44
import {expect} from '@playwright/test';
55

6+
import {isTransientEvaluateError} from './testRefs';
67
import type {ServerView} from './serverView';
78

89
async function isMattermostServerUrl(win: ServerView): Promise<boolean> {
910
const url = await win.url().catch(() => '');
1011
return url.startsWith('http://') || url.startsWith('https://');
1112
}
1213

14+
async function runRendererProbe(win: ServerView, body: string): Promise<boolean> {
15+
try {
16+
return await win.runInRenderer<boolean>(body);
17+
} catch (error) {
18+
if (isTransientEvaluateError(error)) {
19+
return false;
20+
}
21+
throw error;
22+
}
23+
}
24+
1325
async function hasAppShell(win: ServerView): Promise<boolean> {
1426
if (!(await isMattermostServerUrl(win))) {
1527
return false;
1628
}
1729

18-
return win.runInRenderer<boolean>(`
30+
return runRendererProbe(win, `
1931
return Boolean(
2032
document.querySelector('#post_textbox')
2133
|| document.querySelector('#channelHeaderTitle')
2234
|| document.querySelector('input.search-bar.form-control'),
2335
);
24-
`).catch(() => false);
36+
`);
2537
}
2638

2739
async function hasLoginForm(win: ServerView): Promise<boolean> {
2840
if (!(await isMattermostServerUrl(win))) {
2941
return false;
3042
}
3143

32-
return win.runInRenderer<boolean>(`
44+
return runRendererProbe(win, `
3345
return Boolean(document.querySelector('#input_loginId'));
34-
`).catch(() => false);
46+
`);
3547
}
3648

3749
/**

e2e/helpers/notificationEffects.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,11 @@ import type {ElectronApplication} from 'playwright';
1010
* often emits `failed` without `show`), so flash_taskbar and dock_bounce tests
1111
* exercise the same flashFrame() gate the notification `show` handler calls.
1212
*/
13-
export async function triggerFlashEffects(app: ElectronApplication, flash = true): Promise<void> {
13+
export async function triggerNotificationEffects(app: ElectronApplication, flash = true): Promise<void> {
1414
await app.evaluate((_, shouldFlash: boolean) => {
15-
const trigger = (global as any).__e2eFlashEffects as ((value: boolean) => void) | undefined;
15+
const trigger = (global as any).__e2eNotificationEffects as ((value: boolean) => void) | undefined;
1616
if (!trigger) {
17-
throw new Error('__e2eFlashEffects not exposed (NODE_ENV must be test)');
17+
throw new Error('__e2eNotificationEffects not exposed (NODE_ENV must be test)');
1818
}
1919
trigger(shouldFlash);
2020
}, flash);

e2e/helpers/testRefs.ts

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -82,11 +82,7 @@ export async function getMainWindowId(app: ElectronApplication): Promise<number>
8282
});
8383
return mainWindowId;
8484
} catch (error) {
85-
const message = error instanceof Error ? error.message : String(error);
86-
if (
87-
message.includes('Execution context was destroyed') ||
88-
message.includes('Target page, context or browser has been closed')
89-
) {
85+
if (isTransientEvaluateError(error)) {
9086
return null;
9187
}
9288
throw error;
@@ -104,7 +100,7 @@ export async function getMainWindowId(app: ElectronApplication): Promise<number>
104100
}
105101

106102
export async function getActiveServerWebContentsId(app: ElectronApplication): Promise<number> {
107-
const id = await app.evaluate(() => {
103+
const id = await evaluateInMainProcess(app, () => {
108104
const refs = (global as any).__e2eTestRefs;
109105
const view = refs?.TabManager?.getCurrentActiveTabView?.();
110106
return view?.webContentsId ?? null;

0 commit comments

Comments
 (0)