Skip to content

Commit 79372f1

Browse files
fix(ci): harden macOS release E2E for GitHub runners
Keep the agent host alive without windows, install FFmpeg in CI, capture host logs on failure, and relax ATS so WKWebView can load fixture HTTP. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 5291291 commit 79372f1

5 files changed

Lines changed: 70 additions & 19 deletions

File tree

‎.github/workflows/release.yml‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,14 +22,16 @@ jobs:
2222
runs-on: macos-latest
2323
steps:
2424
- uses: actions/checkout@v4
25+
- name: Install FFmpeg
26+
run: brew install ffmpeg
2527
- name: Build
2628
env:
2729
HEADLESS_VERSION: ${{ needs.version.outputs.version }}
2830
run: ./apps/headless/build.sh
2931
- name: Unit tests
3032
run: ./apps/headless/test.sh
3133
- name: E2E
32-
run: ./apps/headless/Tests/macos-e2e.sh
34+
run: zsh ./apps/headless/Tests/macos-e2e.sh
3335
- name: Package
3436
env:
3537
VERSION: ${{ needs.version.outputs.version }}

‎apps/headless/Sources/HeadlessCLI/main.swift‎

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,8 +38,16 @@ private struct HostLauncher {
3838
environment["HEADLESS_AGENT_HOST"] = "1"
3939
process.environment = environment
4040
process.standardInput = FileHandle.nullDevice
41-
process.standardOutput = FileHandle.nullDevice
42-
process.standardError = FileHandle.nullDevice
41+
if let hostLog = environment["HEADLESS_HOST_LOG"], hostLog.hasPrefix("/") {
42+
let logURL = URL(fileURLWithPath: hostLog)
43+
FileManager.default.createFile(atPath: logURL.path, contents: nil)
44+
let handle = try FileHandle(forWritingTo: logURL)
45+
process.standardOutput = handle
46+
process.standardError = handle
47+
} else {
48+
process.standardOutput = FileHandle.nullDevice
49+
process.standardError = FileHandle.nullDevice
50+
}
4351
try process.run()
4452

4553
let deadline = Date().addingTimeInterval(8)

‎apps/headless/Tests/macos-e2e.sh‎

Lines changed: 50 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,27 @@ HOST="$PWD/Headless.app/Contents/MacOS/Headless"
1212

1313
PORT=$((43000 + RANDOM % 1000))
1414
RUNTIME="/tmp/headless-$(id -u)"
15+
mkdir -p "$RUNTIME"
16+
chmod 700 "$RUNTIME"
1517
export HEADLESS_SOCKET="$RUNTIME/macos-e2e-$$.sock"
1618
export HEADLESS_ARTIFACT_DIR="$RUNTIME/artifacts-macos-e2e-$$"
1719
export HEADLESS_HOST_EXECUTABLE="$HOST"
1820
export HEADLESS_FIXTURE_PORT="$PORT"
1921
LOG="$(mktemp "${TMPDIR:-/tmp}/headless-macos-e2e.XXXXXX")"
22+
HOST_LOG="$(mktemp "${TMPDIR:-/tmp}/headless-macos-host.XXXXXX")"
23+
export HEADLESS_HOST_LOG="$HOST_LOG"
24+
STEP="boot"
25+
26+
fail() {
27+
trap - ERR
28+
print -u2 "macOS E2E failed during: $STEP"
29+
print -u2 "---- fixture log ----"
30+
cat "$LOG" >&2 || true
31+
print -u2 "---- host log ----"
32+
cat "$HOST_LOG" >&2 || true
33+
exit 1
34+
}
35+
trap 'fail' ERR
2036

2137
node Tests/fixture-server.mjs >"$LOG" 2>&1 &
2238
FIXTURE_PID=$!
@@ -25,27 +41,40 @@ cleanup() {
2541
"$CLI" stop >/dev/null 2>&1 || true
2642
kill "$FIXTURE_PID" >/dev/null 2>&1 || true
2743
rm -rf "$HEADLESS_ARTIFACT_DIR"
28-
rm -f "$LOG"
44+
rm -f "$LOG" "$HOST_LOG"
2945
}
3046
trap cleanup EXIT INT TERM
3147

48+
STEP="fixture-server"
3249
for _ in {1..100}; do
3350
curl -fsS "http://127.0.0.1:$PORT/designers/dashboard" >/dev/null 2>&1 && break
3451
sleep 0.05
3552
done
3653
curl -fsS "http://127.0.0.1:$PORT/designers/dashboard" >/dev/null
3754

38-
START_RESULT="$("$CLI" start)"
39-
echo "$START_RESULT" | grep -q '"ready":true'
55+
STEP="start-host"
56+
START_RESULT="$("$CLI" start)" || {
57+
print -u2 "headless start failed:"
58+
print -u2 "$START_RESULT"
59+
fail
60+
}
61+
echo "$START_RESULT" | grep -q '"ready":true' || {
62+
print -u2 "host did not become ready: $START_RESULT"
63+
fail
64+
}
65+
echo "▸ host ready"
66+
STEP="tcp-check"
4067
HOST_PID="$(echo "$START_RESULT" | sed -n 's/.*"pid":\([0-9][0-9]*\).*/\1/p')"
4168
test -n "$HOST_PID"
4269
if lsof -nP -a -p "$HOST_PID" -iTCP -sTCP:LISTEN 2>/dev/null | grep -q LISTEN; then
4370
echo "Headless host opened an unexpected TCP listener" >&2
44-
exit 1
71+
fail
4572
fi
73+
STEP="session-visit"
4674
"$CLI" session create qa | grep -q '"session":"qa"'
4775
"$CLI" session list | grep -q '"qa"'
4876
"$CLI" --session qa visit "http://127.0.0.1:$PORT/designers/dashboard" | grep -q 'Designers Dashboard'
77+
STEP="inspect-diagnostics"
4978
SNAPSHOT="$("$CLI" --session qa inspect --interactive --text)"
5079
echo "$SNAPSHOT" | grep -q '"name":"Continue"'
5180
echo "$SNAPSHOT" | grep -q '"name":"Reviewer"'
@@ -69,40 +98,43 @@ echo "$STORAGE" | grep -q 'qa-diagnostic-key'
6998
echo "$STORAGE" | grep -q 'qa-session-key'
7099
if SENSITIVE_STORAGE="$("$CLI" --session qa storage list --values)"; then
71100
echo "sensitive storage values were available without opt-in" >&2
72-
exit 1
101+
fail
73102
fi
74103
echo "$SENSITIVE_STORAGE" | grep -q 'SENSITIVE_DIAGNOSTICS_DISABLED'
104+
STEP="qa-report"
75105
QA_REPORT="$("$CLI" --session qa qa report)"
76106
echo "$QA_REPORT" | grep -q '"kind":"console"'
77107
echo "$QA_REPORT" | grep -q '"status":404'
78108
echo "$QA_REPORT" | grep -q '"kind":"framework-error"'
79109
echo "$QA_REPORT" | grep -q '"kind":"local-not-found"'
80110
"$CLI" --session qa qa clear | grep -q '"cleared"'
81111
"$CLI" --session qa qa report | grep -q '"events":0'
112+
STEP="safe-input-navigation"
82113
"$CLI" --session qa fill @e1 'Ada Lovelace' | grep -q '"valueLength":12'
83114
"$CLI" --session qa press Escape | grep -q '"pressed":"Escape"'
84115
if EXTERNAL_RESULT="$("$CLI" --session qa click --role link --name 'External application')"; then
85116
echo "external application link was not blocked" >&2
86-
exit 1
117+
fail
87118
fi
88119
echo "$EXTERNAL_RESULT" | grep -q 'UNSAFE_NAVIGATION'
89120
if NON_WEB_RESULT="$("$CLI" --session qa click --role link --name 'Non-web browser URL')"; then
90121
echo "non-HTTP browser URL was not blocked" >&2
91-
exit 1
122+
fail
92123
fi
93124
echo "$NON_WEB_RESULT" | grep -q 'UNSAFE_NAVIGATION'
94125
if CREDENTIAL_RESULT="$("$CLI" --session qa click --role link --name 'Credential-bearing URL')"; then
95126
echo "credential-bearing browser URL was not blocked" >&2
96-
exit 1
127+
fail
97128
fi
98129
echo "$CREDENTIAL_RESULT" | grep -q 'UNSAFE_NAVIGATION'
99130
if SUSPICIOUS_RESULT="$("$CLI" --session qa click --role link --name 'Suspicious installer')"; then
100131
echo "suspicious installer link was not blocked" >&2
101-
exit 1
132+
fail
102133
fi
103134
echo "$SUSPICIOUS_RESULT" | grep -q 'UNSAFE_RESOURCE_TYPE'
104135
"$CLI" --session qa click --role button --name 'Scripted non-web navigation' | grep -q '"clicked"'
105136
"$CLI" --session qa wait --url /designers/dashboard --settled --timeout 10000 | grep -q 'Designers Dashboard'
137+
STEP="artifacts-visual"
106138
"$CLI" --session qa screenshot --output viewport.png | grep -q '"name":"viewport.png"'
107139
"$CLI" --session qa screenshot --full-page --output full-page.png | grep -q '"name":"full-page.png"'
108140
"$CLI" --session qa screenshot --role button --name Continue --output continue.png | grep -q '"name":"continue.png"'
@@ -112,11 +144,13 @@ test -s "$HEADLESS_ARTIFACT_DIR/visual-diff.png"
112144
ANIMATIONS="$("$CLI" --session qa animations list)"
113145
echo "$ANIMATIONS" | grep -q '"animations"'
114146
echo "$ANIMATIONS" | grep -q '"iterations":null'
115-
if NETWORK_SIMULATION="$($CLI --session qa network emulate --latency 25)"; then
147+
STEP="network-emulate-unsupported"
148+
if NETWORK_SIMULATION="$("$CLI" --session qa network emulate --latency 25)"; then
116149
echo "WebKit network emulation was unexpectedly exposed" >&2
117-
exit 1
150+
fail
118151
fi
119152
echo "$NETWORK_SIMULATION" | grep -q 'UNSUPPORTED_CAPABILITY'
153+
STEP="flows-reports"
120154
"$CLI" --session qa flow start | grep -q '"recording":true'
121155
"$CLI" --session qa visit "http://127.0.0.1:$PORT/designers/dashboard" | grep -q 'Designers Dashboard'
122156
"$CLI" --session qa click --role button --name Continue | grep -q '"clicked"'
@@ -133,17 +167,18 @@ FULL_HEIGHT="$(sips -g pixelHeight "$HEADLESS_ARTIFACT_DIR/full-page.png" | awk
133167
test "$FULL_HEIGHT" -gt "$VIEWPORT_HEIGHT"
134168
if "$CLI" --session qa screenshot --output viewport.png >/dev/null 2>&1; then
135169
echo "artifact overwrite was not rejected" >&2
136-
exit 1
170+
fail
137171
fi
138172
if "$CLI" screenshot --output ../escape.png >/dev/null 2>&1; then
139173
echo "artifact path traversal was not rejected" >&2
140-
exit 1
174+
fail
141175
fi
176+
STEP="recording"
142177
"$CLI" --session qa record start --fps 5 | grep -q '"active":true'
143178
"$CLI" --session qa record status | grep -q '"active":true'
144179
if "$CLI" --session qa record start >/dev/null 2>&1; then
145180
echo "a second recording was not rejected" >&2
146-
exit 1
181+
fail
147182
fi
148183
"$CLI" --session qa tour --full-page --pace 5000 | grep -q '"durationMs"'
149184
"$CLI" --session qa click --role button --name Continue | grep -q '"clicked"'
@@ -157,6 +192,7 @@ test "$(stat -f %Lp "$HEADLESS_ARTIFACT_DIR/dashboard-flow.mp4")" = "600"
157192
"$CLI" artifacts list | grep -q '"name":"dashboard-flow.mp4"'
158193
"$CLI" --session qa back | grep -q 'Designers Dashboard'
159194
"$CLI" --session qa reload | grep -q 'Designers Dashboard'
195+
STEP="capture-hostile"
160196
CAPTURE="$("$CLI" --session qa capture-info)"
161197
echo "$CAPTURE" | grep -q '"engine":"webkit"'
162198
echo "$CAPTURE" | grep -q '"windowId"'

‎apps/headless/build.sh‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,10 @@ cat > "$APP/Contents/Info.plist" <<PLIST
8383
<key>NSHighResolutionCapable</key><true/>
8484
<key>NSSupportsAutomaticGraphicsSwitching</key><true/>
8585
<key>NSAppTransportSecurity</key>
86-
<dict><key>NSAllowsArbitraryLoadsInWebContent</key><true/></dict>
86+
<dict>
87+
<key>NSAllowsArbitraryLoads</key><true/>
88+
<key>NSAllowsArbitraryLoadsInWebContent</key><true/>
89+
</dict>
8790
<key>NSHumanReadableCopyright</key><string>headless — the browser that isn’t there</string>
8891
</dict>
8992
</plist>

‎apps/headless/main.swift‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -855,7 +855,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
855855

856856
@objc func newWindow(_ sender: Any?) { openWindow(url: nil) }
857857

858-
func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { true }
858+
// Agent hosts must stay alive when the last session window closes; the CLI
859+
// owns process lifetime via `headless stop` / shutdown.
860+
func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { !isAgentHost }
859861
func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool { true }
860862

861863
func applicationWillTerminate(_ notification: Notification) {

0 commit comments

Comments
 (0)