Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 5 additions & 6 deletions docs/devtools.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion docs/test-driver.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down
4 changes: 4 additions & 0 deletions packages/desktop-app/macos/SparkTestDriver.h
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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);
Expand Down
1 change: 1 addition & 0 deletions packages/desktop-app/macos/SparkTestDriver.mm
Original file line number Diff line number Diff line change
Expand Up @@ -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<NSString *> *SparkBuiltInCommands(void) {
return [[[@[@"ping", @"navigate", @"openFile", @"waitFor", @"elements", @"setAppAppearance", @"capture", @"menus", @"startup", @"quit"]
arrayByAddingObjectsFromArray:SparkDriverInputCommandNames()] arrayByAddingObjectsFromArray:[SparkDriverFrameCommandNames() arrayByAddingObjectsFromArray:SparkDriverCommitCommandNames()]]
Expand Down
8 changes: 8 additions & 0 deletions packages/desktop-host/AppDelegate.mm
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
Expand Down
10 changes: 5 additions & 5 deletions scripts/test-devtools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"))}`);
Expand Down
7 changes: 6 additions & 1 deletion tests/test-driver.native.mm
Original file line number Diff line number Diff line change
Expand Up @@ -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"];
Expand Down Expand Up @@ -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;
Expand Down
Loading