Skip to content

Commit 91e51cd

Browse files
committed
ci: unit test adjustments
[skip ci]
1 parent f7cdfcc commit 91e51cd

5 files changed

Lines changed: 104 additions & 21 deletions

File tree

.github/workflows/npm_release.yml

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -241,9 +241,24 @@ jobs:
241241
# Simulator app crashes land in the host's DiagnosticReports.
242242
cp -R ~/Library/Logs/DiagnosticReports/. "$DIAG/DiagnosticReports/" 2>/dev/null || true
243243
cp -R ~/Library/Logs/CoreSimulator/. "$DIAG/CoreSimulator/" 2>/dev/null || true
244-
# Unified log from the booted simulator: the app's console output, so
245-
# the last spec before a hang is visible even when nothing was POSTed.
246-
xcrun simctl spawn booted log collect --output "$DIAG/simulator.logarchive" 2>/dev/null || true
244+
# Unified log = the app's console output (so the last spec before a hang
245+
# is visible even when nothing was POSTed). `log collect` needs a booted
246+
# device; don't rely on the `booted` alias (the prior collect failed
247+
# because the sim wasn't booted at that moment). Resolve a concrete UDID
248+
# — prefer one already booted from the test run, else the test device,
249+
# booting it so the persisted log store can be collected.
250+
UDID="$(xcrun simctl list devices booted | grep -oE '[0-9A-Fa-f-]{36}' | head -1)"
251+
if [ -z "$UDID" ]; then
252+
UDID="$(xcrun simctl list devices 'iPhone 15 Pro Max' | grep -oE '[0-9A-Fa-f-]{36}' | head -1)"
253+
[ -n "$UDID" ] && xcrun simctl boot "$UDID" 2>/dev/null || true
254+
[ -n "$UDID" ] && xcrun simctl bootstatus "$UDID" 2>/dev/null || true
255+
fi
256+
if [ -n "$UDID" ]; then
257+
echo "Collecting unified log from simulator $UDID"
258+
xcrun simctl spawn "$UDID" log collect --output "$DIAG/simulator.logarchive" 2>/dev/null || true
259+
else
260+
echo "No simulator UDID resolved; skipping logarchive collection."
261+
fi
247262
echo "Collected diagnostics:"; ls -laR "$DIAG" 2>/dev/null || true
248263
- name: Upload test diagnostics (on failure)
249264
if: ${{ failure() }}

TestRunner/app/Infrastructure/Jasmine/jasmine-2.0.1/boot.js

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,32 @@ var TerminalReporter = require('../jasmine-reporters/terminal_reporter').Termina
133133
}));
134134
jasmine.getEnv().addReporter(new JUnitXmlReporter());
135135

136+
// Progress beacon: fire-and-forget GET of each suite/spec name to the XCTest
137+
// host's /progress endpoint. When the suite hangs (no JUnit report is ever
138+
// POSTed), this lets the Swift harness name the spec that was running when the
139+
// JS thread stalled. Async via NSURLSession so it never blocks the JS thread;
140+
// best-effort — any error is swallowed.
141+
(function installProgressBeacon() {
142+
try {
143+
var reportUrl = NSProcessInfo.processInfo.environment.objectForKey("REPORT_BASEURL");
144+
if (!reportUrl) { return; }
145+
var origin = new URL(String(reportUrl)).origin;
146+
var beacon = function (name) {
147+
try {
148+
var url = origin + "/progress?spec=" + encodeURIComponent(name || "");
149+
var req = NSMutableURLRequest.requestWithURL(NSURL.URLWithString(url));
150+
req.HTTPMethod = "GET";
151+
req.timeoutInterval = 2.0;
152+
NSURLSession.sharedSession.dataTaskWithRequestCompletionHandler(req, function () {}).resume();
153+
} catch (e) { /* best-effort */ }
154+
};
155+
jasmine.getEnv().addReporter({
156+
suiteStarted: function (r) { beacon("[suite] " + (r && r.fullName ? r.fullName : "")); },
157+
specStarted: function (r) { beacon(r && r.fullName ? r.fullName : ""); }
158+
});
159+
} catch (e) { /* best-effort */ }
160+
}());
161+
136162
env.specFilter = function(spec) {
137163
return true;
138164
};

TestRunner/app/tests/HttpEsmLoaderTests.js

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -167,7 +167,10 @@ describe("HTTP ESM Loader", function() {
167167
// Prefer the local XCTest-hosted HTTP server (when available) to avoid ATS restrictions
168168
// and make this test deterministic.
169169
var origin = getHostOrigin();
170-
var spec = origin ? (origin + "/esm/timeout.mjs?delayMs=6500") : "https://192.0.2.1:5173/timeout-test.js";
170+
// Prefer the local XCTest server's delayed endpoint (deterministic, hermetic).
171+
// The fallback is a closed local port (fast connection-refused), never a live
172+
// external/TEST-NET host whose connect timeout would stall the JS thread on CI.
173+
var spec = origin ? (origin + "/esm/timeout.mjs?delayMs=6500") : "http://127.0.0.1:59999/timeout-test.js";
171174

172175
import(spec).then(function(module) {
173176
fail("Should not have succeeded for unreachable server");

TestRunner/app/tests/RemoteModuleSecurityTests.js

Lines changed: 28 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -18,16 +18,28 @@
1818
// - With allowlist: Only URLs matching a prefix in remoteModuleAllowlist are allowed
1919

2020
describe("Remote Module Security", function() {
21-
21+
22+
// Hermetic by design: never touch live external hosts (esm.sh, TEST-NET
23+
// 192.0.2.1, example.com). HTTP module fetches are SYNCHRONOUS on the JS
24+
// thread (HMRSupport.mm: NSURLConnection sendSynchronousRequest), so a slow
25+
// or transitively-fanning remote import (e.g. esm.sh pulling a graph of
26+
// sub-modules) blocks the event loop and, accumulated, runs the whole suite
27+
// past its timeout on CI -> false "hang". A closed local port fails fast
28+
// with a connection-refused *network* error, which is exactly what every
29+
// debug-mode spec below asserts: the security gate lets the request through
30+
// (so it's NOT a security error) and the fetch then fails on the network.
31+
var DEAD_HTTP = "http://127.0.0.1:59999";
32+
var DEAD_HTTPS = "https://127.0.0.1:59999";
33+
2234
describe("Debug Mode Behavior", function() {
2335
// In debug mode (test environment), all remote modules should be allowed
2436
// regardless of security configuration
2537

2638
it("should allow HTTP module imports in debug mode", function(done) {
27-
// This test uses a known unreachable IP to trigger the HTTP loading path
28-
// In debug mode, the security check passes and we get a network error
29-
// (not a security error)
30-
import("http://192.0.2.1:5173/test-module.js").then(function(module) {
39+
// Uses a closed local port to trigger the HTTP loading path and fail
40+
// fast. In debug mode the security check passes and we get a network
41+
// error (connection refused), NOT a security error.
42+
import(DEAD_HTTP + "/test-module.js").then(function(module) {
3143
// If we somehow succeed, that's fine too
3244
expect(module).toBeDefined();
3345
done();
@@ -42,8 +54,8 @@ describe("Remote Module Security", function() {
4254
});
4355

4456
it("should allow HTTPS module imports in debug mode", function(done) {
45-
// Test HTTPS URL - should be allowed in debug mode
46-
import("https://192.0.2.1:5173/test-module.js").then(function(module) {
57+
// Test HTTPS URL - should be allowed in debug mode (closed local port -> fast network error)
58+
import(DEAD_HTTPS + "/test-module.js").then(function(module) {
4759
expect(module).toBeDefined();
4860
done();
4961
}).catch(function(error) {
@@ -96,9 +108,9 @@ describe("Remote Module Security", function() {
96108
// code paths for HTTP loading are exercised correctly
97109

98110
it("should handle allowlisted URL prefixes", function(done) {
99-
// esm.sh is in our test allowlist
100-
// In debug mode this is allowed anyway, but tests the path
101-
import("https://esm.sh/test-module").then(function(module) {
111+
// In debug mode the allowlist is not enforced; this just exercises the
112+
// remote-load path and confirms we get a network (not security) error.
113+
import(DEAD_HTTPS + "/test-module").then(function(module) {
102114
expect(module).toBeDefined();
103115
done();
104116
}).catch(function(error) {
@@ -111,7 +123,7 @@ describe("Remote Module Security", function() {
111123

112124
it("should handle non-allowlisted URLs in debug mode", function(done) {
113125
// This URL is NOT in the allowlist, but debug mode allows it anyway
114-
import("https://not-in-allowlist.example.com/module.js").then(function(module) {
126+
import(DEAD_HTTPS + "/not-allowlisted/module.js").then(function(module) {
115127
expect(module).toBeDefined();
116128
done();
117129
}).catch(function(error) {
@@ -132,7 +144,7 @@ describe("Remote Module Security", function() {
132144
it("should allow importing from allowlisted static HTTP URLs", function(done) {
133145
// This test verifies the ResolveModuleCallback security check path
134146
// is exercised for static imports
135-
const testUrl = "https://esm.sh/@test/module";
147+
const testUrl = DEAD_HTTPS + "/@test/module";
136148

137149
import(testUrl).then(function(module) {
138150
expect(module).toBeDefined();
@@ -152,7 +164,7 @@ describe("Remote Module Security", function() {
152164
// handling code doesn't crash
153165

154166
it("should provide clear errors for failed HTTP imports", function(done) {
155-
import("http://invalid-url-test:9999/module.js").then(function(module) {
167+
import(DEAD_HTTP + "/module.js").then(function(module) {
156168
done();
157169
}).catch(function(error) {
158170
expect(error).toBeDefined();
@@ -188,7 +200,7 @@ describe("Remote Module Security", function() {
188200

189201
it("should handle URLs with special characters", function(done) {
190202
// URLs with encoded characters should be handled properly
191-
import("https://example.com/module%20with%20spaces.js").then(function(module) {
203+
import(DEAD_HTTPS + "/module%20with%20spaces.js").then(function(module) {
192204
done();
193205
}).catch(function(error) {
194206
// Error handling should not crash
@@ -198,7 +210,7 @@ describe("Remote Module Security", function() {
198210
});
199211

200212
it("should handle URLs with query parameters", function(done) {
201-
import("https://esm.sh/lodash?target=es2020").then(function(module) {
213+
import(DEAD_HTTPS + "/lodash?target=es2020").then(function(module) {
202214
done();
203215
}).catch(function(error) {
204216
// Query params should be preserved in URL handling
@@ -208,7 +220,7 @@ describe("Remote Module Security", function() {
208220
});
209221

210222
it("should handle URLs with fragments", function(done) {
211-
import("https://esm.sh/module#section").then(function(module) {
223+
import(DEAD_HTTPS + "/module#section").then(function(module) {
212224
done();
213225
}).catch(function(error) {
214226
expect(error).toBeDefined();

TestRunnerTests/TestRunnerTests.swift

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,12 @@ class TestRunnerTests: XCTestCase {
66
private var server: HTTPServer!
77
private var runtimeUnitTestsExpectation: XCTestExpectation!
88

9+
// Most recent spec reported by the in-app Jasmine progress beacon (see the
10+
// /progress handler below). When the suite hangs and never POSTs results,
11+
// this names the spec that was running when the JS thread stalled.
12+
private let progressLock = NSLock()
13+
private var lastSpecSeen = "(no spec reported yet)"
14+
915
override func setUp() {
1016
continueAfterFailure = false
1117

@@ -25,6 +31,24 @@ class TestRunnerTests: XCTestCase {
2531
let path = (environ["PATH_INFO"] as? String) ?? "/"
2632
let query = (environ["QUERY_STRING"] as? String) ?? ""
2733

34+
// Progress beacon from the in-app Jasmine reporter (fire-and-forget):
35+
// records the spec currently running so a hang/timeout can report
36+
// where the JS suite stalled, even though no JUnit report is POSTed.
37+
if method == "GET" && path == "/progress" {
38+
if let specParam = query
39+
.split(separator: "&")
40+
.first(where: { $0.hasPrefix("spec=") }) {
41+
let raw = String(specParam.dropFirst("spec=".count))
42+
let decoded = raw.removingPercentEncoding ?? raw
43+
self.progressLock.lock()
44+
self.lastSpecSeen = decoded
45+
self.progressLock.unlock()
46+
}
47+
startResponse("204 No Content", [])
48+
sendBody(Data())
49+
return
50+
}
51+
2852
// Serve tiny ESM modules for runtime HTTP loader tests.
2953
if method == "GET" {
3054
if path == "/esm/query.mjs" || path == "/ns/m/query.mjs" {
@@ -202,7 +226,10 @@ class TestRunnerTests: XCTestCase {
202226
}
203227
return
204228
case .timedOut:
205-
XCTFail("Ran past \(Int(jasmineTestsTimeout))s with the \"Jasmine tests\" expectation unfulfilled while the app was STILL RUNNING -> the JS suite HUNG (deadlock or never-settled async); it did not crash. Check the 'test-diagnostics' artifact (simulator.logarchive) for the last spec that logged before the stall.")
229+
progressLock.lock()
230+
let lastSpec = lastSpecSeen
231+
progressLock.unlock()
232+
XCTFail("Ran past \(Int(jasmineTestsTimeout))s with the \"Jasmine tests\" expectation unfulfilled while the app was STILL RUNNING -> the JS suite HUNG (deadlock or never-settled async); it did not crash. Last spec reported by the in-app beacon: \"\(lastSpec)\". Also see the 'test-diagnostics' artifact (simulator.logarchive).")
206233
default:
207234
XCTFail("Unexpected XCTWaiter result: \(result.rawValue)")
208235
}

0 commit comments

Comments
 (0)