diff --git a/docs/devtools.md b/docs/devtools.md index ed7e5a6d..a806ea5b 100644 --- a/docs/devtools.md +++ b/docs/devtools.md @@ -28,10 +28,9 @@ closes the previous DevTools session, which reports that a new debugger opened; ([#292](https://github.com/LegendApp/legend-spark/issues/292)). A debugger session survives source edits and full reloads (`r`, or reload from DevTools): the shared runtime keeps its -target, so its DevTools window stays attached. Spark destroys isolated runtimes on reload; each would come back as a -new target with the same title when the app starts it again, but today isolated runtimes do not restart after a reload -([#291](https://github.com/LegendApp/legend-spark/issues/291)). In the Kitchen Sink, edits currently arrive as full -reloads rather than hot updates (#291). +target, so its DevTools window stays attached. Spark destroys isolated runtimes on reload; each comes back as a +new target with the same title when the app starts it again. In the Kitchen Sink, edits currently arrive as full +reloads rather than hot updates ([#291](https://github.com/LegendApp/legend-spark/issues/291)). ## API @@ -421,8 +420,8 @@ instead, headless: reconnects, then calls `openDevTools` for both runtimes and an absent one (`CHROME_PATH` points React Native's browser launcher at a recorder, so no browser opens). 5. It edits a source file and restores it, pauses on an uncaught exception, and reloads, checking that the shared - runtime's session stays attached throughout. It reports, without failing, whether the worker restarted after the - reload (#291). + runtime's session stays attached throughout. After the reload, it checks that the worker restarts as a new + target. `bun scripts/test-network-inspector.ts` checks the network inspector the same way, against loopback fixtures (`scripts/testing/network-fixtures.ts`, 127.0.0.1 only). It attaches to the shared runtime over CDP and runs the diff --git a/docs/test-driver.md b/docs/test-driver.md index 2020c139..f57b38a2 100644 --- a/docs/test-driver.md +++ b/docs/test-driver.md @@ -234,7 +234,7 @@ send one request at a time; each reply echoes `id`. Failures are | Command | Fields | Reply | | --- | --- | --- | | `ping` | | `ok` | -| `navigate` | `url`, `timeoutMs` (30000) | Waits for the first rendered UI to settle (so link listeners are subscribed), then opens the URL through `application:openURLs:`, the path the OS uses for deep links. | +| `navigate` | `url`, `timeoutMs` (30000) | Waits for the first rendered UI to settle (so link listeners are subscribed; after a JavaScript reload, the first UI the reloaded app renders), then opens the URL through `application:openURLs:`, the path the OS uses for deep links. | | `openFile` | `path`, `timeoutMs` (30000) | `path` must be absolute and exist (`E_INVALID_ARGUMENT`, `E_NOT_FOUND`). Waits like `navigate`, then opens a file URL for it through `application:openURLs:`, the path the OS uses for a file opened from Finder. `navigate` rejects file URLs, so only `openFile` delivers files. | | `waitFor` | `testID`, `timeoutMs` (10000) | `window` identifier and `frame` of the first visible view whose accessibility identifier (React Native `testID`) matches. `frame` is in points from the top-left of the window's frame view, the area `capture` renders, so frame × `scale` is the view's pixels in a capture. `E_TIMEOUT` otherwise; `timeoutMs: 0` checks once. | | `elements` | | `windows` (`id`, `title`, `key`, `main`, `frame`), front to back, and `elements`: the in-process accessibility view of every window with an identifier. An element is a visible view with a testID, or an accessibility element (controls through their cells) with a role, label or text: `key`, `parent` (the key of the nearest enclosing element), `window`, `id`, `role` (the NSAccessibility role without `AX`: `button`, `staticText`, `radioButton`), `label`, `text` (the string value, else the title, else the displayed text of a view with attributed text, such as React Native `Text`), `value` (the accessibility value as a string, numbers included), `enabled` and `selected` (its `isAccessibilityEnabled` and `isAccessibilitySelected`, a control's through its cell) and `frame` (as `waitFor`). A view inside an element with the same testID (the native control a React Native view hosts) refines that element. The components of an `NSPathControl` and the tab buttons of an `NSTabView` (accessibility elements that are not views) are elements too. A window's `frame` is its frame view, at 0,0. | diff --git a/packages/desktop-app/macos/SparkTestDriver.h b/packages/desktop-app/macos/SparkTestDriver.h index f3794d53..573e2875 100644 --- a/packages/desktop-app/macos/SparkTestDriver.h +++ b/packages/desktop-app/macos/SparkTestDriver.h @@ -21,6 +21,7 @@ static inline BOOL SparkTestDriverStart(void) } static inline BOOL SparkTestDriverActive(void) { return NO; } static inline void SparkTestDriverDidMount(void) {} +static inline void SparkTestDriverDidReload(void) {} static inline BOOL SparkTestDriverEnding(void) { return NO; } #else @@ -30,6 +31,9 @@ FOUNDATION_EXPORT BOOL SparkTestDriverStart(void); FOUNDATION_EXPORT BOOL SparkTestDriverActive(void); // The host reports each mounted UI transaction; captures wait until mounts settle. FOUNDATION_EXPORT void SparkTestDriverDidMount(void); +// The host reports each reload of the app's JavaScript; navigate then waits for the reloaded UI, +// whose link listeners are not subscribed yet, instead of the UI rendered before the reload. +FOUNDATION_EXPORT void SparkTestDriverDidReload(void); // YES once the runner's session has ended: that termination is teardown, not a user quit, // so the host terminates without running the app's quit guards. FOUNDATION_EXPORT BOOL SparkTestDriverEnding(void); diff --git a/packages/desktop-app/macos/SparkTestDriver.mm b/packages/desktop-app/macos/SparkTestDriver.mm index 657f682e..377a7899 100644 --- a/packages/desktop-app/macos/SparkTestDriver.mm +++ b/packages/desktop-app/macos/SparkTestDriver.mm @@ -69,6 +69,7 @@ void SparkTestDriverDidMount(void) { if (now - firstMount <= SparkStartupWindow && quietPoints.count < SparkMaxQuietPoints) dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(SparkSettleInterval * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ SparkNoteQuiet(CACurrentMediaTime()); }); } +void SparkTestDriverDidReload(void) { lastMount = 0; } static NSArray *SparkBuiltInCommands(void) { return [[[@[@"ping", @"navigate", @"openFile", @"waitFor", @"elements", @"setAppAppearance", @"capture", @"menus", @"startup", @"quit"] arrayByAddingObjectsFromArray:SparkDriverInputCommandNames()] arrayByAddingObjectsFromArray:[SparkDriverFrameCommandNames() arrayByAddingObjectsFromArray:SparkDriverCommitCommandNames()]] diff --git a/packages/desktop-host/AppDelegate.mm b/packages/desktop-host/AppDelegate.mm index 5699cbd8..724d8693 100644 --- a/packages/desktop-host/AppDelegate.mm +++ b/packages/desktop-host/AppDelegate.mm @@ -148,6 +148,14 @@ - (void)loadReactNativeWindow:(NSDictionary *)launchOptions { }); } - (void)didMountComponentsWithRootTag:(NSInteger)rootTag { SparkTestDriverDidMount(); } +// Worker runtimes' hosts forward hostDidStart: here too; only the main host's restart reloads the app UI. +- (void)hostDidStart:(RCTHost *)host { + [super hostDidStart:host]; + if (host != self.rootViewFactory.reactHost) return; + // A reload starts a new instance with its own surface presenter, which reports the reloaded UI's mounts. + if (SparkTestDriverActive()) [host.surfacePresenter addObserver:self]; + SparkTestDriverDidReload(); +} - (BOOL)windowShouldClose:(NSWindow *)window { NSString *behavior = SparkLifecycleConfiguration()[@"mainWindow"][@"closeBehavior"]; if ([behavior isEqual:@"hide"]) { [window orderOut:nil]; return NO; } diff --git a/scripts/test-devtools.ts b/scripts/test-devtools.ts index 0f0bf5ab..96fef9f2 100644 --- a/scripts/test-devtools.ts +++ b/scripts/test-devtools.ts @@ -171,12 +171,12 @@ try { assert.equal(await main.evaluate("1 + 1"), 2); assert.equal((await target(SHARED)).id, shared.id); pass(`reload: the shared target ${shared.id} kept its id and the same session evaluates in the reloaded runtime`); - // Isolated runtimes are destroyed on reload. Re-registering needs the worker to start again, which it does not: - // after a reload ThreadedRuntime.run never reaches native (#291). Reported, not asserted. + // The reload destroyed the worker; the DevTools screen of the reloaded app starts it again as a new target, + // which /json/list lists shortly after the screen shows it running. await driver.navigate("spark-ks://devtools/devtools"); - const restarted = await driver.waitFor("devtools-devtools-worker-running", 20000).then(() => true, () => false); - const reloadedWorker = restarted ? (await targets()).find(item => item.title === WORKER && item.id !== worker.id) : undefined; - console.log(reloadedWorker ? `PASS reload: the worker re-registered as ${reloadedWorker.id}` : "KNOWN ISSUE #291: the worker runtime did not restart after the reload, so it has no target"); + await driver.waitFor("devtools-devtools-worker-running", 20000); + const reloadedWorker = await until(async () => (await targets()).find(item => item.title === WORKER && item.id !== worker.id), "the restarted worker registers its target"); + pass(`reload: the worker re-registered as ${reloadedWorker.id}`); } catch (error) { const pauses = debuggerEvents.filter(message => message.method === "Debugger.paused" || message.method === "Debugger.resumed"); console.error(`Shared runtime debugger events: ${JSON.stringify(pauses.map(message => message.method === "Debugger.paused" ? `paused(${message.params.reason}: ${message.params.callFrames?.[0]?.functionName}) ${JSON.stringify(message.params.data?.description ?? "").slice(0, 120)}` : "resumed"))}`); diff --git a/tests/test-driver.native.mm b/tests/test-driver.native.mm index d46cbe28..7c5c7150 100644 --- a/tests/test-driver.native.mm +++ b/tests/test-driver.native.mm @@ -470,6 +470,10 @@ int main(int argc, char **argv) { @autoreleasepool { Check([Request(fd, @{ @"command": @"openFile", @"path": @"/etc/hosts" })[@"ok"] boolValue], @"openFile"); Check([Code(Request(fd, @{ @"command": @"openFile", @"path": @"etc/hosts" })) isEqual:@"E_INVALID_ARGUMENT"], @"openFile needs an absolute path"); Check([Code(Request(fd, @{ @"command": @"openFile", @"path": @"/etc/spark-driver-missing" })) isEqual:@"E_NOT_FOUND"], @"openFile needs an existing file"); + dispatch_sync(dispatch_get_main_queue(), ^{ SparkTestDriverDidReload(); }); + Check([Code(Request(fd, @{ @"command": @"navigate", @"url": @"spark-ks://reloaded", @"timeoutMs": @200 })) isEqual:@"E_TIMEOUT"], @"navigate after a reload waits for the reloaded UI"); + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, 100 * NSEC_PER_MSEC), dispatch_get_main_queue(), ^{ SparkTestDriverDidMount(); }); + Check([Request(fd, @{ @"command": @"navigate", @"url": @"spark-ks://reloaded" })[@"ok"] boolValue], @"navigate after the reloaded UI mounts"); NSDictionary *found = Request(fd, @{ @"command": @"waitFor", @"testID": @"probe-layer" }); Check([found[@"ok"] boolValue] && [found[@"frame"][@"width"] isEqual:@100] && [found[@"window"] isEqual:@"spark.main"], @"waitFor finds an existing view"); probeFrame = found[@"frame"]; @@ -1069,8 +1073,9 @@ int main(int argc, char **argv) { @autoreleasepool { Pump(^{ return (BOOL)(access([directory stringByAppendingPathComponent:@"driver.sock"].fileSystemRepresentation, F_OK) != 0); }, 2); Check(access([directory stringByAppendingPathComponent:@"driver.sock"].fileSystemRepresentation, F_OK) != 0, @"socket is removed"); if ([mode isEqual:@"session"]) { - Check(openedURLs.count == 2 && [openedURLs[0].absoluteString isEqual:@"spark-ks://windows/frame"], @"navigate opens the URL through the app delegate"); + Check(openedURLs.count == 3 && [openedURLs[0].absoluteString isEqual:@"spark-ks://windows/frame"], @"navigate opens the URL through the app delegate"); Check(openedURLs[1].isFileURL && [openedURLs[1].path isEqual:@"/etc/hosts"], @"openFile opens a file URL through the app delegate"); + Check([openedURLs[2].absoluteString isEqual:@"spark-ks://reloaded"], @"a navigate that timed out opens nothing"); // A window that was never shown renders everything drawn in-process. Check(Near(Sample(@"cache.png", NSMakeRect(0, 200, 100, 100), window), 1, 0, 0), @"capture renders layer background colors"); CGFloat scale = [[NSBitmapImageRep imageRepWithData:[NSData dataWithContentsOfFile:[directory stringByAppendingPathComponent:@"cache.png"]]] pixelsWide] / window.frame.size.width;