mirror of
https://github.com/callstack/agent-device.git
synced 2026-09-14 20:06:34 +08:00
feat(ios): productionize Simulator AX snapshot bridge (#2277)
* feat(ios): productionize Simulator AX snapshot bridge * fix: address Simulator AX bridge review comments * docs: refresh Simulator AX evidence * fix: address new Simulator AX bridge review comments * docs: record public snapshot source timings * fix: preserve size report helper on base checkout * fix: allow base packages without snapshot bridge * fix: close simulator snapshot source ownership gaps * docs: explain simulator bridge language choice
This commit is contained in:
committed by
GitHub
parent
17b6ca36f8
commit
5bb3ea3b2a
@@ -300,6 +300,29 @@
|
||||
"file": "packages/platform-android/src/mechanics.ts",
|
||||
"exports": ["*"]
|
||||
},
|
||||
{
|
||||
"comment": "Apple Simulator snapshot acquisition is a private package facet consumed by downstream runtime work; Fallow cannot see external consumers through the workspace exports map. Keep only the deliberately narrow public factory, source interface, and request/outcome types here; host injection, cache metadata, and preparation internals stay private.",
|
||||
"file": "packages/platform-apple/src/snapshot-source-facade.ts",
|
||||
"exports": [
|
||||
"createSimulatorSnapshotSource",
|
||||
"SimulatorSnapshotSource",
|
||||
"SnapshotSourceFailure",
|
||||
"SnapshotSourceFailureKind",
|
||||
"SnapshotSourceLimits",
|
||||
"SnapshotSourceOutcome",
|
||||
"SnapshotSourceRequest",
|
||||
"SnapshotSourceTarget"
|
||||
]
|
||||
},
|
||||
{
|
||||
"comment": "The native wire vocabulary is exported only so the parity test can pin the Objective-C literals; it is not part of the snapshot-source package facet.",
|
||||
"file": "packages/platform-apple/src/snapshot-source/protocol.ts",
|
||||
"exports": [
|
||||
"SNAPSHOT_SOURCE_ATTRIBUTE_KEYS",
|
||||
"SNAPSHOT_SOURCE_RESPONSE_KEYS",
|
||||
"SNAPSHOT_SOURCE_WIRE_KEYS"
|
||||
]
|
||||
},
|
||||
{
|
||||
"comment": "Deliberately kept off the @agent-device/maestro façade (index.test.ts asserts its absence) and consumed only by the conformance harness under packages/maestro/test/.",
|
||||
"file": "packages/maestro/src/internal/program-ir-command-parser.ts",
|
||||
|
||||
@@ -126,6 +126,24 @@ jobs:
|
||||
runtime-version: ${{ env.IOS_RUNTIME_VERSION }}
|
||||
preferred-device-name: iPhone 17 Pro
|
||||
|
||||
- name: Verify clean-installed Simulator snapshot bridge preparation
|
||||
if: github.event_name == 'pull_request'
|
||||
env:
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
run: |
|
||||
git fetch origin "$BASE_SHA" --depth=1
|
||||
if git diff --quiet "$BASE_SHA"...HEAD -- \
|
||||
apple/snapshot-bridge \
|
||||
packages/platform-apple/src/snapshot-source \
|
||||
scripts/check-package.ts \
|
||||
scripts/size-report-install.mjs \
|
||||
scripts/size-report-package.mjs; then
|
||||
echo "Snapshot bridge packaging is unchanged; skipping preparation proof."
|
||||
exit 0
|
||||
fi
|
||||
pnpm build
|
||||
pnpm check:package -- --verify-snapshot-bridge-preparation
|
||||
|
||||
- name: Run targeted iOS runner XCTest regressions
|
||||
run: |
|
||||
XCTESTRUN_PATH="$(find "$AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH/Build/Products" -maxdepth 1 -name '*.xctestrun' -print -quit)"
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
The Simulator AX bridge contains code adapted from Meta Platforms, Inc. idb
|
||||
v1.5.2, specifically SimulatorFrameworkBridge/AccessibilityService.m and
|
||||
SimulatorFrameworkBridge/AccessibilityRuntime.m.
|
||||
|
||||
Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,39 @@
|
||||
# Simulator AX bridge
|
||||
|
||||
This directory contains the small private accessibility reader used by the
|
||||
Apple platform acquisition facet. The framed server and request validation
|
||||
live in `SnapshotBridge.m`; private runtime binding lives in
|
||||
`SnapshotBridgeRuntime.m`. It is compiled for the iOS Simulator on first use
|
||||
and is never downloaded, pre-signed, or built by npm installation.
|
||||
|
||||
The guest process uses the `XCTAccessibilityFramework` remote-access client
|
||||
from the simulator runtime and the `userTestingSnapshotForElement:options:error:`
|
||||
single-fetch API. Requests and responses are length-prefixed JSON frames:
|
||||
|
||||
```text
|
||||
uint32 big-endian byte length
|
||||
UTF-8 JSON object
|
||||
```
|
||||
|
||||
The host owns all target identity, bounds, deadlines, and lifecycle decisions.
|
||||
The guest returns only a bounded raw tree, the target pid, truncation, and
|
||||
protocol/source versions. It does not expose an HTTP route or a public CLI
|
||||
surface.
|
||||
|
||||
The private API is intentionally pinned to the idb v1.5.2-compatible shape.
|
||||
See `LICENSE.idb` for attribution.
|
||||
|
||||
## Why Objective-C
|
||||
|
||||
The selected #2192 mechanism was idb v1.5.2's Objective-C
|
||||
`SimulatorFrameworkBridge`; the Python used during the spike was only a client
|
||||
for exercising that guest reader. This bridge keeps the proven native boundary
|
||||
and removes the Python/idb client dependency.
|
||||
|
||||
Objective-C is the narrowest implementation for this private runtime adapter:
|
||||
it resolves unavailable classes and functions with `dlopen`, `dlsym`, and the
|
||||
Objective-C runtime, invokes dynamically discovered selectors, and contains
|
||||
`NSException` failures. A Swift implementation would still require an
|
||||
Objective-C shim for those operations, adding another native boundary. Keeping
|
||||
the guest in Objective-C also allows direct lazy compilation with `clang`
|
||||
without an Xcode project or Swift module for private headers.
|
||||
@@ -0,0 +1,280 @@
|
||||
/*
|
||||
* The framed server and request validation for the private Simulator AX reader.
|
||||
* The runtime binding is isolated in SnapshotBridgeRuntime.m.
|
||||
*/
|
||||
|
||||
#import "SnapshotBridgeRuntime.h"
|
||||
|
||||
#import <Foundation/Foundation.h>
|
||||
|
||||
#import <arpa/inet.h>
|
||||
#import <errno.h>
|
||||
#import <limits.h>
|
||||
#import <math.h>
|
||||
#import <poll.h>
|
||||
#import <sys/socket.h>
|
||||
#import <sys/stat.h>
|
||||
#import <sys/types.h>
|
||||
#import <sys/un.h>
|
||||
#import <unistd.h>
|
||||
|
||||
static const int kDefaultIdleTimeoutSeconds = 60;
|
||||
|
||||
static void bridgeLog(NSString *message)
|
||||
{
|
||||
fprintf(stderr, "[agent-device-snapshot-bridge] %s\n", message.UTF8String ?: "(no message)");
|
||||
fflush(stderr);
|
||||
}
|
||||
|
||||
NSDictionary *failureResponse(NSString *requestId,
|
||||
NSString *kind,
|
||||
NSString *code,
|
||||
NSString *message)
|
||||
{
|
||||
return @{
|
||||
kProtocolVersionKey : @(kProtocolVersion),
|
||||
kSourceVersionKey : kSourceVersion,
|
||||
kRequestIdKey : requestId ?: @"",
|
||||
@"ok" : @NO,
|
||||
@"error_kind" : kind ?: @"reader_unavailable",
|
||||
@"error_code" : code ?: @"unknown",
|
||||
@"error" : message ?: @"snapshot bridge request failed",
|
||||
};
|
||||
}
|
||||
|
||||
static BOOL validBoundInteger(id value, NSUInteger minimum, NSUInteger maximum, NSUInteger *output)
|
||||
{
|
||||
if (![value isKindOfClass:NSNumber.class]) return NO;
|
||||
NSNumber *number = value;
|
||||
if (number.doubleValue != floor(number.doubleValue)) return NO;
|
||||
if (number.unsignedIntegerValue < minimum || number.unsignedIntegerValue > maximum) return NO;
|
||||
if (output) *output = number.unsignedIntegerValue;
|
||||
return YES;
|
||||
}
|
||||
|
||||
static NSDictionary *handleRequest(NSDictionary *request)
|
||||
{
|
||||
NSString *requestId = [request[kRequestIdKey] isKindOfClass:NSString.class] ? request[kRequestIdKey] : @"";
|
||||
id verb = request[@"verb"];
|
||||
if (![verb isKindOfClass:NSString.class] || ![verb isEqualToString:@"describe"]) {
|
||||
return failureResponse(requestId, @"bad_request", @"verb-not-supported", @"snapshot bridge accepts describe requests only");
|
||||
}
|
||||
NSNumber *pidValue = request[@"pid"];
|
||||
if (!validBoundInteger(pidValue, 1, INT_MAX, NULL)) {
|
||||
return failureResponse(requestId, @"bad_request", @"pid-required", @"describe requires a positive target pid");
|
||||
}
|
||||
NSString *generation = [request[@"generation"] isKindOfClass:NSString.class] ? request[@"generation"] : @"";
|
||||
if (generation.length == 0) {
|
||||
return failureResponse(requestId, @"bad_request", @"generation-required", @"describe requires an opaque target generation");
|
||||
}
|
||||
id snapshotTree = request[@"snapshotTree"];
|
||||
if (![snapshotTree isKindOfClass:NSNumber.class] || ![snapshotTree boolValue]) {
|
||||
return failureResponse(requestId, @"bad_request", @"snapshot-tree-required", @"snapshotTree must be enabled");
|
||||
}
|
||||
id automationMode = request[@"automationMode"];
|
||||
if (![automationMode isKindOfClass:NSNumber.class] || ![automationMode boolValue]) {
|
||||
return failureResponse(requestId, @"bad_request", @"automation-mode-required", @"automationMode must be enabled");
|
||||
}
|
||||
NSUInteger maxDepth = 0;
|
||||
NSUInteger maxNodes = 0;
|
||||
NSUInteger maxDurationMs = 0;
|
||||
NSUInteger maxResponseBytes = 0;
|
||||
if (!validBoundInteger(request[@"maxDepth"], 0, kMaximumDepth, &maxDepth) ||
|
||||
!validBoundInteger(request[@"maxNodes"], 1, kMaximumNodes, &maxNodes) ||
|
||||
!validBoundInteger(request[@"maxDurationMs"], 1, kMaximumDurationMs, &maxDurationMs) ||
|
||||
!validBoundInteger(request[@"maxResponseBytes"], 1024, kMaximumFrameBytes, &maxResponseBytes)) {
|
||||
return failureResponse(requestId, @"bad_request", @"bounds-invalid", @"snapshot bridge request bounds are outside the bridge limits");
|
||||
}
|
||||
|
||||
NSString *setupError = nil;
|
||||
BridgeRuntime *runtime = sharedRuntime(&setupError);
|
||||
if (!runtime) {
|
||||
NSMutableDictionary *unavailable = [failureResponse(requestId, @"unsupported", @"runtime-unavailable", setupError) mutableCopy];
|
||||
unavailable[@"pid"] = pidValue;
|
||||
unavailable[@"generation"] = generation;
|
||||
return unavailable;
|
||||
}
|
||||
NSDictionary *error = nil;
|
||||
NSDictionary *response = [runtime snapshotForProcess:pidValue.intValue
|
||||
maxDepth:maxDepth
|
||||
maxNodes:maxNodes
|
||||
requestId:requestId
|
||||
generation:generation
|
||||
maxDurationMs:maxDurationMs
|
||||
error:&error];
|
||||
if (response) return response;
|
||||
if (error) {
|
||||
NSMutableDictionary *annotated = [error mutableCopy];
|
||||
annotated[@"pid"] = pidValue;
|
||||
annotated[@"generation"] = generation;
|
||||
return annotated;
|
||||
}
|
||||
return failureResponse(requestId, @"reader_unavailable", @"empty-response", @"AX bridge returned no response");
|
||||
}
|
||||
|
||||
static BOOL readFully(int fd, void *buffer, size_t length)
|
||||
{
|
||||
size_t offset = 0;
|
||||
while (offset < length) {
|
||||
ssize_t count = recv(fd, (char *)buffer + offset, length - offset, 0);
|
||||
if (count > 0) {
|
||||
offset += (size_t)count;
|
||||
continue;
|
||||
}
|
||||
if (count < 0 && errno == EINTR) continue;
|
||||
return NO;
|
||||
}
|
||||
return YES;
|
||||
}
|
||||
|
||||
static BOOL writeFully(int fd, const void *buffer, size_t length)
|
||||
{
|
||||
size_t offset = 0;
|
||||
while (offset < length) {
|
||||
ssize_t count = send(fd, (const char *)buffer + offset, length - offset, MSG_NOSIGNAL);
|
||||
if (count > 0) {
|
||||
offset += (size_t)count;
|
||||
continue;
|
||||
}
|
||||
if (count < 0 && errno == EINTR) continue;
|
||||
return NO;
|
||||
}
|
||||
return YES;
|
||||
}
|
||||
|
||||
static NSData *serializedResponse(NSDictionary *response, NSUInteger maxResponseBytes)
|
||||
{
|
||||
NSError *error = nil;
|
||||
NSData *data = nil;
|
||||
@try {
|
||||
data = [NSJSONSerialization dataWithJSONObject:response options:0 error:&error];
|
||||
if (data && data.length + sizeof(uint32_t) <= maxResponseBytes) return data;
|
||||
} @catch (NSException *exception) {
|
||||
bridgeLog(exception.reason ?: @"response serialization raised an exception");
|
||||
}
|
||||
NSMutableDictionary *fallback = [failureResponse(
|
||||
response[kRequestIdKey],
|
||||
data ? @"response_limit_exceeded" : @"malformed_tree",
|
||||
data ? @"response-too-large" : @"response-not-json-safe",
|
||||
data ? @"snapshot response exceeds the per-request response bound" : (error.localizedDescription ?: @"response was not JSON serializable")) mutableCopy];
|
||||
if (response[@"pid"] != nil) fallback[@"pid"] = response[@"pid"];
|
||||
if (response[@"generation"] != nil) fallback[@"generation"] = response[@"generation"];
|
||||
return [NSJSONSerialization dataWithJSONObject:fallback options:0 error:NULL];
|
||||
}
|
||||
|
||||
static NSUInteger responseLimitForRequest(id request)
|
||||
{
|
||||
if (![request isKindOfClass:NSDictionary.class]) return kMaximumFrameBytes;
|
||||
NSNumber *value = request[@"maxResponseBytes"];
|
||||
if (![value isKindOfClass:NSNumber.class]) return kMaximumFrameBytes;
|
||||
NSUInteger result = value.unsignedIntegerValue;
|
||||
return result >= 1024 && result <= kMaximumFrameBytes ? result : kMaximumFrameBytes;
|
||||
}
|
||||
|
||||
static int serve(NSString *socketPath, int idleTimeoutSeconds, BOOL exitOnDisconnect)
|
||||
{
|
||||
if (socketPath.length == 0 || socketPath.length >= sizeof(((struct sockaddr_un *)0)->sun_path)) {
|
||||
bridgeLog(@"socket path is empty or too long");
|
||||
return 1;
|
||||
}
|
||||
|
||||
int listener = socket(AF_UNIX, SOCK_STREAM, 0);
|
||||
if (listener < 0) {
|
||||
bridgeLog([NSString stringWithFormat:@"socket failed: %s", strerror(errno)]);
|
||||
return 1;
|
||||
}
|
||||
struct sockaddr_un address = {0};
|
||||
address.sun_family = AF_UNIX;
|
||||
strlcpy(address.sun_path, socketPath.fileSystemRepresentation, sizeof(address.sun_path));
|
||||
unlink(address.sun_path);
|
||||
if (bind(listener, (struct sockaddr *)&address, sizeof(address)) != 0 || listen(listener, 4) != 0) {
|
||||
bridgeLog([NSString stringWithFormat:@"bind/listen failed for %@: %s", socketPath, strerror(errno)]);
|
||||
close(listener);
|
||||
return 1;
|
||||
}
|
||||
chmod(address.sun_path, S_IRUSR | S_IWUSR);
|
||||
bridgeLog([NSString stringWithFormat:@"serving protocol %lu on %@", (unsigned long)kProtocolVersion, socketPath]);
|
||||
|
||||
BOOL done = NO;
|
||||
while (!done) {
|
||||
struct pollfd waitForClient = {.fd = listener, .events = POLLIN, .revents = 0};
|
||||
int ready = poll(&waitForClient, 1, idleTimeoutSeconds * 1000);
|
||||
if (ready == 0) break;
|
||||
if (ready < 0) {
|
||||
if (errno == EINTR) continue;
|
||||
break;
|
||||
}
|
||||
int connection = accept(listener, NULL, NULL);
|
||||
if (connection < 0) {
|
||||
if (errno == EINTR) continue;
|
||||
break;
|
||||
}
|
||||
struct timeval timeout = {.tv_sec = idleTimeoutSeconds, .tv_usec = 0};
|
||||
setsockopt(connection, SOL_SOCKET, SO_RCVTIMEO, &timeout, sizeof(timeout));
|
||||
setsockopt(connection, SOL_SOCKET, SO_SNDTIMEO, &timeout, sizeof(timeout));
|
||||
while (YES) {
|
||||
@autoreleasepool {
|
||||
uint32_t networkLength = 0;
|
||||
if (!readFully(connection, &networkLength, sizeof(networkLength))) break;
|
||||
uint32_t length = ntohl(networkLength);
|
||||
if (length == 0 || length > kMaximumFrameBytes) break;
|
||||
NSMutableData *body = [NSMutableData dataWithLength:length];
|
||||
if (!readFully(connection, body.mutableBytes, length)) break;
|
||||
id parsed = [NSJSONSerialization JSONObjectWithData:body options:0 error:NULL];
|
||||
NSDictionary *response = [parsed isKindOfClass:NSDictionary.class]
|
||||
? handleRequest(parsed)
|
||||
: failureResponse(@"", @"bad_request", @"json-object-required", @"request frame must be a JSON object");
|
||||
NSData *encoded = serializedResponse(response, responseLimitForRequest(parsed));
|
||||
if (encoded.length > kMaximumFrameBytes) break;
|
||||
uint32_t responseLength = htonl((uint32_t)encoded.length);
|
||||
if (!writeFully(connection, &responseLength, sizeof(responseLength)) ||
|
||||
!writeFully(connection, encoded.bytes, encoded.length)) break;
|
||||
}
|
||||
}
|
||||
close(connection);
|
||||
if (exitOnDisconnect) done = YES;
|
||||
}
|
||||
|
||||
close(listener);
|
||||
unlink(address.sun_path);
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int integerArgument(NSArray<NSString *> *arguments, NSString *flag, int fallback)
|
||||
{
|
||||
for (NSUInteger index = 0; index + 1 < arguments.count; index += 1) {
|
||||
if (![arguments[index] isEqualToString:flag]) continue;
|
||||
NSInteger value = arguments[index + 1].integerValue;
|
||||
if (value > 0 && value <= INT_MAX) return (int)value;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
static BOOL boolArgument(NSArray<NSString *> *arguments, NSString *flag, BOOL fallback)
|
||||
{
|
||||
for (NSUInteger index = 0; index + 1 < arguments.count; index += 1) {
|
||||
if ([arguments[index] isEqualToString:flag]) return [arguments[index + 1] boolValue];
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
int main(int argc, const char *argv[])
|
||||
{
|
||||
@autoreleasepool {
|
||||
if (argc < 3 || strcmp(argv[1], "serve") != 0) {
|
||||
fprintf(stderr, "Usage: %s serve <socket> [--idle-timeout <seconds>] [--exit-on-disconnect <bool>]\n", argv[0]);
|
||||
return 2;
|
||||
}
|
||||
NSMutableArray<NSString *> *arguments = [NSMutableArray array];
|
||||
for (int index = 2; index < argc; index += 1) {
|
||||
NSString *value = [NSString stringWithUTF8String:argv[index]];
|
||||
if (value) [arguments addObject:value];
|
||||
}
|
||||
NSString *socketPath = arguments.firstObject;
|
||||
if (socketPath.length == 0) return 2;
|
||||
NSArray<NSString *> *flags = [arguments subarrayWithRange:NSMakeRange(1, arguments.count - 1)];
|
||||
return serve(socketPath,
|
||||
integerArgument(flags, @"--idle-timeout", kDefaultIdleTimeoutSeconds),
|
||||
boolArgument(flags, @"--exit-on-disconnect", YES));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
#import <Foundation/Foundation.h>
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
extern NSString *const kProtocolVersionKey;
|
||||
extern NSString *const kSourceVersionKey;
|
||||
extern NSString *const kRequestIdKey;
|
||||
extern NSString *const kSourceVersion;
|
||||
extern const NSUInteger kProtocolVersion;
|
||||
extern const uint32_t kMaximumFrameBytes;
|
||||
extern const NSUInteger kMaximumDepth;
|
||||
extern const NSUInteger kMaximumNodes;
|
||||
extern const NSUInteger kMaximumDurationMs;
|
||||
|
||||
NSDictionary *failureResponse(NSString *requestId,
|
||||
NSString *kind,
|
||||
NSString *code,
|
||||
NSString *message);
|
||||
|
||||
@interface BridgeRuntime : NSObject
|
||||
- (nullable instancetype)initWithError:(NSString *_Nullable *_Nullable)error;
|
||||
- (nullable NSDictionary *)snapshotForProcess:(pid_t)pid
|
||||
maxDepth:(NSUInteger)maxDepth
|
||||
maxNodes:(NSUInteger)maxNodes
|
||||
requestId:(NSString *)requestId
|
||||
generation:(NSString *)generation
|
||||
maxDurationMs:(NSUInteger)maxDurationMs
|
||||
error:(NSDictionary *_Nullable *_Nonnull)error;
|
||||
@end
|
||||
|
||||
BridgeRuntime *_Nullable sharedRuntime(NSString *_Nullable *_Nullable error);
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
@@ -0,0 +1,359 @@
|
||||
/*
|
||||
* The private Simulator AX reader is adapted from Meta Platforms, Inc. idb v1.5.2
|
||||
* SimulatorFrameworkBridge/AccessibilityService.m and AccessibilityRuntime.m.
|
||||
* See LICENSE.idb for the upstream notice and license.
|
||||
*/
|
||||
|
||||
#import "SnapshotBridgeRuntime.h"
|
||||
|
||||
#import <CoreGraphics/CoreGraphics.h>
|
||||
#import <objc/message.h>
|
||||
#import <objc/runtime.h>
|
||||
|
||||
#import <dlfcn.h>
|
||||
#import <signal.h>
|
||||
#import <unistd.h>
|
||||
|
||||
NSString *const kProtocolVersionKey = @"protocolVersion";
|
||||
NSString *const kSourceVersionKey = @"sourceVersion";
|
||||
NSString *const kRequestIdKey = @"requestId";
|
||||
NSString *const kSourceVersion = @"agent-device-simulator-ax-v1.5.3";
|
||||
const NSUInteger kProtocolVersion = 1;
|
||||
const uint32_t kMaximumFrameBytes = 16 * 1024 * 1024;
|
||||
const NSUInteger kMaximumDepth = 128;
|
||||
const NSUInteger kMaximumNodes = 10000;
|
||||
const NSUInteger kMaximumDurationMs = 120000;
|
||||
|
||||
static NSString *const kAttributeElementType = @"XC_kAXXCAttributeElementType";
|
||||
static NSString *const kAttributeElementBaseType = @"XC_kAXXCAttributeElementBaseType";
|
||||
static NSString *const kAttributeLabel = @"XC_kAXXCAttributeLabel";
|
||||
static NSString *const kAttributeValue = @"XC_kAXXCAttributeValue";
|
||||
static NSString *const kAttributeIdentifier = @"XC_kAXXCAttributeIdentifier";
|
||||
static NSString *const kAttributeFrame = @"XC_kAXXCAttributeFrame";
|
||||
static NSString *const kAttributeAutomationType = @"XC_kAXXCAttributeAutomationType";
|
||||
static NSString *const kAttributeChildren = @"XC_kAXXCAttributeChildren";
|
||||
static NSString *const kSnapshotAttributes = @"UIAccessibilitySnapshotKeyAttributes";
|
||||
static NSString *const kSnapshotChildren = @"UIAccessibilitySnapshotKeyChildren";
|
||||
static NSString *const kXctAutomationSupportPath =
|
||||
@"/Developer/Library/PrivateFrameworks/XCTAutomationSupport.framework/XCTAutomationSupport";
|
||||
static NSString *const kAxRuntimePath =
|
||||
@"/System/Library/PrivateFrameworks/AXRuntime.framework/AXRuntime";
|
||||
static NSString *const kAccessibilityErrorKey = @"accessibility-error";
|
||||
|
||||
typedef NSDictionary<NSString *, id> *_Nullable (*DefaultSnapshotParametersFn)(void);
|
||||
typedef NSArray<NSNumber *> *_Nullable (*AttributeNumbersForNamesFn)(NSArray<NSString *> *names);
|
||||
typedef uint32_t (*AXValueGetTypeFn)(const void *value);
|
||||
typedef Boolean (*AXValueGetValueFn)(const void *value, uint32_t type, void *out);
|
||||
typedef bool (*AutomationEnabledFn)(void);
|
||||
|
||||
@interface XCTAccessibilityFramework : NSObject
|
||||
- (instancetype)initForRemoteAccess;
|
||||
- (nullable id)userTestingSnapshotForElement:(id)element
|
||||
options:(NSDictionary<NSString *, id> *)options
|
||||
error:(NSError **)error;
|
||||
@end
|
||||
|
||||
@interface XCAccessibilityElement : NSObject
|
||||
- (nullable void *)AXUIElement;
|
||||
@end
|
||||
|
||||
@protocol XCAccessibilityElementFactory <NSObject>
|
||||
+ (nullable XCAccessibilityElement *)elementWithProcessIdentifier:(pid_t)pid;
|
||||
@end
|
||||
|
||||
static NSNumber *finiteNumber(double value)
|
||||
{
|
||||
return isfinite(value) ? @(value) : nil;
|
||||
}
|
||||
|
||||
static NSDictionary *_Nullable rectDictionary(CGRect rect)
|
||||
{
|
||||
NSNumber *x = finiteNumber(rect.origin.x);
|
||||
NSNumber *y = finiteNumber(rect.origin.y);
|
||||
NSNumber *width = finiteNumber(rect.size.width);
|
||||
NSNumber *height = finiteNumber(rect.size.height);
|
||||
if (!x || !y || !width || !height) {
|
||||
return nil;
|
||||
}
|
||||
return @{ @"X" : x, @"Y" : y, @"Width" : width, @"Height" : height };
|
||||
}
|
||||
|
||||
@interface SnapshotWatchdogState : NSObject
|
||||
@property(atomic) BOOL completed;
|
||||
@end
|
||||
|
||||
@implementation SnapshotWatchdogState
|
||||
@end
|
||||
|
||||
static dispatch_source_t startRequestWatchdog(NSUInteger durationMs, SnapshotWatchdogState *state)
|
||||
{
|
||||
dispatch_source_t watchdog = dispatch_source_create(
|
||||
DISPATCH_SOURCE_TYPE_TIMER,
|
||||
0,
|
||||
0,
|
||||
dispatch_get_global_queue(QOS_CLASS_UTILITY, 0));
|
||||
dispatch_source_set_timer(
|
||||
watchdog,
|
||||
dispatch_time(DISPATCH_TIME_NOW, (uint64_t)durationMs * NSEC_PER_MSEC),
|
||||
DISPATCH_TIME_FOREVER,
|
||||
0);
|
||||
dispatch_source_set_event_handler(watchdog, ^{
|
||||
if (!state.completed) kill(getpid(), SIGKILL);
|
||||
});
|
||||
dispatch_resume(watchdog);
|
||||
return watchdog;
|
||||
}
|
||||
|
||||
static void finishRequestWatchdog(dispatch_source_t watchdog, SnapshotWatchdogState *state)
|
||||
{
|
||||
state.completed = YES;
|
||||
dispatch_source_cancel(watchdog);
|
||||
}
|
||||
|
||||
@implementation BridgeRuntime {
|
||||
XCTAccessibilityFramework *_framework;
|
||||
Class<XCAccessibilityElementFactory> _elementClass;
|
||||
DefaultSnapshotParametersFn _defaultSnapshotParameters;
|
||||
AttributeNumbersForNamesFn _attributeNumbersForNames;
|
||||
AXValueGetTypeFn _valueGetType;
|
||||
AXValueGetValueFn _valueGetValue;
|
||||
AutomationEnabledFn _automationEnabled;
|
||||
}
|
||||
|
||||
- (nullable instancetype)initWithError:(NSString *_Nullable *_Nullable)error
|
||||
{
|
||||
self = [super init];
|
||||
if (!self) return nil;
|
||||
|
||||
dlopen(kAxRuntimePath.UTF8String, RTLD_NOW);
|
||||
dlopen(kXctAutomationSupportPath.UTF8String, RTLD_NOW);
|
||||
|
||||
Class frameworkClass = objc_lookUpClass("XCTAccessibilityFramework");
|
||||
_elementClass = (Class<XCAccessibilityElementFactory>)objc_lookUpClass("XCAccessibilityElement");
|
||||
if (!frameworkClass || !_elementClass) {
|
||||
if (error) *error = @"XCTAutomationSupport accessibility classes are unavailable";
|
||||
return nil;
|
||||
}
|
||||
|
||||
_framework = [(XCTAccessibilityFramework *)[frameworkClass alloc] initForRemoteAccess];
|
||||
if (!_framework || ![_framework respondsToSelector:@selector(userTestingSnapshotForElement:options:error:)]) {
|
||||
if (error) *error = @"XCTAccessibilityFramework snapshot API is unavailable";
|
||||
return nil;
|
||||
}
|
||||
|
||||
_defaultSnapshotParameters = (DefaultSnapshotParametersFn)dlsym(RTLD_DEFAULT, "XCTDefaultSnapshotParameters");
|
||||
_attributeNumbersForNames = (AttributeNumbersForNamesFn)dlsym(
|
||||
RTLD_DEFAULT, "XCAXAccessibilityAttributesForStringAttributes");
|
||||
_valueGetType = (AXValueGetTypeFn)dlsym(RTLD_DEFAULT, "AXValueGetType");
|
||||
_valueGetValue = (AXValueGetValueFn)dlsym(RTLD_DEFAULT, "AXValueGetValue");
|
||||
_automationEnabled = (AutomationEnabledFn)dlsym(RTLD_DEFAULT, "_AXSAutomationEnabled");
|
||||
if (!_defaultSnapshotParameters || !_attributeNumbersForNames || !_valueGetType || !_valueGetValue) {
|
||||
if (error) *error = @"AX snapshot conversion functions are unavailable";
|
||||
return nil;
|
||||
}
|
||||
return self;
|
||||
}
|
||||
|
||||
- (BOOL)assertAutomationMode:(BOOL)wanted
|
||||
{
|
||||
Class settingsClass = NSClassFromString(@"AXSettings");
|
||||
SEL sharedInstance = NSSelectorFromString(@"sharedInstance");
|
||||
SEL setter = NSSelectorFromString(@"setAutomationEnabled:");
|
||||
if (settingsClass && [settingsClass respondsToSelector:sharedInstance]) {
|
||||
id settings = ((id (*)(id, SEL))objc_msgSend)(settingsClass, sharedInstance);
|
||||
if ([settings respondsToSelector:setter]) {
|
||||
((void (*)(id, SEL, BOOL))objc_msgSend)(settings, setter, wanted);
|
||||
}
|
||||
}
|
||||
return _automationEnabled != NULL && _automationEnabled();
|
||||
}
|
||||
|
||||
- (nullable id)jsonValue:(id)value name:(NSString *)name
|
||||
{
|
||||
if (!value || value == [NSNull null]) return nil;
|
||||
if ([value isKindOfClass:NSString.class] || [value isKindOfClass:NSNumber.class]) return value;
|
||||
|
||||
const void *raw = (__bridge const void *)value;
|
||||
if (_valueGetType(raw) == 3) {
|
||||
CGRect rect = CGRectZero;
|
||||
if (_valueGetValue(raw, 3, &rect)) return rectDictionary(rect);
|
||||
}
|
||||
if ([name isEqualToString:kAttributeFrame]) return nil;
|
||||
return nil;
|
||||
}
|
||||
|
||||
- (nullable NSDictionary *)nodeFromSnapshot:(id)snapshot
|
||||
namesByNumber:(NSDictionary<NSNumber *, NSString *> *)namesByNumber
|
||||
depth:(NSUInteger)depth
|
||||
maxDepth:(NSUInteger)maxDepth
|
||||
maxNodes:(NSUInteger)maxNodes
|
||||
count:(NSUInteger *)count
|
||||
truncated:(BOOL *)truncated
|
||||
malformed:(BOOL *)malformed
|
||||
{
|
||||
if (![snapshot isKindOfClass:NSDictionary.class]) {
|
||||
*malformed = YES;
|
||||
return nil;
|
||||
}
|
||||
if (*count >= maxNodes) {
|
||||
*truncated = YES;
|
||||
return nil;
|
||||
}
|
||||
(*count)++;
|
||||
|
||||
NSDictionary *attributes = ((NSDictionary *)snapshot)[kSnapshotAttributes];
|
||||
if (![attributes isKindOfClass:NSDictionary.class]) {
|
||||
*malformed = YES;
|
||||
return nil;
|
||||
}
|
||||
NSMutableDictionary *node = [NSMutableDictionary dictionary];
|
||||
for (NSNumber *number in attributes) {
|
||||
NSString *name = namesByNumber[number];
|
||||
if (!name || [name isEqualToString:kAttributeChildren]) continue;
|
||||
id safe = [self jsonValue:attributes[number] name:name];
|
||||
if (safe) node[name] = safe;
|
||||
}
|
||||
|
||||
NSArray *children = ((NSDictionary *)snapshot)[kSnapshotChildren];
|
||||
if (![children isKindOfClass:NSArray.class]) {
|
||||
*malformed = YES;
|
||||
return nil;
|
||||
}
|
||||
NSMutableArray *builtChildren = [NSMutableArray array];
|
||||
if (depth >= maxDepth) {
|
||||
if (children.count > 0) *truncated = YES;
|
||||
} else {
|
||||
for (id child in children) {
|
||||
NSDictionary *built = [self nodeFromSnapshot:child
|
||||
namesByNumber:namesByNumber
|
||||
depth:depth + 1
|
||||
maxDepth:maxDepth
|
||||
maxNodes:maxNodes
|
||||
count:count
|
||||
truncated:truncated
|
||||
malformed:malformed];
|
||||
if (built) [builtChildren addObject:built];
|
||||
if (*malformed) return nil;
|
||||
if (*truncated) break;
|
||||
if (*count >= maxNodes) {
|
||||
if (builtChildren.count < children.count) *truncated = YES;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
node[kAttributeChildren] = builtChildren;
|
||||
return node;
|
||||
}
|
||||
|
||||
- (nullable NSDictionary *)snapshotForProcess:(pid_t)pid
|
||||
maxDepth:(NSUInteger)maxDepth
|
||||
maxNodes:(NSUInteger)maxNodes
|
||||
requestId:(NSString *)requestId
|
||||
generation:(NSString *)generation
|
||||
maxDurationMs:(NSUInteger)maxDurationMs
|
||||
error:(NSDictionary *_Nullable *_Nonnull)error
|
||||
{
|
||||
SnapshotWatchdogState *watchdogState = [SnapshotWatchdogState new];
|
||||
dispatch_source_t watchdog = startRequestWatchdog(maxDurationMs, watchdogState);
|
||||
XCAccessibilityElement *root = [_elementClass elementWithProcessIdentifier:pid];
|
||||
if (!root) {
|
||||
if (error) *error = failureResponse(requestId, @"application_unavailable", @"application-element-missing", @"application element is unavailable");
|
||||
finishRequestWatchdog(watchdog, watchdogState);
|
||||
return nil;
|
||||
}
|
||||
void *raw = [root AXUIElement];
|
||||
if (!raw) {
|
||||
if (error) *error = failureResponse(requestId, @"application_unavailable", @"application-element-missing", @"application element is unavailable");
|
||||
finishRequestWatchdog(watchdog, watchdogState);
|
||||
return nil;
|
||||
}
|
||||
|
||||
NSArray<NSString *> *names = @[
|
||||
kAttributeElementType,
|
||||
kAttributeElementBaseType,
|
||||
kAttributeLabel,
|
||||
kAttributeValue,
|
||||
kAttributeIdentifier,
|
||||
kAttributeFrame,
|
||||
kAttributeAutomationType,
|
||||
kAttributeChildren,
|
||||
];
|
||||
NSArray<NSNumber *> *numbers = _attributeNumbersForNames(names);
|
||||
if (![numbers isKindOfClass:NSArray.class] || numbers.count != names.count) {
|
||||
if (error) *error = failureResponse(requestId, @"reader_unavailable", @"attribute-vocabulary-mismatch", @"AX attribute vocabulary is incompatible");
|
||||
finishRequestWatchdog(watchdog, watchdogState);
|
||||
return nil;
|
||||
}
|
||||
NSMutableDictionary<NSNumber *, NSString *> *namesByNumber = [NSMutableDictionary dictionary];
|
||||
[numbers enumerateObjectsUsingBlock:^(NSNumber *number, NSUInteger index, BOOL *stop) {
|
||||
(void)stop;
|
||||
if ([number isKindOfClass:NSNumber.class]) namesByNumber[number] = names[index];
|
||||
}];
|
||||
NSMutableDictionary *options = [_defaultSnapshotParameters() mutableCopy];
|
||||
if (!options) options = [NSMutableDictionary dictionary];
|
||||
options[@"attributes"] = numbers;
|
||||
options[@"maxDepth"] = @(maxDepth);
|
||||
options[@"maxChildren"] = @(maxNodes);
|
||||
options[@"maxArrayCount"] = @(maxNodes);
|
||||
BOOL automationEnabled = [self assertAutomationMode:YES];
|
||||
NSError *runtimeError = nil;
|
||||
id snapshot = nil;
|
||||
@try {
|
||||
snapshot = [_framework userTestingSnapshotForElement:(__bridge id)raw options:options error:&runtimeError];
|
||||
} @catch (NSException *exception) {
|
||||
if (error) *error = failureResponse(requestId, @"reader_unavailable", @"private-api-exception", exception.reason ?: @"AX snapshot raised an exception");
|
||||
finishRequestWatchdog(watchdog, watchdogState);
|
||||
return nil;
|
||||
}
|
||||
if (!snapshot) {
|
||||
NSNumber *axError = runtimeError.userInfo[kAccessibilityErrorKey];
|
||||
NSInteger code = [axError respondsToSelector:@selector(integerValue)] ? axError.integerValue : runtimeError.code;
|
||||
NSString *kind = code == -25216 ? @"application_not_responding" : @"application_unavailable";
|
||||
NSString *message = runtimeError.localizedDescription ?: @"AX snapshot returned no tree";
|
||||
if (error) *error = failureResponse(requestId, kind, code == -25216 ? @"application-timeout" : @"application-server-unavailable", message);
|
||||
finishRequestWatchdog(watchdog, watchdogState);
|
||||
return nil;
|
||||
}
|
||||
BOOL truncated = NO;
|
||||
BOOL malformed = NO;
|
||||
NSUInteger count = 0;
|
||||
NSDictionary *tree = [self nodeFromSnapshot:snapshot
|
||||
namesByNumber:namesByNumber
|
||||
depth:0
|
||||
maxDepth:maxDepth
|
||||
maxNodes:maxNodes
|
||||
count:&count
|
||||
truncated:&truncated
|
||||
malformed:&malformed];
|
||||
if (!tree) {
|
||||
if (error) *error = failureResponse(requestId, @"malformed_tree", malformed ? @"snapshot-tree-malformed" : @"snapshot-root-invalid", malformed ? @"AX snapshot contained a malformed node" : @"AX snapshot did not contain a materialized root node");
|
||||
finishRequestWatchdog(watchdog, watchdogState);
|
||||
return nil;
|
||||
}
|
||||
finishRequestWatchdog(watchdog, watchdogState);
|
||||
return @{
|
||||
kProtocolVersionKey : @(kProtocolVersion),
|
||||
kSourceVersionKey : kSourceVersion,
|
||||
kRequestIdKey : requestId ?: @"",
|
||||
@"generation" : generation ?: @"",
|
||||
@"ok" : @YES,
|
||||
@"pid" : @(pid),
|
||||
@"tree" : tree,
|
||||
@"truncated" : @(truncated),
|
||||
@"automationEnabled" : @(automationEnabled),
|
||||
};
|
||||
}
|
||||
@end
|
||||
BridgeRuntime *sharedRuntime(NSString **error)
|
||||
{
|
||||
static BridgeRuntime *runtime;
|
||||
static dispatch_once_t once;
|
||||
static NSString *setupError;
|
||||
dispatch_once(&once, ^{
|
||||
NSString *localError = nil;
|
||||
runtime = [[BridgeRuntime alloc] initWithError:&localError];
|
||||
setupError = [localError copy];
|
||||
});
|
||||
if (!runtime && error) *error = setupError ?: @"AX bridge runtime is unavailable";
|
||||
return runtime;
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
# iOS Simulator snapshot-source live evidence
|
||||
|
||||
- Issue: #2196
|
||||
- Observed: 2026-09-04T10:14:05Z
|
||||
- Revision: `597cb16db1`
|
||||
- Target: verified booted `iPhone 17 Pro` Simulator, iOS 26.2
|
||||
- UDID: `F7D6F9A4-4FCC-4DD7-AC0B-3280C9319CB9`
|
||||
- App: `Agent Device Tester` (`com.callstack.agentdevicelab`), initial PID `65124`, final PID `67942`
|
||||
- Workflow: `agent-device open` established the session; one instance of the public `@agent-device/platform-apple/snapshot-source` facet was called with raw projection. Eight warm acquires were followed by eight terminate/launch acquires carrying new app generations. No production routing or proxy path was used.
|
||||
|
||||
## Result
|
||||
|
||||
| Measurement | Observed |
|
||||
|---|---:|
|
||||
| Public-facet prime acquire (includes preparation) | 2462.4 ms |
|
||||
| Public-facet warm acquire p95 (8 samples) | 14.1 ms |
|
||||
| Public-facet relaunch acquire p95 (8 samples) | 97.1 ms |
|
||||
| Warm acquire range | 12.1–14.1 ms |
|
||||
| Relaunch acquire range | 38.3–97.1 ms |
|
||||
| Raw nodes | 77 |
|
||||
| Truncated | false |
|
||||
| Viewport | 402 x 874 |
|
||||
| Producer | `simulator-ax-bridge` |
|
||||
| Intent | `full` |
|
||||
| Residue | `hittability` unavailable |
|
||||
|
||||
The returned lineage carried each supplied target id and changed opaque generation. Every sample returned 77 raw nodes without truncation; the source did not claim hittability or interaction-query facts. The prime includes the one-time source/toolchain preparation; the source instance retained the successfully prepared binary for all later acquires.
|
||||
|
||||
## Build and cache
|
||||
|
||||
- Protocol version: `1`
|
||||
- Source version: `agent-device-simulator-ax-v1.5.3`
|
||||
- Source hash: `44e0c10dd5f0bf236c35293999e05d6bfaa740b492a98206da6dc1dec6f7d879`
|
||||
- Cache key: `0c73362db09451e54089e40d42c8f263`
|
||||
- The prime used the prepared disk-cache entry and completed source/toolchain validation once; deterministic tests cover cold publish, atomic concurrent publish, corrupt-entry rejection, source invalidation, and toolchain invalidation.
|
||||
- Closing the source after the measurement left no `snapshot-bridge` or `agent-device-ax-*` helper process.
|
||||
|
||||
## Package size
|
||||
|
||||
- Measured npm artifact at the revision above: 482 files, 1,035,582-byte tarball, 3,512,119 unpacked and clean-installed bytes.
|
||||
- The exact base/head delta is supplied by the GitHub Size workflow; its base-aware assertion does not require a bridge asset on a base commit that predates this facet.
|
||||
- The `apple-snapshot-bridge` component contributes 29,684 unpacked bytes across five published source/license/readme files.
|
||||
|
||||
## Boundary
|
||||
|
||||
- The first live attempt intentionally exercised the original long temp-socket path and failed closed with the guest's typed `socket path is empty or too long` diagnostic. The path was shortened to a per-host-process, target-hashed `/tmp` namespace before the successful retry.
|
||||
- Native sources compile with `clang -Werror -Wall -Wextra` for the iOS Simulator.
|
||||
- This is evidence for the private acquisition facet only. It does not authorize production snapshot routing, fallback, XCTest interaction, physical-device support, or a public CLI surface.
|
||||
|
||||
## Review reconciliation
|
||||
|
||||
The implementation remains one reviewable facet with four ownership layers: native AX acquisition,
|
||||
the framed wire contract, host-side build/cache, and helper lifecycle. The tests and gates stay beside
|
||||
those layers, including an explicit wire-vocabulary literal guard rather than a native-produced round
|
||||
trip claim. The change is intentionally not split into
|
||||
independently publishable commits because each layer is unusable without the adjacent protocol and
|
||||
lifecycle contract.
|
||||
|
||||
| Retained growth | Scope kept in the facet |
|
||||
|---|---|
|
||||
| Native runtime | Private AX binding, strict tree materialization, watchdog, and bounded response framing |
|
||||
| Host/cache | Toolchain-aware atomic build cache and clean-installed native source preparation |
|
||||
| Lifecycle/wire | Per-simulator generation routing, persistent helper reuse, typed failures, and reap recovery |
|
||||
| Proof | Vitest coverage topology, native/TypeScript vocabulary parity, size base/head handling, and live evidence |
|
||||
|
||||
The smaller alternatives were rejected for concrete boundary reasons: a generic cancellation protocol
|
||||
cannot interrupt the synchronous private AX call safely, so a per-request native watchdog plus exact
|
||||
helper reap is the bounded failure path; exposing host/cache injection would make test seams part of
|
||||
the public contract, so injection remains internal; and hashing the whole source directory would make
|
||||
README, license, and other package-only files rebuild the binary, so the cache fingerprints only the
|
||||
three native compile inputs.
|
||||
@@ -209,6 +209,7 @@
|
||||
"dist",
|
||||
"apple/macos-helper",
|
||||
"!apple/macos-helper/**/.build",
|
||||
"apple/snapshot-bridge",
|
||||
"android/snapshot-helper/dist",
|
||||
"!android/snapshot-helper/dist/*.idsig",
|
||||
"!android/snapshot-helper/README.md",
|
||||
|
||||
@@ -4,6 +4,9 @@
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Apple-family platform runtime metadata and implementations for agent-device.",
|
||||
"scripts": {
|
||||
"verify-installed-snapshot-bridge": "node --experimental-strip-types scripts/verify-installed-snapshot-bridge.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@agent-device/capture-kit": "workspace:*",
|
||||
"@agent-device/contracts": "workspace:*",
|
||||
@@ -53,6 +56,10 @@
|
||||
"types": "./src/runner/index.ts",
|
||||
"default": "./src/runner/index.ts"
|
||||
},
|
||||
"./snapshot-source": {
|
||||
"types": "./src/snapshot-source-facade.ts",
|
||||
"default": "./src/snapshot-source-facade.ts"
|
||||
},
|
||||
"./runner/operations": {
|
||||
"types": "./src/runner-operations-facade.ts",
|
||||
"default": "./src/runner-operations-facade.ts"
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import path from 'node:path';
|
||||
import { ensureSnapshotBridgeBinary } from '../src/snapshot-source/cache.ts';
|
||||
import { createSnapshotSourceDeadline } from '../src/snapshot-source/deadline.ts';
|
||||
import { createSnapshotSourceHost } from '../src/snapshot-source/host.ts';
|
||||
import { resolveSnapshotSourceLimits } from '../src/snapshot-source/limits.ts';
|
||||
|
||||
const [installedRoot, cacheRoot] = process.argv.slice(2);
|
||||
if (!installedRoot || !cacheRoot) {
|
||||
throw new Error('Usage: verify-installed-snapshot-bridge <installed-root> <cache-root>');
|
||||
}
|
||||
|
||||
const host = {
|
||||
...createSnapshotSourceHost(),
|
||||
projectRoot: () => installedRoot,
|
||||
};
|
||||
const limits = resolveSnapshotSourceLimits({ maxDurationMs: 120_000 });
|
||||
const prepared = await ensureSnapshotBridgeBinary({
|
||||
host,
|
||||
runtime: 'installed-package-verification',
|
||||
limits,
|
||||
deadline: createSnapshotSourceDeadline(limits.maxDurationMs, undefined),
|
||||
cacheRoot,
|
||||
});
|
||||
if (!prepared.path.startsWith(`${cacheRoot}${path.sep}`) || !host.exists(prepared.path)) {
|
||||
throw new Error('Installed snapshot bridge preparation did not publish its compiled binary.');
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* Dormant Simulator AX acquisition. The implementation is loaded only when a caller explicitly
|
||||
* creates the source; importing this facet keeps the platform package's startup surface inert.
|
||||
*/
|
||||
export type {
|
||||
SnapshotSourceFailure,
|
||||
SnapshotSourceFailureKind,
|
||||
SnapshotSourceLimits,
|
||||
SnapshotSourceOutcome,
|
||||
SnapshotSourceRequest,
|
||||
SnapshotSourceTarget,
|
||||
} from './snapshot-source/types.ts';
|
||||
|
||||
import type { SnapshotSourceOutcome, SnapshotSourceRequest } from './snapshot-source/types.ts';
|
||||
|
||||
export type SimulatorSnapshotSource = Readonly<{
|
||||
acquire(request: SnapshotSourceRequest): Promise<SnapshotSourceOutcome>;
|
||||
close(): Promise<void>;
|
||||
}>;
|
||||
|
||||
export function createSimulatorSnapshotSource(): SimulatorSnapshotSource {
|
||||
let implementation:
|
||||
| Promise<import('./snapshot-source/adapter.ts').SimulatorSnapshotSource>
|
||||
| undefined;
|
||||
let closed = false;
|
||||
const load = async () => {
|
||||
implementation ??= import('./snapshot-source/adapter.ts').then(
|
||||
({ createSimulatorSnapshotSource: create }) => create(),
|
||||
);
|
||||
return await implementation;
|
||||
};
|
||||
return {
|
||||
acquire: async (request) => {
|
||||
if (closed) {
|
||||
return {
|
||||
stage: 'failed',
|
||||
failure: { kind: 'unsupported', code: 'source-closed' },
|
||||
};
|
||||
}
|
||||
return await (await load()).acquire(request);
|
||||
},
|
||||
close: async () => {
|
||||
closed = true;
|
||||
if (implementation) await (await implementation).close();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { test } from 'vitest';
|
||||
import {
|
||||
createIosSnapshotRequest,
|
||||
deriveIosCaptureHint,
|
||||
} from '@agent-device/capture-kit/ios-snapshot-planning';
|
||||
import { createSnapshotSourceHost } from './host.ts';
|
||||
import { createSimulatorSnapshotSource } from './adapter.ts';
|
||||
import {
|
||||
encodeSnapshotBridgeFrame,
|
||||
SNAPSHOT_SOURCE_PROTOCOL_VERSION,
|
||||
SNAPSHOT_SOURCE_VERSION,
|
||||
} from './protocol.ts';
|
||||
import type { SnapshotSourceHost, SnapshotSourceProcess, SnapshotSourceSocket } from './types.ts';
|
||||
|
||||
test('the Simulator AX source returns raw acquisition facts and discloses unsupported facets', async () => {
|
||||
const root = await mkdtemp(path.join(os.tmpdir(), 'agent-device-snapshot-adapter-'));
|
||||
const sourceRoot = path.join(root, 'source');
|
||||
const cacheRoot = path.join(root, 'cache');
|
||||
await (await import('@agent-device/host-kit/host-file')).ensureHostDirectory(sourceRoot);
|
||||
await writeFile(path.join(sourceRoot, 'SnapshotBridge.m'), 'native source');
|
||||
await writeFile(path.join(sourceRoot, 'SnapshotBridgeRuntime.m'), 'native runtime');
|
||||
await writeFile(path.join(sourceRoot, 'SnapshotBridgeRuntime.h'), 'native header');
|
||||
const fixture = createAdapterHost();
|
||||
const source = createSimulatorSnapshotSource({
|
||||
host: fixture.host,
|
||||
sourceRoot,
|
||||
cacheRoot,
|
||||
limits: { maxNodes: 20, maxTraversalDepth: 10, maxDurationMs: 1000 },
|
||||
});
|
||||
const request = createIosSnapshotRequest({ interactiveOnly: true });
|
||||
const hint = deriveIosCaptureHint(request);
|
||||
|
||||
try {
|
||||
const result = await source.acquire({
|
||||
target: {
|
||||
udid: 'simulator-1',
|
||||
runtime: 'iOS 26.2',
|
||||
pid: 321,
|
||||
generation: 'generation-1',
|
||||
targetId: 'target-1',
|
||||
},
|
||||
hint,
|
||||
});
|
||||
assert.equal(fixture.builds, 1);
|
||||
assert.equal(fixture.runs, 6);
|
||||
assert.equal(result.stage, 'acquired');
|
||||
assert.equal(result.acquisition.producer, 'simulator-ax-bridge');
|
||||
assert.equal(result.acquisition.intent, 'full');
|
||||
assert.deepEqual(result.acquisition.hint, hint);
|
||||
assert.equal(result.acquisition.nodes[0]?.pid, 321);
|
||||
assert.deepEqual(result.acquisition.viewport, {
|
||||
kind: 'reported',
|
||||
rect: { x: 0, y: 0, width: 390, height: 844 },
|
||||
});
|
||||
assert.deepEqual(result.acquisition.lineage, {
|
||||
targetId: 'target-1',
|
||||
generation: 'generation-1',
|
||||
});
|
||||
assert.deepEqual(result.acquisition.residue, [
|
||||
{ kind: 'unavailable-fact', fact: 'hittability' },
|
||||
{ kind: 'unavailable-fact', fact: 'interactive-query' },
|
||||
]);
|
||||
|
||||
fixture.responsePid = 999;
|
||||
const outcome = await source.acquire({
|
||||
target: {
|
||||
udid: 'simulator-1',
|
||||
runtime: 'iOS 26.2',
|
||||
pid: 321,
|
||||
generation: 'generation-1',
|
||||
},
|
||||
hint,
|
||||
});
|
||||
assert.equal(outcome.stage, 'failed');
|
||||
if (outcome.stage === 'failed') assert.equal(outcome.failure.kind, 'stale-target');
|
||||
assert.equal(fixture.runs, 6);
|
||||
} finally {
|
||||
await source.close();
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('preparation consumes the same acquisition deadline as bridge I/O', async () => {
|
||||
const root = await mkdtemp(path.join(os.tmpdir(), 'agent-device-snapshot-adapter-deadline-'));
|
||||
const sourceRoot = path.join(root, 'source');
|
||||
const cacheRoot = path.join(root, 'cache');
|
||||
await (await import('@agent-device/host-kit/host-file')).ensureHostDirectory(sourceRoot);
|
||||
await writeFile(path.join(sourceRoot, 'SnapshotBridge.m'), 'native source');
|
||||
await writeFile(path.join(sourceRoot, 'SnapshotBridgeRuntime.m'), 'native runtime');
|
||||
await writeFile(path.join(sourceRoot, 'SnapshotBridgeRuntime.h'), 'native header');
|
||||
const fixture = createAdapterHost(150);
|
||||
const source = createSimulatorSnapshotSource({ host: fixture.host, sourceRoot, cacheRoot });
|
||||
const request = createIosSnapshotRequest();
|
||||
const hint = deriveIosCaptureHint(request);
|
||||
|
||||
try {
|
||||
const outcome = await source.acquire({
|
||||
target: { ...targetForTest(), generation: 'generation-1' },
|
||||
hint,
|
||||
limits: { maxDurationMs: 100 },
|
||||
});
|
||||
assert.equal(outcome.stage, 'failed');
|
||||
if (outcome.stage === 'failed') assert.equal(outcome.failure.kind, 'timeout');
|
||||
assert.equal(fixture.builds, 1);
|
||||
} finally {
|
||||
await source.close();
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
type AdapterFixture = {
|
||||
host: SnapshotSourceHost;
|
||||
builds: number;
|
||||
runs: number;
|
||||
responsePid: number;
|
||||
};
|
||||
|
||||
function targetForTest() {
|
||||
return {
|
||||
udid: 'simulator-1',
|
||||
runtime: 'iOS 26.2',
|
||||
pid: 321,
|
||||
};
|
||||
}
|
||||
|
||||
function createAdapterHost(buildDelayMs = 0): AdapterFixture {
|
||||
const realHost = createSnapshotSourceHost();
|
||||
const fixture: AdapterFixture = {
|
||||
host: undefined as never,
|
||||
builds: 0,
|
||||
runs: 0,
|
||||
responsePid: 321,
|
||||
};
|
||||
const host: SnapshotSourceHost = {
|
||||
...realHost,
|
||||
run: async (command, args) => {
|
||||
fixture.runs += 1;
|
||||
if (command === 'xcrun' && args.includes('clang')) {
|
||||
fixture.builds += 1;
|
||||
if (buildDelayMs > 0) await new Promise((resolve) => setTimeout(resolve, buildDelayMs));
|
||||
await writeFile(args.at(-1)!, 'bridge-binary');
|
||||
return { stdout: '', stderr: '', exitCode: 0 };
|
||||
}
|
||||
return {
|
||||
stdout:
|
||||
command === 'xcodebuild'
|
||||
? 'Xcode 16.4\nBuild version 16F6'
|
||||
: command === 'sw_vers'
|
||||
? '15.6'
|
||||
: command === 'uname'
|
||||
? 'arm64'
|
||||
: '26.2',
|
||||
stderr: '',
|
||||
exitCode: 0,
|
||||
};
|
||||
},
|
||||
start: () => new AdapterProcess(),
|
||||
connect: async () => new AdapterSocket(() => fixture.responsePid),
|
||||
readTargetProcessStartTime: async () => 'target-start',
|
||||
};
|
||||
fixture.host = host;
|
||||
return fixture;
|
||||
}
|
||||
|
||||
class AdapterProcess implements SnapshotSourceProcess {
|
||||
readonly pid = 801;
|
||||
readonly wait: Promise<{ stdout: string; stderr: string; exitCode: number }>;
|
||||
private resolveWait!: (result: { stdout: string; stderr: string; exitCode: number }) => void;
|
||||
private alive = true;
|
||||
|
||||
constructor() {
|
||||
this.wait = new Promise((resolve) => {
|
||||
this.resolveWait = resolve;
|
||||
});
|
||||
}
|
||||
|
||||
isAlive(): boolean {
|
||||
return this.alive;
|
||||
}
|
||||
|
||||
signal(): void {
|
||||
this.alive = false;
|
||||
this.resolveWait({ stdout: '', stderr: '', exitCode: 0 });
|
||||
}
|
||||
|
||||
readLog(): string {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
class AdapterSocket extends EventEmitter implements SnapshotSourceSocket {
|
||||
destroyed = false;
|
||||
private readonly readResponsePid: () => number;
|
||||
|
||||
constructor(responsePid: () => number) {
|
||||
super();
|
||||
this.readResponsePid = responsePid;
|
||||
}
|
||||
|
||||
write(frame: Buffer): boolean {
|
||||
const bodyLength = frame.readUInt32BE(0);
|
||||
const request = JSON.parse(frame.subarray(4, bodyLength + 4).toString('utf8')) as {
|
||||
requestId: string;
|
||||
pid: number;
|
||||
generation: string;
|
||||
};
|
||||
queueMicrotask(() => {
|
||||
if (this.destroyed) return;
|
||||
this.emit(
|
||||
'data',
|
||||
encodeSnapshotBridgeFrame(
|
||||
{
|
||||
protocolVersion: SNAPSHOT_SOURCE_PROTOCOL_VERSION,
|
||||
sourceVersion: SNAPSHOT_SOURCE_VERSION,
|
||||
requestId: request.requestId,
|
||||
ok: true,
|
||||
pid: this.readResponsePid(),
|
||||
generation: request.generation,
|
||||
truncated: false,
|
||||
automationEnabled: true,
|
||||
tree: {
|
||||
XC_kAXXCAttributeElementType: 'Application',
|
||||
XC_kAXXCAttributeFrame: { X: 0, Y: 0, Width: 390, Height: 844 },
|
||||
XC_kAXXCAttributeChildren: [],
|
||||
},
|
||||
},
|
||||
{
|
||||
maxRequestBytes: 64 * 1024,
|
||||
},
|
||||
),
|
||||
);
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
if (this.destroyed) return;
|
||||
this.destroyed = true;
|
||||
queueMicrotask(() => this.emit('close'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
import { AppError } from '@agent-device/kernel/errors';
|
||||
import type { CaptureHint, IosSnapshotAcquisition } from '@agent-device/contracts/ios-snapshot';
|
||||
import { ensureSnapshotBridgeBinary } from './cache.ts';
|
||||
import { createSnapshotSourceDeadline, remainingSnapshotSourceMs } from './deadline.ts';
|
||||
import { asSnapshotSourceError, snapshotSourceError } from './errors.ts';
|
||||
import { SnapshotBridgeManager } from './lifecycle.ts';
|
||||
import { resolveSnapshotSourceLimits } from './limits.ts';
|
||||
import type { SnapshotBridgeEnvelope } from './protocol.ts';
|
||||
import { decodeSnapshotBridgeTree } from './tree.ts';
|
||||
import { createSnapshotSourceHost } from './host.ts';
|
||||
import type {
|
||||
SnapshotSourceHost,
|
||||
SnapshotSourceBridgeBinary,
|
||||
SnapshotSourceLimits,
|
||||
SnapshotSourceOutcome,
|
||||
SnapshotSourceRequest,
|
||||
} from './types.ts';
|
||||
|
||||
const SNAPSHOT_SOURCE_PRODUCER = 'simulator-ax-bridge' as const;
|
||||
|
||||
export type SimulatorSnapshotSourceOptions = Readonly<{
|
||||
host?: SnapshotSourceHost;
|
||||
limits?: Partial<SnapshotSourceLimits>;
|
||||
sourceRoot?: string;
|
||||
cacheRoot?: string;
|
||||
}>;
|
||||
|
||||
export type SimulatorSnapshotSource = Readonly<{
|
||||
acquire(request: SnapshotSourceRequest): Promise<SnapshotSourceOutcome>;
|
||||
close(): Promise<void>;
|
||||
}>;
|
||||
|
||||
export function createSimulatorSnapshotSource(
|
||||
options: SimulatorSnapshotSourceOptions = {},
|
||||
): SimulatorSnapshotSource {
|
||||
const host = options.host ?? createSnapshotSourceHost();
|
||||
const manager = new SnapshotBridgeManager(host);
|
||||
const preparedBinaries = new Map<string, SnapshotSourceBridgeBinary>();
|
||||
let closed = false;
|
||||
|
||||
const prepare = async (
|
||||
input: Readonly<{
|
||||
runtime: string;
|
||||
limits: SnapshotSourceLimits;
|
||||
deadline: import('./deadline.ts').SnapshotSourceDeadline;
|
||||
}>,
|
||||
) => {
|
||||
if (closed) throw snapshotSourceError('unsupported', 'source-closed');
|
||||
const prepared = preparedBinaries.get(input.runtime);
|
||||
if (prepared) return prepared;
|
||||
const completed = await host.withDiagnosticTimer(
|
||||
'ios.snapshot-source.prepare',
|
||||
async () =>
|
||||
await ensureSnapshotBridgeBinary({
|
||||
host,
|
||||
runtime: input.runtime,
|
||||
limits: input.limits,
|
||||
deadline: input.deadline,
|
||||
sourceRoot: options.sourceRoot,
|
||||
cacheRoot: options.cacheRoot,
|
||||
}),
|
||||
{ producer: SNAPSHOT_SOURCE_PRODUCER },
|
||||
);
|
||||
preparedBinaries.set(input.runtime, completed);
|
||||
return completed;
|
||||
};
|
||||
|
||||
const acquire = async (request: SnapshotSourceRequest): Promise<SnapshotSourceOutcome> => {
|
||||
try {
|
||||
if (closed) throw snapshotSourceError('unsupported', 'source-closed');
|
||||
validateRequest(request);
|
||||
const limits = resolveSnapshotSourceLimits({ ...options.limits, ...request.limits });
|
||||
const deadline = createSnapshotSourceDeadline(limits.maxDurationMs, request.signal);
|
||||
const maxDepth = resolveRequestedDepth(request.hint, limits.maxTraversalDepth);
|
||||
return await host.withDiagnosticTimer(
|
||||
'ios.snapshot-source.acquire',
|
||||
async () => {
|
||||
const bridge = await prepare({
|
||||
runtime: request.target.runtime,
|
||||
limits,
|
||||
deadline,
|
||||
});
|
||||
const envelope = await manager.request({
|
||||
target: request.target,
|
||||
bridge,
|
||||
limits,
|
||||
maxDepth,
|
||||
deadline,
|
||||
});
|
||||
remainingSnapshotSourceMs(deadline, 'snapshot-decode-deadline');
|
||||
return {
|
||||
stage: 'acquired',
|
||||
acquisition: createAcquisition(
|
||||
request.hint,
|
||||
request.target,
|
||||
envelope,
|
||||
limits,
|
||||
maxDepth,
|
||||
),
|
||||
};
|
||||
},
|
||||
{ producer: SNAPSHOT_SOURCE_PRODUCER },
|
||||
);
|
||||
} catch (error) {
|
||||
const failure = asSnapshotSourceError(error);
|
||||
return {
|
||||
stage: 'failed',
|
||||
failure: {
|
||||
kind: failure.failureKind,
|
||||
code: failure.failureCode,
|
||||
...(failure.details ? { details: failure.details } : {}),
|
||||
},
|
||||
} satisfies SnapshotSourceOutcome;
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
acquire,
|
||||
close: async () => {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
await manager.close();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
function validateRequest(request: SnapshotSourceRequest): void {
|
||||
if (
|
||||
!request.target.udid.trim() ||
|
||||
!request.target.runtime.trim() ||
|
||||
!request.target.generation.trim() ||
|
||||
!Number.isSafeInteger(request.target.pid) ||
|
||||
request.target.pid <= 0
|
||||
) {
|
||||
throw new AppError('INVALID_ARGS', 'Simulator snapshot source target identity is incomplete');
|
||||
}
|
||||
const hint = request.hint;
|
||||
if (
|
||||
(hint.projection !== 'raw' && hint.projection !== 'regular') ||
|
||||
!['full', 'surface-observation'].includes(hint.acquisitionIntent) ||
|
||||
typeof hint.interactiveOnly !== 'boolean' ||
|
||||
typeof hint.customActions !== 'boolean' ||
|
||||
!validDepth(hint.rawTraversalDepth) ||
|
||||
!validDepth(hint.regularPresentedDepth)
|
||||
) {
|
||||
throw new AppError('INVALID_ARGS', 'Simulator snapshot source capture hint is invalid');
|
||||
}
|
||||
}
|
||||
|
||||
function resolveRequestedDepth(hint: CaptureHint, maximum: number): number {
|
||||
const requested = hint.rawTraversalDepth ?? hint.regularPresentedDepth ?? maximum;
|
||||
if (requested > maximum) {
|
||||
throw new AppError('INVALID_ARGS', 'Simulator snapshot source depth exceeds its bound', {
|
||||
requested,
|
||||
maximum,
|
||||
});
|
||||
}
|
||||
return requested;
|
||||
}
|
||||
|
||||
function validDepth(value: number | null): boolean {
|
||||
return value === null || (Number.isSafeInteger(value) && value >= 0);
|
||||
}
|
||||
|
||||
function createAcquisition(
|
||||
hint: CaptureHint,
|
||||
target: SnapshotSourceRequest['target'],
|
||||
envelope: SnapshotBridgeEnvelope,
|
||||
limits: SnapshotSourceLimits,
|
||||
maxDepth: number,
|
||||
): IosSnapshotAcquisition {
|
||||
if (envelope.automationEnabled !== true) {
|
||||
throw snapshotSourceError('unsupported', 'automation-mode-unavailable');
|
||||
}
|
||||
const tree = envelope.tree;
|
||||
const truncated = envelope.truncated;
|
||||
if (typeof truncated !== 'boolean') {
|
||||
throw snapshotSourceError('malformed-tree', 'truncated-invalid');
|
||||
}
|
||||
const decoded = decodeSnapshotBridgeTree(tree, { truncated }, limits);
|
||||
const generation = envelope.generation;
|
||||
if (typeof generation !== 'string' || !generation) {
|
||||
throw snapshotSourceError('malformed-tree', 'generation-invalid');
|
||||
}
|
||||
const nodes = Object.freeze(
|
||||
decoded.nodes.map((node) => Object.freeze({ ...node, pid: target.pid })),
|
||||
);
|
||||
const residue = createAcquisitionResidue(
|
||||
hint,
|
||||
truncated,
|
||||
decoded,
|
||||
limits,
|
||||
maxDepth,
|
||||
nodes.length,
|
||||
);
|
||||
const lineage = Object.freeze({
|
||||
...(target.targetId ? { targetId: target.targetId } : {}),
|
||||
generation,
|
||||
});
|
||||
const common = {
|
||||
producer: SNAPSHOT_SOURCE_PRODUCER,
|
||||
nodes,
|
||||
truncated,
|
||||
viewport: decoded.viewport,
|
||||
lineage,
|
||||
residue,
|
||||
};
|
||||
if (hint.acquisitionIntent === 'full') {
|
||||
return { ...common, intent: 'full', hint: { ...hint, acquisitionIntent: 'full' } };
|
||||
}
|
||||
return {
|
||||
...common,
|
||||
intent: 'surface-observation',
|
||||
hint: { ...hint, acquisitionIntent: 'surface-observation' },
|
||||
};
|
||||
}
|
||||
|
||||
function createAcquisitionResidue(
|
||||
hint: CaptureHint,
|
||||
truncated: boolean,
|
||||
decoded: ReturnType<typeof decodeSnapshotBridgeTree>,
|
||||
limits: SnapshotSourceLimits,
|
||||
maxDepth: number,
|
||||
nodeCount: number,
|
||||
) {
|
||||
return Object.freeze([
|
||||
{ kind: 'unavailable-fact', fact: 'hittability' } as const,
|
||||
...(hint.interactiveOnly
|
||||
? ([{ kind: 'unavailable-fact', fact: 'interactive-query' }] as const)
|
||||
: []),
|
||||
...(truncated
|
||||
? [truncationResidue(decoded.maxTraversalDepth, nodeCount, limits, maxDepth)]
|
||||
: []),
|
||||
...(decoded.viewport.kind === 'missing'
|
||||
? ([{ kind: 'missing-viewport', reason: decoded.viewport.reason }] as const)
|
||||
: []),
|
||||
]);
|
||||
}
|
||||
|
||||
function truncationResidue(
|
||||
maxTraversalDepth: number,
|
||||
nodeCount: number,
|
||||
limits: SnapshotSourceLimits,
|
||||
maxDepth: number,
|
||||
): { kind: 'truncated'; dimension: 'nodes' | 'depth' | 'payload'; limit?: number } {
|
||||
if (nodeCount >= limits.maxNodes) {
|
||||
return { kind: 'truncated', dimension: 'nodes', limit: limits.maxNodes };
|
||||
}
|
||||
if (maxTraversalDepth >= maxDepth) {
|
||||
return { kind: 'truncated', dimension: 'depth', limit: maxDepth };
|
||||
}
|
||||
return { kind: 'truncated', dimension: 'payload', limit: limits.maxResponseBytes };
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import path from 'node:path';
|
||||
import { snapshotSourceError } from './errors.ts';
|
||||
import { remainingSnapshotSourceMs, type SnapshotSourceDeadline } from './deadline.ts';
|
||||
import type { SnapshotSourceHost } from './types.ts';
|
||||
|
||||
export type SnapshotSourceToolchainIdentity = Readonly<{
|
||||
xcode: string;
|
||||
macosProductVersion: string;
|
||||
macosBuild: string;
|
||||
architecture: 'arm64' | 'x86_64';
|
||||
simulatorSdk: string;
|
||||
simulatorRuntime: string;
|
||||
}>;
|
||||
|
||||
export const SNAPSHOT_BRIDGE_SOURCE_FILENAMES = [
|
||||
'SnapshotBridge.m',
|
||||
'SnapshotBridgeRuntime.m',
|
||||
'SnapshotBridgeRuntime.h',
|
||||
] as const;
|
||||
export const SNAPSHOT_BRIDGE_COMPILE_FILENAMES = [
|
||||
'SnapshotBridge.m',
|
||||
'SnapshotBridgeRuntime.m',
|
||||
] as const;
|
||||
|
||||
export async function fingerprintSnapshotBridgeSource(
|
||||
host: SnapshotSourceHost,
|
||||
root: string,
|
||||
deadline: SnapshotSourceDeadline,
|
||||
): Promise<string> {
|
||||
const hash = createHash('sha256');
|
||||
for (const sourceFile of SNAPSHOT_BRIDGE_SOURCE_FILENAMES) {
|
||||
const filePath = path.join(root, sourceFile);
|
||||
remainingSnapshotSourceMs(deadline, 'native-source-fingerprint-deadline');
|
||||
if (!host.exists(filePath)) {
|
||||
throw snapshotSourceError('unsupported', 'native-source-missing', { filePath });
|
||||
}
|
||||
hash.update(sourceFile);
|
||||
hash.update('\0');
|
||||
hash.update(await host.readBinary(filePath));
|
||||
hash.update('\0');
|
||||
}
|
||||
return hash.digest('hex');
|
||||
}
|
||||
|
||||
export async function readSnapshotSourceToolchain(
|
||||
host: SnapshotSourceHost,
|
||||
simulatorRuntime: string,
|
||||
deadline: SnapshotSourceDeadline,
|
||||
): Promise<SnapshotSourceToolchainIdentity> {
|
||||
const xcode = await toolOutput(host, 'xcodebuild', ['-version'], deadline);
|
||||
const macosProductVersion = await toolOutput(host, 'sw_vers', ['-productVersion'], deadline);
|
||||
const macosBuild = await toolOutput(host, 'sw_vers', ['-buildVersion'], deadline);
|
||||
const architecture = await toolOutput(host, 'uname', ['-m'], deadline);
|
||||
const simulatorSdk = await toolOutput(
|
||||
host,
|
||||
'xcrun',
|
||||
['--sdk', 'iphonesimulator', '--show-sdk-version'],
|
||||
deadline,
|
||||
);
|
||||
const runtime = simulatorRuntime.trim();
|
||||
if (!runtime) throw snapshotSourceError('unsupported', 'simulator-runtime-missing');
|
||||
if (architecture !== 'arm64' && architecture !== 'x86_64') {
|
||||
throw snapshotSourceError('unsupported', 'simulator-architecture-unsupported', {
|
||||
architecture,
|
||||
});
|
||||
}
|
||||
return {
|
||||
xcode,
|
||||
macosProductVersion,
|
||||
macosBuild,
|
||||
architecture,
|
||||
simulatorSdk,
|
||||
simulatorRuntime: runtime,
|
||||
};
|
||||
}
|
||||
|
||||
async function toolOutput(
|
||||
host: SnapshotSourceHost,
|
||||
command: string,
|
||||
args: string[],
|
||||
deadline: SnapshotSourceDeadline,
|
||||
): Promise<string> {
|
||||
const result = await host.run(command, args, {
|
||||
allowFailure: true,
|
||||
signal: deadline.signal,
|
||||
timeoutMs: Math.min(10_000, remainingSnapshotSourceMs(deadline, 'toolchain-probe-deadline')),
|
||||
});
|
||||
if (result.exitCode !== 0) {
|
||||
throw snapshotSourceError('unsupported', 'toolchain-probe-failed', {
|
||||
command,
|
||||
exitCode: result.exitCode,
|
||||
stderr: result.stderr.slice(0, 1024),
|
||||
});
|
||||
}
|
||||
const output = (result.stdout || result.stderr).trim();
|
||||
if (!output) throw snapshotSourceError('unsupported', 'toolchain-probe-empty', { command });
|
||||
return output;
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { test } from 'vitest';
|
||||
import { createSnapshotSourceHost } from './host.ts';
|
||||
import { ensureSnapshotBridgeBinary } from './cache.ts';
|
||||
import { createSnapshotSourceDeadline } from './deadline.ts';
|
||||
import { DEFAULT_SNAPSHOT_SOURCE_LIMITS } from './limits.ts';
|
||||
import type { SnapshotSourceHost } from './types.ts';
|
||||
|
||||
test('snapshot bridge preparation is cold-once, atomic, and invalidates corrupt or stale entries', async () => {
|
||||
const root = await mkdtemp(path.join(os.tmpdir(), 'agent-device-snapshot-source-'));
|
||||
const sourceRoot = path.join(root, 'source');
|
||||
const cacheRoot = path.join(root, 'cache');
|
||||
await writeFile(path.join(root, 'placeholder'), 'unused');
|
||||
const sourceFile = path.join(sourceRoot, 'SnapshotBridge.m');
|
||||
await (await import('@agent-device/host-kit/host-file')).ensureHostDirectory(sourceRoot);
|
||||
await writeFile(sourceFile, 'native source v1');
|
||||
await writeFile(path.join(sourceRoot, 'SnapshotBridgeRuntime.m'), 'native runtime v1');
|
||||
await writeFile(path.join(sourceRoot, 'SnapshotBridgeRuntime.h'), 'native header v1');
|
||||
|
||||
let builds = 0;
|
||||
let xcodeVersion = 'Xcode 16.4\nBuild version 16F6';
|
||||
const host = createFakeBuildHost(
|
||||
() => {
|
||||
builds += 1;
|
||||
return `binary-${builds}`;
|
||||
},
|
||||
() => xcodeVersion,
|
||||
);
|
||||
|
||||
try {
|
||||
const first = await ensureSnapshotBridgeBinary({
|
||||
host,
|
||||
runtime: 'iOS 26.2',
|
||||
limits: DEFAULT_SNAPSHOT_SOURCE_LIMITS,
|
||||
deadline: testDeadline(),
|
||||
sourceRoot,
|
||||
cacheRoot,
|
||||
});
|
||||
assert.equal(builds, 1);
|
||||
assert.equal(await readFile(first.path, 'utf8'), 'binary-1');
|
||||
|
||||
const hit = await ensureSnapshotBridgeBinary({
|
||||
host,
|
||||
runtime: 'iOS 26.2',
|
||||
limits: DEFAULT_SNAPSHOT_SOURCE_LIMITS,
|
||||
deadline: testDeadline(),
|
||||
sourceRoot,
|
||||
cacheRoot,
|
||||
});
|
||||
assert.equal(hit.path, first.path);
|
||||
assert.equal(builds, 1);
|
||||
|
||||
await writeFile(path.join(sourceRoot, 'README.md'), 'documentation v1');
|
||||
const differentTreeLimits = await ensureSnapshotBridgeBinary({
|
||||
host,
|
||||
runtime: 'iOS 26.2',
|
||||
limits: { ...DEFAULT_SNAPSHOT_SOURCE_LIMITS, maxNodes: 200, maxTraversalDepth: 12 },
|
||||
deadline: testDeadline(),
|
||||
sourceRoot,
|
||||
cacheRoot,
|
||||
});
|
||||
assert.equal(differentTreeLimits.cacheKey, first.cacheKey);
|
||||
assert.equal(builds, 1);
|
||||
await writeFile(path.join(sourceRoot, 'README.md'), 'documentation v2');
|
||||
const documentationChanged = await ensureSnapshotBridgeBinary({
|
||||
host,
|
||||
runtime: 'iOS 26.2',
|
||||
limits: DEFAULT_SNAPSHOT_SOURCE_LIMITS,
|
||||
deadline: testDeadline(),
|
||||
sourceRoot,
|
||||
cacheRoot,
|
||||
});
|
||||
assert.equal(documentationChanged.cacheKey, first.cacheKey);
|
||||
assert.equal(builds, 1);
|
||||
const manifest = JSON.parse(
|
||||
await readFile(path.join(path.dirname(first.path), 'manifest.json'), 'utf8'),
|
||||
) as { toolchain: { macosBuild: string } };
|
||||
assert.equal(manifest.toolchain.macosBuild, '24G90');
|
||||
|
||||
await writeFile(first.path, 'corrupt');
|
||||
await ensureSnapshotBridgeBinary({
|
||||
host,
|
||||
runtime: 'iOS 26.2',
|
||||
limits: DEFAULT_SNAPSHOT_SOURCE_LIMITS,
|
||||
deadline: testDeadline(),
|
||||
sourceRoot,
|
||||
cacheRoot,
|
||||
});
|
||||
assert.equal(builds, 2);
|
||||
|
||||
await writeFile(sourceFile, 'native source v2');
|
||||
const sourceChanged = await ensureSnapshotBridgeBinary({
|
||||
host,
|
||||
runtime: 'iOS 26.2',
|
||||
limits: DEFAULT_SNAPSHOT_SOURCE_LIMITS,
|
||||
deadline: testDeadline(),
|
||||
sourceRoot,
|
||||
cacheRoot,
|
||||
});
|
||||
assert.notEqual(sourceChanged.sourceHash, first.sourceHash);
|
||||
assert.equal(builds, 3);
|
||||
|
||||
xcodeVersion = 'Xcode 16.5\nBuild version 16F5';
|
||||
const toolchainChanged = await ensureSnapshotBridgeBinary({
|
||||
host,
|
||||
runtime: 'iOS 26.2',
|
||||
limits: DEFAULT_SNAPSHOT_SOURCE_LIMITS,
|
||||
deadline: testDeadline(),
|
||||
sourceRoot,
|
||||
cacheRoot,
|
||||
});
|
||||
assert.notEqual(toolchainChanged.cacheKey, sourceChanged.cacheKey);
|
||||
assert.equal(builds, 4);
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('concurrent snapshot bridge preparation publishes one cache entry', async () => {
|
||||
const root = await mkdtemp(path.join(os.tmpdir(), 'agent-device-snapshot-source-concurrent-'));
|
||||
const sourceRoot = path.join(root, 'source');
|
||||
const cacheRoot = path.join(root, 'cache');
|
||||
await (await import('@agent-device/host-kit/host-file')).ensureHostDirectory(sourceRoot);
|
||||
await writeFile(path.join(sourceRoot, 'SnapshotBridge.m'), 'native source');
|
||||
await writeFile(path.join(sourceRoot, 'SnapshotBridgeRuntime.m'), 'native runtime');
|
||||
await writeFile(path.join(sourceRoot, 'SnapshotBridgeRuntime.h'), 'native header');
|
||||
let builds = 0;
|
||||
const host = createFakeBuildHost(async () => {
|
||||
builds += 1;
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
return `binary-${builds}`;
|
||||
});
|
||||
|
||||
try {
|
||||
const results = await Promise.all(
|
||||
[1, 2].map(() =>
|
||||
ensureSnapshotBridgeBinary({
|
||||
host,
|
||||
runtime: 'iOS 26.2',
|
||||
limits: DEFAULT_SNAPSHOT_SOURCE_LIMITS,
|
||||
deadline: testDeadline(),
|
||||
sourceRoot,
|
||||
cacheRoot,
|
||||
}),
|
||||
),
|
||||
);
|
||||
assert.equal(builds, 1);
|
||||
assert.equal(results[0]?.path, results[1]?.path);
|
||||
assert.equal(await readFile(results[0]!.path, 'utf8'), 'binary-1');
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('an aborted cache waiter does not cancel an independent preparation', async () => {
|
||||
const root = await mkdtemp(path.join(os.tmpdir(), 'agent-device-snapshot-source-abort-'));
|
||||
const sourceRoot = path.join(root, 'source');
|
||||
const cacheRoot = path.join(root, 'cache');
|
||||
await (await import('@agent-device/host-kit/host-file')).ensureHostDirectory(sourceRoot);
|
||||
await writeFile(path.join(sourceRoot, 'SnapshotBridge.m'), 'native source');
|
||||
await writeFile(path.join(sourceRoot, 'SnapshotBridgeRuntime.m'), 'native runtime');
|
||||
await writeFile(path.join(sourceRoot, 'SnapshotBridgeRuntime.h'), 'native header');
|
||||
let builds = 0;
|
||||
let buildStarted!: () => void;
|
||||
const started = new Promise<void>((resolve) => {
|
||||
buildStarted = resolve;
|
||||
});
|
||||
const host = createFakeBuildHost(async () => {
|
||||
builds += 1;
|
||||
buildStarted();
|
||||
await new Promise((resolve) => setTimeout(resolve, 40));
|
||||
return `binary-${builds}`;
|
||||
});
|
||||
const controller = new AbortController();
|
||||
|
||||
try {
|
||||
const canceled = ensureSnapshotBridgeBinary({
|
||||
host,
|
||||
runtime: 'iOS 26.2',
|
||||
limits: { ...DEFAULT_SNAPSHOT_SOURCE_LIMITS, maxDurationMs: 300 },
|
||||
deadline: testDeadline(300, controller.signal),
|
||||
sourceRoot,
|
||||
cacheRoot,
|
||||
});
|
||||
await started;
|
||||
controller.abort();
|
||||
const survivor = ensureSnapshotBridgeBinary({
|
||||
host,
|
||||
runtime: 'iOS 26.2',
|
||||
limits: DEFAULT_SNAPSHOT_SOURCE_LIMITS,
|
||||
deadline: testDeadline(),
|
||||
sourceRoot,
|
||||
cacheRoot,
|
||||
});
|
||||
|
||||
await expectRejectedCancellation(canceled);
|
||||
const result = await survivor;
|
||||
assert.equal(await readFile(result.path, 'utf8'), 'binary-2');
|
||||
assert.equal(builds, 2);
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
async function expectRejectedCancellation(value: Promise<unknown>): Promise<void> {
|
||||
await assert.rejects(value, (error: unknown) => {
|
||||
return (
|
||||
error instanceof Error &&
|
||||
'failureKind' in error &&
|
||||
(error as { failureKind: string }).failureKind === 'cancelled'
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function testDeadline(
|
||||
timeoutMs = DEFAULT_SNAPSHOT_SOURCE_LIMITS.maxDurationMs,
|
||||
signal?: AbortSignal,
|
||||
) {
|
||||
return createSnapshotSourceDeadline(timeoutMs, signal);
|
||||
}
|
||||
|
||||
function createFakeBuildHost(
|
||||
binary: string | (() => string | Promise<string>),
|
||||
getXcode: () => string = () => 'Xcode 16.4\nBuild version 16F6',
|
||||
): SnapshotSourceHost {
|
||||
const real = createSnapshotSourceHost();
|
||||
return {
|
||||
...real,
|
||||
run: async (command, args) => {
|
||||
if (command === 'xcrun' && args.includes('clang')) {
|
||||
const outputPath = args.at(-1)!;
|
||||
const contents = typeof binary === 'function' ? await binary() : binary;
|
||||
await writeFile(outputPath, contents);
|
||||
return { stdout: '', stderr: '', exitCode: 0 };
|
||||
}
|
||||
const stdout =
|
||||
command === 'xcodebuild'
|
||||
? getXcode()
|
||||
: command === 'sw_vers'
|
||||
? args.includes('-buildVersion')
|
||||
? '24G90'
|
||||
: '15.6'
|
||||
: command === 'uname'
|
||||
? 'arm64'
|
||||
: '26.2';
|
||||
return { stdout, stderr: '', exitCode: 0 };
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import path from 'node:path';
|
||||
import { SnapshotSourceError, snapshotSourceError } from './errors.ts';
|
||||
import { remainingSnapshotSourceMs, type SnapshotSourceDeadline } from './deadline.ts';
|
||||
import {
|
||||
fingerprintSnapshotBridgeSource,
|
||||
readSnapshotSourceToolchain,
|
||||
SNAPSHOT_BRIDGE_COMPILE_FILENAMES,
|
||||
SNAPSHOT_BRIDGE_SOURCE_FILENAMES,
|
||||
type SnapshotSourceToolchainIdentity,
|
||||
} from './cache-identity.ts';
|
||||
import { SNAPSHOT_SOURCE_PROTOCOL_VERSION, SNAPSHOT_SOURCE_VERSION } from './protocol.ts';
|
||||
import type {
|
||||
SnapshotSourceBridgeBinary,
|
||||
SnapshotSourceHost,
|
||||
SnapshotSourceLimits,
|
||||
} from './types.ts';
|
||||
|
||||
type SnapshotBridgeCacheManifest = Readonly<{
|
||||
schemaVersion: 1;
|
||||
protocolVersion: number;
|
||||
sourceVersion: string;
|
||||
sourceHash: string;
|
||||
cacheKey: string;
|
||||
toolchain: SnapshotSourceToolchainIdentity;
|
||||
binarySha256: string;
|
||||
}>;
|
||||
|
||||
const CACHE_SCHEMA_VERSION = 1 as const;
|
||||
const BRIDGE_FILENAME = 'snapshot-bridge';
|
||||
const MANIFEST_FILENAME = 'manifest.json';
|
||||
const BUILD_TIMEOUT_MS = 120_000;
|
||||
|
||||
export async function ensureSnapshotBridgeBinary(
|
||||
input: Readonly<{
|
||||
host: SnapshotSourceHost;
|
||||
runtime: string;
|
||||
limits: SnapshotSourceLimits;
|
||||
deadline: SnapshotSourceDeadline;
|
||||
sourceRoot?: string;
|
||||
cacheRoot?: string;
|
||||
}>,
|
||||
): Promise<SnapshotSourceBridgeBinary> {
|
||||
const deadline = input.deadline;
|
||||
const sourceRoot = input.sourceRoot ?? resolveSnapshotBridgeSourceRoot(input.host);
|
||||
const sourceHash = await fingerprintSnapshotBridgeSource(input.host, sourceRoot, deadline);
|
||||
const toolchain = await readSnapshotSourceToolchain(input.host, input.runtime, deadline);
|
||||
const cacheKey = hashJson({
|
||||
schemaVersion: CACHE_SCHEMA_VERSION,
|
||||
protocolVersion: SNAPSHOT_SOURCE_PROTOCOL_VERSION,
|
||||
sourceVersion: SNAPSHOT_SOURCE_VERSION,
|
||||
sourceHash,
|
||||
toolchain,
|
||||
});
|
||||
const cacheRoot =
|
||||
input.cacheRoot ?? path.join(input.host.homeDirectory(), '.agent-device', 'snapshot-source');
|
||||
const entryPath = path.join(cacheRoot, cacheKey);
|
||||
const releaseLock = await input.host.acquireLock(path.join(cacheRoot, `${cacheKey}.lock`), {
|
||||
deadline,
|
||||
});
|
||||
try {
|
||||
const cached = await readValidCache(
|
||||
input.host,
|
||||
entryPath,
|
||||
{
|
||||
sourceHash,
|
||||
cacheKey,
|
||||
toolchain,
|
||||
},
|
||||
deadline,
|
||||
);
|
||||
if (cached) return cached;
|
||||
remainingSnapshotSourceMs(deadline, 'native-build-deadline');
|
||||
if (input.host.exists(entryPath)) await input.host.remove(entryPath);
|
||||
|
||||
remainingSnapshotSourceMs(deadline, 'native-build-deadline');
|
||||
await input.host.ensureDirectory(cacheRoot);
|
||||
const temporaryPath = path.join(cacheRoot, `.${cacheKey}.${input.host.processId()}.tmp`);
|
||||
remainingSnapshotSourceMs(deadline, 'native-build-deadline');
|
||||
await input.host.remove(temporaryPath);
|
||||
try {
|
||||
remainingSnapshotSourceMs(deadline, 'native-build-deadline');
|
||||
await input.host.ensureDirectory(temporaryPath);
|
||||
const outputPath = path.join(temporaryPath, BRIDGE_FILENAME);
|
||||
const result = await input.host.run(
|
||||
'xcrun',
|
||||
[
|
||||
'--sdk',
|
||||
'iphonesimulator',
|
||||
'clang',
|
||||
'-arch',
|
||||
toolchain.architecture,
|
||||
'-mios-simulator-version-min=15.0',
|
||||
'-fobjc-arc',
|
||||
'-Werror',
|
||||
'-Wall',
|
||||
'-Wextra',
|
||||
'-framework',
|
||||
'Foundation',
|
||||
'-framework',
|
||||
'CoreGraphics',
|
||||
...SNAPSHOT_BRIDGE_COMPILE_FILENAMES.map((sourceFile) =>
|
||||
path.join(sourceRoot, sourceFile),
|
||||
),
|
||||
'-o',
|
||||
outputPath,
|
||||
],
|
||||
{
|
||||
signal: deadline.signal,
|
||||
timeoutMs: Math.min(
|
||||
BUILD_TIMEOUT_MS,
|
||||
remainingSnapshotSourceMs(deadline, 'native-build-deadline'),
|
||||
),
|
||||
allowFailure: true,
|
||||
},
|
||||
);
|
||||
if (result.exitCode !== 0 || !input.host.exists(outputPath)) {
|
||||
throw snapshotSourceError('unsupported', 'native-build-failed', {
|
||||
exitCode: result.exitCode,
|
||||
stderr: result.stderr.slice(0, 4096),
|
||||
});
|
||||
}
|
||||
remainingSnapshotSourceMs(deadline, 'native-build-deadline');
|
||||
await input.host.chmod(outputPath, 0o755);
|
||||
const binarySha256 = await sha256File(input.host, outputPath, deadline);
|
||||
const manifest: SnapshotBridgeCacheManifest = {
|
||||
schemaVersion: CACHE_SCHEMA_VERSION,
|
||||
protocolVersion: SNAPSHOT_SOURCE_PROTOCOL_VERSION,
|
||||
sourceVersion: SNAPSHOT_SOURCE_VERSION,
|
||||
sourceHash,
|
||||
cacheKey,
|
||||
toolchain,
|
||||
binarySha256,
|
||||
};
|
||||
await input.host.writeText(
|
||||
path.join(temporaryPath, MANIFEST_FILENAME),
|
||||
`${JSON.stringify(manifest, null, 2)}\n`,
|
||||
);
|
||||
remainingSnapshotSourceMs(deadline, 'native-build-deadline');
|
||||
await input.host.rename(temporaryPath, entryPath);
|
||||
return {
|
||||
path: path.join(entryPath, BRIDGE_FILENAME),
|
||||
sourceHash,
|
||||
cacheKey,
|
||||
protocolVersion: SNAPSHOT_SOURCE_PROTOCOL_VERSION,
|
||||
sourceVersion: SNAPSHOT_SOURCE_VERSION,
|
||||
};
|
||||
} catch (error) {
|
||||
await input.host.remove(temporaryPath);
|
||||
throw error;
|
||||
}
|
||||
} finally {
|
||||
await releaseLock();
|
||||
}
|
||||
}
|
||||
|
||||
function resolveSnapshotBridgeSourceRoot(host: SnapshotSourceHost): string {
|
||||
const projectRoot = host.projectRoot();
|
||||
const checkoutRoot = path.join(projectRoot, 'apple', 'snapshot-bridge');
|
||||
if (
|
||||
SNAPSHOT_BRIDGE_SOURCE_FILENAMES.every((sourceFile) =>
|
||||
host.exists(path.join(checkoutRoot, sourceFile)),
|
||||
)
|
||||
) {
|
||||
return checkoutRoot;
|
||||
}
|
||||
const packagedRoot = path.join(projectRoot, 'dist', 'apple', 'snapshot-bridge');
|
||||
if (
|
||||
SNAPSHOT_BRIDGE_SOURCE_FILENAMES.every((sourceFile) =>
|
||||
host.exists(path.join(packagedRoot, sourceFile)),
|
||||
)
|
||||
) {
|
||||
return packagedRoot;
|
||||
}
|
||||
throw snapshotSourceError('unsupported', 'native-source-missing', { projectRoot });
|
||||
}
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
async function readValidCache(
|
||||
host: SnapshotSourceHost,
|
||||
entryPath: string,
|
||||
expected: Readonly<{
|
||||
sourceHash: string;
|
||||
cacheKey: string;
|
||||
toolchain: SnapshotSourceToolchainIdentity;
|
||||
}>,
|
||||
deadline: SnapshotSourceDeadline,
|
||||
): Promise<SnapshotSourceBridgeBinary | undefined> {
|
||||
const binaryPath = path.join(entryPath, BRIDGE_FILENAME);
|
||||
if (!host.exists(binaryPath) || !host.exists(path.join(entryPath, MANIFEST_FILENAME))) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
const manifest = JSON.parse(
|
||||
await host.readText(path.join(entryPath, MANIFEST_FILENAME)),
|
||||
) as Partial<SnapshotBridgeCacheManifest>;
|
||||
if (
|
||||
manifest.schemaVersion !== CACHE_SCHEMA_VERSION ||
|
||||
manifest.protocolVersion !== SNAPSHOT_SOURCE_PROTOCOL_VERSION ||
|
||||
manifest.sourceVersion !== SNAPSHOT_SOURCE_VERSION ||
|
||||
manifest.sourceHash !== expected.sourceHash ||
|
||||
manifest.cacheKey !== expected.cacheKey ||
|
||||
JSON.stringify(manifest.toolchain) !== JSON.stringify(expected.toolchain) ||
|
||||
typeof manifest.binarySha256 !== 'string'
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
if ((await sha256File(host, binaryPath, deadline)) !== manifest.binarySha256) return undefined;
|
||||
return {
|
||||
path: binaryPath,
|
||||
sourceHash: expected.sourceHash,
|
||||
cacheKey: expected.cacheKey,
|
||||
protocolVersion: SNAPSHOT_SOURCE_PROTOCOL_VERSION,
|
||||
sourceVersion: SNAPSHOT_SOURCE_VERSION,
|
||||
};
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof SnapshotSourceError &&
|
||||
(error.failureKind === 'cancelled' || error.failureKind === 'timeout')
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async function sha256File(
|
||||
host: SnapshotSourceHost,
|
||||
filePath: string,
|
||||
deadline: SnapshotSourceDeadline,
|
||||
): Promise<string> {
|
||||
remainingSnapshotSourceMs(deadline, 'native-cache-hash-deadline');
|
||||
return createHash('sha256')
|
||||
.update(await host.readBinary(filePath))
|
||||
.digest('hex');
|
||||
}
|
||||
|
||||
function hashJson(value: unknown): string {
|
||||
return createHash('sha256').update(JSON.stringify(value)).digest('hex').slice(0, 32);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { Deadline } from '@agent-device/host-kit/retry';
|
||||
import { snapshotSourceError } from './errors.ts';
|
||||
|
||||
export type SnapshotSourceDeadline = Readonly<{
|
||||
clock: Deadline;
|
||||
signal: AbortSignal | undefined;
|
||||
}>;
|
||||
|
||||
export function createSnapshotSourceDeadline(
|
||||
timeoutMs: number,
|
||||
signal: AbortSignal | undefined,
|
||||
): SnapshotSourceDeadline {
|
||||
if (signal?.aborted) throw snapshotSourceError('cancelled', 'abort-signal');
|
||||
return { clock: Deadline.fromTimeoutMs(timeoutMs), signal };
|
||||
}
|
||||
|
||||
export function remainingSnapshotSourceMs(deadline: SnapshotSourceDeadline, code: string): number {
|
||||
if (deadline.signal?.aborted) throw snapshotSourceError('cancelled', 'abort-signal');
|
||||
const remainingMs = deadline.clock.remainingMs();
|
||||
if (remainingMs <= 0) throw snapshotSourceError('timeout', code);
|
||||
return Math.max(1, Math.floor(remainingMs));
|
||||
}
|
||||
|
||||
export async function waitForSnapshotSourceDelay(
|
||||
deadline: SnapshotSourceDeadline,
|
||||
requestedMs: number,
|
||||
code: string,
|
||||
): Promise<void> {
|
||||
const delayMs = Math.min(requestedMs, remainingSnapshotSourceMs(deadline, code));
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
let settled = false;
|
||||
const timer = setTimeout(() => finish(resolve), delayMs);
|
||||
const onAbort = () => {
|
||||
finish(() => reject(snapshotSourceError('cancelled', 'abort-signal')));
|
||||
};
|
||||
const finish = (action: () => void) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
deadline.signal?.removeEventListener('abort', onAbort);
|
||||
action();
|
||||
};
|
||||
deadline.signal?.addEventListener('abort', onAbort, { once: true });
|
||||
if (deadline.signal?.aborted) onAbort();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { AppError, isRequestCanceledError } from '@agent-device/kernel/errors';
|
||||
import type { SnapshotSourceFailureKind } from './types.ts';
|
||||
|
||||
const APP_ERROR_CODE_BY_KIND: Readonly<Record<SnapshotSourceFailureKind, string>> = {
|
||||
unsupported: 'UNSUPPORTED_OPERATION',
|
||||
'malformed-tree': 'COMMAND_FAILED',
|
||||
'stale-target': 'COMMAND_FAILED',
|
||||
timeout: 'COMMAND_FAILED',
|
||||
cancelled: 'COMMAND_FAILED',
|
||||
'process-crash': 'COMMAND_FAILED',
|
||||
'transport-failure': 'COMMAND_FAILED',
|
||||
};
|
||||
|
||||
export class SnapshotSourceError extends AppError {
|
||||
readonly failureKind: SnapshotSourceFailureKind;
|
||||
readonly failureCode: string;
|
||||
|
||||
constructor(
|
||||
kind: SnapshotSourceFailureKind,
|
||||
code: string,
|
||||
message = `iOS Simulator snapshot source ${kind}: ${code}`,
|
||||
details: Readonly<Record<string, unknown>> = {},
|
||||
cause?: unknown,
|
||||
) {
|
||||
super(
|
||||
APP_ERROR_CODE_BY_KIND[kind],
|
||||
message,
|
||||
{
|
||||
...details,
|
||||
bridgeFailure: kind,
|
||||
bridgeFailureCode: code,
|
||||
...(kind === 'cancelled' ? { reason: 'request_canceled' } : {}),
|
||||
},
|
||||
cause,
|
||||
);
|
||||
this.name = 'SnapshotSourceError';
|
||||
this.failureKind = kind;
|
||||
this.failureCode = code;
|
||||
}
|
||||
}
|
||||
|
||||
export function snapshotSourceError(
|
||||
kind: SnapshotSourceFailureKind,
|
||||
code: string,
|
||||
details: Readonly<Record<string, unknown>> = {},
|
||||
cause?: unknown,
|
||||
): SnapshotSourceError {
|
||||
return new SnapshotSourceError(kind, code, undefined, details, cause);
|
||||
}
|
||||
|
||||
export function asSnapshotSourceError(error: unknown): SnapshotSourceError {
|
||||
if (error instanceof SnapshotSourceError) return error;
|
||||
if (isRequestCanceledError(error)) {
|
||||
return snapshotSourceError('cancelled', 'abort-signal', {}, error);
|
||||
}
|
||||
if (error instanceof AppError && typeof error.details?.timeoutMs === 'number') {
|
||||
return snapshotSourceError(
|
||||
'timeout',
|
||||
'host-operation-timeout',
|
||||
{ timeoutMs: error.details.timeoutMs },
|
||||
error,
|
||||
);
|
||||
}
|
||||
return snapshotSourceError(
|
||||
'transport-failure',
|
||||
'unexpected-host-error',
|
||||
{ error: error instanceof Error ? error.message : String(error) },
|
||||
error,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"protocolVersion": 1,
|
||||
"sourceVersion": "agent-device-simulator-ax-v1.5.3",
|
||||
"requestKeys": [
|
||||
"verb",
|
||||
"requestId",
|
||||
"pid",
|
||||
"generation",
|
||||
"snapshotTree",
|
||||
"automationMode",
|
||||
"maxDepth",
|
||||
"maxNodes",
|
||||
"maxDurationMs",
|
||||
"maxResponseBytes"
|
||||
],
|
||||
"responseKeys": [
|
||||
"protocolVersion",
|
||||
"sourceVersion",
|
||||
"requestId",
|
||||
"generation",
|
||||
"ok",
|
||||
"pid",
|
||||
"tree",
|
||||
"truncated",
|
||||
"automationEnabled",
|
||||
"error_kind",
|
||||
"error_code",
|
||||
"error"
|
||||
],
|
||||
"attributeKeys": [
|
||||
"XC_kAXXCAttributeElementType",
|
||||
"XC_kAXXCAttributeElementBaseType",
|
||||
"XC_kAXXCAttributeLabel",
|
||||
"XC_kAXXCAttributeValue",
|
||||
"XC_kAXXCAttributeIdentifier",
|
||||
"XC_kAXXCAttributeFrame",
|
||||
"XC_kAXXCAttributeAutomationType",
|
||||
"XC_kAXXCAttributeChildren"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { test } from 'vitest';
|
||||
import { createSnapshotSourceHost, snapshotSourceSocketPath } from './host.ts';
|
||||
|
||||
test('snapshot bridge socket paths stay within the AF_UNIX limit and are target-specific', () => {
|
||||
const host = createSnapshotSourceHost();
|
||||
const first = snapshotSourceSocketPath(host, 'simulator-1', 'owner-1');
|
||||
const second = snapshotSourceSocketPath(host, 'simulator-2', 'owner-1');
|
||||
const otherOwner = snapshotSourceSocketPath(host, 'simulator-1', 'owner-2');
|
||||
|
||||
assert.equal(first.length < 104, true);
|
||||
assert.equal(second.length < 104, true);
|
||||
assert.equal(otherOwner.length < 104, true);
|
||||
assert.notEqual(first, second);
|
||||
assert.notEqual(first, otherOwner);
|
||||
});
|
||||
@@ -0,0 +1,246 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import net from 'node:net';
|
||||
import path from 'node:path';
|
||||
import { runCmd, runCmdBackground } from '@agent-device/host-kit/command';
|
||||
import { acquireProcessLock } from '@agent-device/host-kit/file';
|
||||
import {
|
||||
chmodHostFile,
|
||||
ensureHostDirectory,
|
||||
hostFileExistsSync,
|
||||
hostHomeDirectory,
|
||||
readHostBinaryFile,
|
||||
readHostTextFile,
|
||||
removeHostPath,
|
||||
renameHostPath,
|
||||
writeHostTextFile,
|
||||
} from '@agent-device/host-kit/host-file';
|
||||
import {
|
||||
hostProcessId,
|
||||
readProcessStartTime,
|
||||
signalProcessGroupBestEffort,
|
||||
} from '@agent-device/host-kit/process';
|
||||
import { emitDiagnostic, withDiagnosticTimer } from '@agent-device/host-kit/diagnostics';
|
||||
import { findProjectRoot } from '@agent-device/host-kit/version';
|
||||
import { SnapshotSourceError, snapshotSourceError } from './errors.ts';
|
||||
import { remainingSnapshotSourceMs } from './deadline.ts';
|
||||
import type { SnapshotSourceHost, SnapshotSourceProcess, SnapshotSourceSocket } from './types.ts';
|
||||
|
||||
const BRIDGE_IDLE_TIMEOUT_SECONDS = 60;
|
||||
const MAX_PROCESS_LOG_BYTES = 64 * 1024;
|
||||
const SNAPSHOT_SOCKET_ROOT = '/tmp';
|
||||
|
||||
export function createSnapshotSourceHost(): SnapshotSourceHost {
|
||||
return {
|
||||
projectRoot: findProjectRoot,
|
||||
homeDirectory: hostHomeDirectory,
|
||||
run: async (command, args, options) => await runCmd(command, args, options),
|
||||
start: startSnapshotBridge,
|
||||
connect: connectSnapshotBridge,
|
||||
readText: readHostTextFile,
|
||||
readBinary: readHostBinaryFile,
|
||||
writeText: writeHostTextFile,
|
||||
ensureDirectory: ensureHostDirectory,
|
||||
chmod: chmodHostFile,
|
||||
exists: hostFileExistsSync,
|
||||
rename: renameHostPath,
|
||||
remove: removeHostPath,
|
||||
acquireLock: acquireSnapshotSourceLock,
|
||||
emitDiagnostic,
|
||||
withDiagnosticTimer,
|
||||
processId: hostProcessId,
|
||||
readTargetProcessStartTime,
|
||||
};
|
||||
}
|
||||
|
||||
async function readTargetProcessStartTime(
|
||||
pid: number,
|
||||
options: { signal?: AbortSignal; timeoutMs: number },
|
||||
): Promise<string | null> {
|
||||
const result = await runCmd('ps', ['-p', String(pid), '-o', 'lstart='], {
|
||||
allowFailure: true,
|
||||
signal: options.signal,
|
||||
timeoutMs: options.timeoutMs,
|
||||
});
|
||||
if (result.exitCode !== 0) return null;
|
||||
return result.stdout.trim() || null;
|
||||
}
|
||||
|
||||
function startSnapshotBridge(
|
||||
udid: string,
|
||||
bridgePath: string,
|
||||
socketPath: string,
|
||||
options: { signal?: AbortSignal } = {},
|
||||
): SnapshotSourceProcess {
|
||||
if (options.signal?.aborted) {
|
||||
throw snapshotSourceError('cancelled', 'abort-signal');
|
||||
}
|
||||
const started = runCmdBackground(
|
||||
'xcrun',
|
||||
[
|
||||
'simctl',
|
||||
'spawn',
|
||||
udid,
|
||||
bridgePath,
|
||||
'serve',
|
||||
socketPath,
|
||||
'--idle-timeout',
|
||||
String(BRIDGE_IDLE_TIMEOUT_SECONDS),
|
||||
'--exit-on-disconnect',
|
||||
'false',
|
||||
],
|
||||
{
|
||||
allowFailure: true,
|
||||
captureOutput: false,
|
||||
detached: true,
|
||||
},
|
||||
);
|
||||
const pid = started.child.pid ?? 0;
|
||||
if (pid <= 0) {
|
||||
throw snapshotSourceError('transport-failure', 'bridge-process-pid-missing');
|
||||
}
|
||||
|
||||
let log = '';
|
||||
started.child.stderr?.setEncoding('utf8');
|
||||
started.child.stderr?.on('data', (chunk: string | Buffer) => {
|
||||
log = appendBoundedLog(log, String(chunk));
|
||||
});
|
||||
|
||||
return {
|
||||
pid,
|
||||
wait: started.wait,
|
||||
isAlive: () => started.child.exitCode === null && started.child.signalCode === null,
|
||||
signal: (signal) => {
|
||||
if (!signalProcessGroupBestEffort(pid, signal)) {
|
||||
started.child.kill(signal);
|
||||
}
|
||||
},
|
||||
readLog: () => log,
|
||||
};
|
||||
}
|
||||
|
||||
async function connectSnapshotBridge(
|
||||
socketPath: string,
|
||||
options: { signal?: AbortSignal; timeoutMs: number },
|
||||
): Promise<SnapshotSourceSocket> {
|
||||
if (options.signal?.aborted) {
|
||||
throw snapshotSourceError('cancelled', 'abort-signal');
|
||||
}
|
||||
const timeoutMs = Math.max(1, Math.floor(options.timeoutMs));
|
||||
return await new Promise<SnapshotSourceSocket>((resolve, reject) => {
|
||||
const socket = net.createConnection({ path: socketPath });
|
||||
let connected = false;
|
||||
let settled = false;
|
||||
const timer = setTimeout(() => {
|
||||
finish(snapshotSourceError('timeout', 'bridge-connect-timeout'));
|
||||
socket.destroy();
|
||||
}, timeoutMs);
|
||||
const onAbort = () => {
|
||||
finish(snapshotSourceError('cancelled', 'abort-signal'));
|
||||
socket.destroy();
|
||||
};
|
||||
const onConnect = () => {
|
||||
connected = true;
|
||||
clearTimeout(timer);
|
||||
options.signal?.removeEventListener('abort', onAbort);
|
||||
socket.off('error', onError);
|
||||
socket.off('close', onClose);
|
||||
socket.setTimeout(0);
|
||||
resolve(socket);
|
||||
};
|
||||
const onError = (error: Error) => {
|
||||
finish(error);
|
||||
socket.destroy();
|
||||
};
|
||||
const onClose = () => {
|
||||
if (!connected)
|
||||
finish(snapshotSourceError('transport-failure', 'bridge-closed-before-connect'));
|
||||
};
|
||||
const finish = (error: unknown) => {
|
||||
if (settled || connected) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
options.signal?.removeEventListener('abort', onAbort);
|
||||
socket.off('connect', onConnect);
|
||||
socket.off('error', onError);
|
||||
socket.off('close', onClose);
|
||||
reject(error);
|
||||
};
|
||||
socket.once('connect', onConnect);
|
||||
socket.once('error', onError);
|
||||
socket.once('close', onClose);
|
||||
options.signal?.addEventListener('abort', onAbort, { once: true });
|
||||
if (options.signal?.aborted) onAbort();
|
||||
});
|
||||
}
|
||||
|
||||
async function acquireSnapshotSourceLock(
|
||||
lockPath: string,
|
||||
options: Parameters<SnapshotSourceHost['acquireLock']>[1],
|
||||
): Promise<() => Promise<void>> {
|
||||
const pid = hostProcessId();
|
||||
const deadline = options.deadline;
|
||||
const pending = acquireProcessLock({
|
||||
lockDirPath: lockPath,
|
||||
owner: {
|
||||
pid,
|
||||
startTime: readProcessStartTime(pid),
|
||||
acquiredAtMs: Date.now(),
|
||||
},
|
||||
timeoutMs: remainingSnapshotSourceMs(deadline, 'cache-lock-deadline'),
|
||||
pollMs: 100,
|
||||
ownerGraceMs: 5_000,
|
||||
description: 'iOS Simulator snapshot bridge cache',
|
||||
});
|
||||
const signal = deadline.signal;
|
||||
if (!signal) return await pending;
|
||||
|
||||
let canceled = false;
|
||||
let onAbort!: () => void;
|
||||
const aborted = new Promise<never>((_, reject) => {
|
||||
onAbort = () => {
|
||||
canceled = true;
|
||||
reject(snapshotSourceError('cancelled', 'abort-signal'));
|
||||
};
|
||||
signal.addEventListener('abort', onAbort, { once: true });
|
||||
if (signal.aborted) onAbort();
|
||||
});
|
||||
try {
|
||||
return await Promise.race([pending, aborted]);
|
||||
} catch (error) {
|
||||
if (canceled)
|
||||
void pending.then(
|
||||
(release) => release(),
|
||||
() => undefined,
|
||||
);
|
||||
if (
|
||||
deadline.clock.isExpired() &&
|
||||
!(error instanceof SnapshotSourceError && error.failureKind === 'cancelled')
|
||||
) {
|
||||
throw snapshotSourceError('timeout', 'cache-lock-deadline');
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
signal.removeEventListener('abort', onAbort);
|
||||
}
|
||||
}
|
||||
|
||||
function appendBoundedLog(current: string, addition: string): string {
|
||||
const combined = current + addition;
|
||||
return combined.length <= MAX_PROCESS_LOG_BYTES
|
||||
? combined
|
||||
: combined.slice(combined.length - MAX_PROCESS_LOG_BYTES);
|
||||
}
|
||||
|
||||
export function snapshotSourceSocketPath(
|
||||
host: SnapshotSourceHost,
|
||||
udid: string,
|
||||
ownerId: string,
|
||||
): string {
|
||||
const targetKey = createHash('sha256').update(udid).digest('hex').slice(0, 12);
|
||||
const ownerKey = createHash('sha256').update(ownerId).digest('hex').slice(0, 12);
|
||||
return path.join(
|
||||
SNAPSHOT_SOCKET_ROOT,
|
||||
`agent-device-ax-${targetKey}-${host.processId()}-${ownerKey}`,
|
||||
'snapshot.sock',
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,526 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { test } from 'vitest';
|
||||
import { createSnapshotSourceHost } from './host.ts';
|
||||
import { SnapshotSourceError, snapshotSourceError } from './errors.ts';
|
||||
import { createSnapshotSourceDeadline } from './deadline.ts';
|
||||
import {
|
||||
encodeSnapshotBridgeFrame,
|
||||
SNAPSHOT_SOURCE_PROTOCOL_VERSION,
|
||||
SNAPSHOT_SOURCE_VERSION,
|
||||
} from './protocol.ts';
|
||||
import { SnapshotBridgeManager } from './lifecycle.ts';
|
||||
import type {
|
||||
SnapshotSourceHost,
|
||||
SnapshotSourceLimits,
|
||||
SnapshotSourceProcess,
|
||||
SnapshotSourceSocket,
|
||||
} from './types.ts';
|
||||
|
||||
const limits: SnapshotSourceLimits = {
|
||||
maxRequestBytes: 64 * 1024,
|
||||
maxResponseBytes: 4 * 1024,
|
||||
maxNodes: 20,
|
||||
maxTraversalDepth: 10,
|
||||
maxDurationMs: 100,
|
||||
};
|
||||
|
||||
const target = {
|
||||
udid: 'simulator-1',
|
||||
runtime: 'iOS 26.2',
|
||||
pid: 123,
|
||||
generation: 'generation-1',
|
||||
};
|
||||
|
||||
const bridge = {
|
||||
path: '/tmp/snapshot-bridge',
|
||||
sourceHash: 'source-hash',
|
||||
cacheKey: 'cache-key',
|
||||
protocolVersion: SNAPSHOT_SOURCE_PROTOCOL_VERSION,
|
||||
sourceVersion: SNAPSHOT_SOURCE_VERSION,
|
||||
};
|
||||
|
||||
test('the bridge manager reuses a healthy per-device helper and stops it exactly once', async () => {
|
||||
const fixture = createLifecycleFixture();
|
||||
const manager = new SnapshotBridgeManager(fixture.host);
|
||||
|
||||
await manager.request({ target, bridge, limits, maxDepth: 10, deadline: deadline() });
|
||||
await manager.request({ target, bridge, limits, maxDepth: 10, deadline: deadline() });
|
||||
|
||||
assert.equal(fixture.processes.length, 1);
|
||||
assert.equal(fixture.sockets.length, 1);
|
||||
await manager.close();
|
||||
assert.deepEqual(fixture.processes[0]!.signals, ['SIGTERM']);
|
||||
});
|
||||
|
||||
test('a new target generation reuses the healthy helper and carries generation per request', async () => {
|
||||
const fixture = createLifecycleFixture();
|
||||
const manager = new SnapshotBridgeManager(fixture.host);
|
||||
|
||||
await manager.request({ target, bridge, limits, maxDepth: 10, deadline: deadline() });
|
||||
await manager.request({
|
||||
target: { ...target, generation: 'generation-2' },
|
||||
bridge,
|
||||
limits,
|
||||
maxDepth: 10,
|
||||
deadline: deadline(),
|
||||
});
|
||||
|
||||
assert.equal(fixture.processes.length, 1);
|
||||
assert.deepEqual(fixture.processes[0]!.signals, []);
|
||||
await manager.close();
|
||||
assert.deepEqual(fixture.processes[0]!.signals, ['SIGTERM']);
|
||||
});
|
||||
|
||||
test('cancellation while queued prevents a later dispatch', async () => {
|
||||
const fixture = createLifecycleFixture({ responseDelayMs: 200 });
|
||||
const manager = new SnapshotBridgeManager(fixture.host);
|
||||
const first = manager.request({
|
||||
target,
|
||||
bridge,
|
||||
limits,
|
||||
maxDepth: 10,
|
||||
deadline: deadline(undefined, 1000),
|
||||
});
|
||||
await waitForDispatch(fixture);
|
||||
|
||||
const controller = new AbortController();
|
||||
const startedAt = Date.now();
|
||||
const queued = manager.request({
|
||||
target,
|
||||
bridge,
|
||||
limits,
|
||||
maxDepth: 10,
|
||||
deadline: deadline(controller.signal, 1000),
|
||||
});
|
||||
setTimeout(() => controller.abort(), 10);
|
||||
|
||||
await assert.rejects(
|
||||
queued,
|
||||
(error: unknown) => error instanceof SnapshotSourceError && error.failureKind === 'cancelled',
|
||||
);
|
||||
assert.ok(Date.now() - startedAt < 100);
|
||||
assert.equal(fixture.sockets[0]?.writes, 1);
|
||||
await first;
|
||||
assert.equal(fixture.sockets.length, 1);
|
||||
await manager.close();
|
||||
});
|
||||
|
||||
test('cancelling a middle waiter does not release the following request early', async () => {
|
||||
const fixture = createLifecycleFixture({ responseDelayMs: 120 });
|
||||
const manager = new SnapshotBridgeManager(fixture.host);
|
||||
const first = manager.request({
|
||||
target,
|
||||
bridge,
|
||||
limits,
|
||||
maxDepth: 10,
|
||||
deadline: deadline(undefined, 1000),
|
||||
});
|
||||
await waitForDispatch(fixture);
|
||||
|
||||
const controller = new AbortController();
|
||||
const middle = manager.request({
|
||||
target,
|
||||
bridge,
|
||||
limits,
|
||||
maxDepth: 10,
|
||||
deadline: deadline(controller.signal, 1000),
|
||||
});
|
||||
const last = manager.request({
|
||||
target,
|
||||
bridge,
|
||||
limits,
|
||||
maxDepth: 10,
|
||||
deadline: deadline(undefined, 1000),
|
||||
});
|
||||
controller.abort();
|
||||
|
||||
await assert.rejects(
|
||||
middle,
|
||||
(error: unknown) => error instanceof SnapshotSourceError && error.failureKind === 'cancelled',
|
||||
);
|
||||
assert.equal(fixture.sockets[0]?.writes, 1);
|
||||
await first;
|
||||
await last;
|
||||
assert.equal(fixture.sockets[0]?.writes, 2);
|
||||
await manager.close();
|
||||
});
|
||||
|
||||
test('independent managers own distinct sockets for the same Simulator', async () => {
|
||||
const fixture = createLifecycleFixture();
|
||||
const first = new SnapshotBridgeManager(fixture.host);
|
||||
const second = new SnapshotBridgeManager(fixture.host);
|
||||
|
||||
await first.request({ target, bridge, limits, maxDepth: 10, deadline: deadline() });
|
||||
await second.request({ target, bridge, limits, maxDepth: 10, deadline: deadline() });
|
||||
|
||||
assert.equal(fixture.socketPaths.length, 2);
|
||||
assert.notEqual(fixture.socketPaths[0], fixture.socketPaths[1]);
|
||||
await first.close();
|
||||
assert.equal(fixture.processes[1]?.isAlive(), true);
|
||||
await second.request({ target, bridge, limits, maxDepth: 10, deadline: deadline() });
|
||||
await second.close();
|
||||
});
|
||||
|
||||
test('request cancellation after dispatch reaps the exact helper before recovery', async () => {
|
||||
const fixture = createLifecycleFixture({ responseDelayMs: 80 });
|
||||
const manager = new SnapshotBridgeManager(fixture.host);
|
||||
const controller = new AbortController();
|
||||
const request = manager.request({
|
||||
target,
|
||||
bridge,
|
||||
limits,
|
||||
maxDepth: 10,
|
||||
deadline: deadline(controller.signal),
|
||||
});
|
||||
setTimeout(() => controller.abort(), 10);
|
||||
|
||||
await assert.rejects(
|
||||
request,
|
||||
(error: unknown) => error instanceof SnapshotSourceError && error.failureKind === 'cancelled',
|
||||
);
|
||||
assert.deepEqual(fixture.processes[0]?.signals, ['SIGTERM']);
|
||||
assert.equal(fixture.processes[0]?.alive, false);
|
||||
await manager.request({ target, bridge, limits, maxDepth: 10, deadline: deadline() });
|
||||
assert.equal(fixture.processes.length, 2);
|
||||
await manager.close();
|
||||
assert.deepEqual(fixture.processes[1]?.signals, ['SIGTERM']);
|
||||
});
|
||||
|
||||
test('pre-dispatch cancellation preserves a healthy helper', async () => {
|
||||
const fixture = createLifecycleFixture({ connectDelayMs: 40 });
|
||||
const manager = new SnapshotBridgeManager(fixture.host);
|
||||
await manager.request({
|
||||
target,
|
||||
bridge,
|
||||
limits: { ...limits, maxDurationMs: 500 },
|
||||
maxDepth: 10,
|
||||
deadline: deadline(undefined, 500),
|
||||
});
|
||||
fixture.sockets[0]!.destroy();
|
||||
|
||||
const controller = new AbortController();
|
||||
const request = manager.request({
|
||||
target,
|
||||
bridge,
|
||||
limits: { ...limits, maxDurationMs: 500 },
|
||||
maxDepth: 10,
|
||||
deadline: deadline(controller.signal, 500),
|
||||
});
|
||||
setTimeout(() => controller.abort(), 10);
|
||||
|
||||
await assert.rejects(
|
||||
request,
|
||||
(error: unknown) => error instanceof SnapshotSourceError && error.failureKind === 'cancelled',
|
||||
);
|
||||
assert.equal(fixture.processes.length, 1);
|
||||
assert.deepEqual(fixture.processes[0]?.signals, []);
|
||||
assert.equal(fixture.processes[0]?.alive, true);
|
||||
|
||||
await manager.request({
|
||||
target,
|
||||
bridge,
|
||||
limits: { ...limits, maxDurationMs: 500 },
|
||||
maxDepth: 10,
|
||||
deadline: deadline(undefined, 500),
|
||||
});
|
||||
assert.equal(fixture.processes.length, 1);
|
||||
await manager.close();
|
||||
assert.deepEqual(fixture.processes[0]?.signals, ['SIGTERM']);
|
||||
});
|
||||
|
||||
test('one absolute deadline covers helper connect and response read', async () => {
|
||||
const fixture = createLifecycleFixture({ connectDelayMs: 70, responseDelayMs: 70 });
|
||||
const manager = new SnapshotBridgeManager(fixture.host);
|
||||
|
||||
await assert.rejects(
|
||||
manager.request({
|
||||
target,
|
||||
bridge,
|
||||
limits: { ...limits, maxDurationMs: 100 },
|
||||
maxDepth: 10,
|
||||
deadline: deadline(undefined, 100),
|
||||
}),
|
||||
(error: unknown) => error instanceof SnapshotSourceError && error.failureKind === 'timeout',
|
||||
);
|
||||
assert.deepEqual(fixture.processes[0]?.signals, ['SIGTERM']);
|
||||
await manager.close();
|
||||
});
|
||||
|
||||
test('a crashed helper is removed and the next request starts a fresh helper', async () => {
|
||||
const fixture = createLifecycleFixture({ responseDelayMs: 80 });
|
||||
const manager = new SnapshotBridgeManager(fixture.host);
|
||||
const request = manager.request({ target, bridge, limits, maxDepth: 10, deadline: deadline() });
|
||||
setTimeout(() => fixture.processes[0]?.crash(), 10);
|
||||
|
||||
await assert.rejects(
|
||||
request,
|
||||
(error: unknown) =>
|
||||
error instanceof SnapshotSourceError && error.failureKind === 'process-crash',
|
||||
);
|
||||
assert.equal(fixture.processes[0]?.signals.length, 0);
|
||||
|
||||
await manager.request({ target, bridge, limits, maxDepth: 10, deadline: deadline() });
|
||||
assert.equal(fixture.processes.length, 2);
|
||||
await manager.close();
|
||||
});
|
||||
|
||||
test('a crashed helper emits its bounded log once and keeps exit facts typed', async () => {
|
||||
const fixture = createLifecycleFixture({ responseDelayMs: 80 });
|
||||
const manager = new SnapshotBridgeManager(fixture.host);
|
||||
const request = manager.request({ target, bridge, limits, maxDepth: 10, deadline: deadline() });
|
||||
setTimeout(() => fixture.processes[0]?.crash(), 10);
|
||||
let failure: SnapshotSourceError | undefined;
|
||||
|
||||
await assert.rejects(request, (error: unknown) => {
|
||||
failure = error instanceof SnapshotSourceError ? error : undefined;
|
||||
return failure?.failureKind === 'process-crash';
|
||||
});
|
||||
assert.equal(failure?.details?.pid, 700);
|
||||
assert.equal(failure?.details?.exitCode, 1);
|
||||
assert.equal(failure?.details?.log, undefined);
|
||||
const processDiagnostics = fixture.diagnostics.filter(
|
||||
(event) => event.phase === 'ios.snapshot-source.bridge-process-exit',
|
||||
);
|
||||
assert.equal(processDiagnostics.length, 1);
|
||||
assert.equal(processDiagnostics[0]?.data?.pid, 700);
|
||||
assert.equal(processDiagnostics[0]?.data?.stderr, 'fixture log');
|
||||
await manager.close();
|
||||
});
|
||||
|
||||
test('the manager rejects a response for a different target process as stale', async () => {
|
||||
const fixture = createLifecycleFixture({ responsePid: target.pid + 1 });
|
||||
const manager = new SnapshotBridgeManager(fixture.host);
|
||||
|
||||
await assert.rejects(
|
||||
manager.request({ target, bridge, limits, maxDepth: 10, deadline: deadline() }),
|
||||
(error: unknown) =>
|
||||
error instanceof SnapshotSourceError && error.failureKind === 'stale-target',
|
||||
);
|
||||
await manager.close();
|
||||
});
|
||||
|
||||
test('the manager rejects a response carrying a previous target generation as stale', async () => {
|
||||
const fixture = createLifecycleFixture({ responseGeneration: 'generation-0' });
|
||||
const manager = new SnapshotBridgeManager(fixture.host);
|
||||
|
||||
await assert.rejects(
|
||||
manager.request({ target, bridge, limits, maxDepth: 10, deadline: deadline() }),
|
||||
(error: unknown) =>
|
||||
error instanceof SnapshotSourceError &&
|
||||
error.failureKind === 'stale-target' &&
|
||||
error.failureCode === 'bridge-generation-mismatch',
|
||||
);
|
||||
await manager.close();
|
||||
});
|
||||
|
||||
test('the manager rejects a tree when the target process changes during acquisition', async () => {
|
||||
const fixture = createLifecycleFixture({ targetStartTimes: ['start-1', 'start-2'] });
|
||||
const manager = new SnapshotBridgeManager(fixture.host);
|
||||
|
||||
await assert.rejects(
|
||||
manager.request({ target, bridge, limits, maxDepth: 10, deadline: deadline() }),
|
||||
(error: unknown) =>
|
||||
error instanceof SnapshotSourceError &&
|
||||
error.failureKind === 'stale-target' &&
|
||||
error.failureCode === 'target-process-changed',
|
||||
);
|
||||
await manager.close();
|
||||
});
|
||||
|
||||
test('typed guest failures retain their kind after target validation', async () => {
|
||||
const fixture = createLifecycleFixture({ responseErrorKind: 'application_not_responding' });
|
||||
const manager = new SnapshotBridgeManager(fixture.host);
|
||||
|
||||
await assert.rejects(
|
||||
manager.request({ target, bridge, limits, maxDepth: 10, deadline: deadline() }),
|
||||
(error: unknown) => error instanceof SnapshotSourceError && error.failureKind === 'timeout',
|
||||
);
|
||||
await manager.close();
|
||||
});
|
||||
|
||||
type LifecycleFixture = {
|
||||
host: SnapshotSourceHost;
|
||||
processes: FakeProcess[];
|
||||
sockets: FakeSocket[];
|
||||
diagnostics: Array<Parameters<SnapshotSourceHost['emitDiagnostic']>[0]>;
|
||||
socketPaths: string[];
|
||||
};
|
||||
|
||||
function deadline(signal?: AbortSignal, timeoutMs = limits.maxDurationMs) {
|
||||
return createSnapshotSourceDeadline(timeoutMs, signal);
|
||||
}
|
||||
|
||||
async function waitForDispatch(fixture: LifecycleFixture): Promise<void> {
|
||||
const startedAt = Date.now();
|
||||
while (fixture.sockets[0]?.writes !== 1) {
|
||||
if (Date.now() - startedAt >= 1000) throw new Error('Fixture request was not dispatched');
|
||||
await new Promise((resolve) => setTimeout(resolve, 1));
|
||||
}
|
||||
}
|
||||
|
||||
function createLifecycleFixture(
|
||||
options: {
|
||||
connectDelayMs?: number;
|
||||
responseDelayMs?: number;
|
||||
responsePid?: number;
|
||||
responseGeneration?: string;
|
||||
responseErrorKind?: string;
|
||||
targetStartTimes?: Array<string | null>;
|
||||
} = {},
|
||||
): LifecycleFixture {
|
||||
const processes: FakeProcess[] = [];
|
||||
const sockets: FakeSocket[] = [];
|
||||
const diagnostics: LifecycleFixture['diagnostics'] = [];
|
||||
const socketPaths: string[] = [];
|
||||
const realHost = createSnapshotSourceHost();
|
||||
const host: SnapshotSourceHost = {
|
||||
...realHost,
|
||||
emitDiagnostic: (event) => diagnostics.push(event),
|
||||
readTargetProcessStartTime: async () => options.targetStartTimes?.shift() ?? 'target-start',
|
||||
start: (_udid, _bridgePath, socketPath) => {
|
||||
socketPaths.push(socketPath);
|
||||
const process = new FakeProcess(700 + processes.length);
|
||||
processes.push(process);
|
||||
return process;
|
||||
},
|
||||
connect: async (_socketPath, connectOptions) => {
|
||||
if (options.connectDelayMs) {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const finish = (error?: SnapshotSourceError) => {
|
||||
clearTimeout(timer);
|
||||
connectOptions.signal?.removeEventListener('abort', onAbort);
|
||||
if (error) reject(error);
|
||||
else resolve();
|
||||
};
|
||||
const timer = setTimeout(() => finish(), options.connectDelayMs);
|
||||
const onAbort = () => {
|
||||
finish(snapshotSourceError('cancelled', 'abort-signal'));
|
||||
};
|
||||
connectOptions.signal?.addEventListener('abort', onAbort, { once: true });
|
||||
if (connectOptions.signal?.aborted) onAbort();
|
||||
});
|
||||
}
|
||||
const socket = new FakeSocket(
|
||||
options.responseDelayMs ?? 0,
|
||||
options.responsePid ?? target.pid,
|
||||
options.responseGeneration,
|
||||
options.responseErrorKind,
|
||||
);
|
||||
sockets.push(socket);
|
||||
return socket;
|
||||
},
|
||||
};
|
||||
return { host, processes, sockets, diagnostics, socketPaths };
|
||||
}
|
||||
|
||||
class FakeProcess implements SnapshotSourceProcess {
|
||||
alive = true;
|
||||
signals: NodeJS.Signals[] = [];
|
||||
readonly wait: Promise<{ stdout: string; stderr: string; exitCode: number }>;
|
||||
private readonly processId: number;
|
||||
private resolveWait!: (result: { stdout: string; stderr: string; exitCode: number }) => void;
|
||||
|
||||
constructor(pid: number) {
|
||||
this.processId = pid;
|
||||
this.wait = new Promise((resolve) => {
|
||||
this.resolveWait = resolve;
|
||||
});
|
||||
}
|
||||
|
||||
get pid(): number {
|
||||
return this.processId;
|
||||
}
|
||||
|
||||
isAlive(): boolean {
|
||||
return this.alive;
|
||||
}
|
||||
|
||||
signal(signal: NodeJS.Signals): void {
|
||||
this.signals.push(signal);
|
||||
this.alive = false;
|
||||
this.resolveWait({ stdout: '', stderr: '', exitCode: signal === 'SIGKILL' ? 137 : 0 });
|
||||
}
|
||||
|
||||
crash(): void {
|
||||
this.alive = false;
|
||||
this.resolveWait({ stdout: '', stderr: 'crashed', exitCode: 1 });
|
||||
}
|
||||
|
||||
readLog(): string {
|
||||
return 'fixture log';
|
||||
}
|
||||
}
|
||||
|
||||
class FakeSocket extends EventEmitter implements SnapshotSourceSocket {
|
||||
destroyed = false;
|
||||
writes = 0;
|
||||
private readonly responseDelayMs: number;
|
||||
private readonly responsePid: number;
|
||||
private readonly responseGeneration: string | undefined;
|
||||
private readonly responseErrorKind: string | undefined;
|
||||
|
||||
constructor(
|
||||
responseDelayMs: number,
|
||||
responsePid: number,
|
||||
responseGeneration: string | undefined,
|
||||
responseErrorKind?: string,
|
||||
) {
|
||||
super();
|
||||
this.responseDelayMs = responseDelayMs;
|
||||
this.responsePid = responsePid;
|
||||
this.responseGeneration = responseGeneration;
|
||||
this.responseErrorKind = responseErrorKind;
|
||||
}
|
||||
|
||||
write(frame: Buffer): boolean {
|
||||
this.writes += 1;
|
||||
const bodyLength = frame.readUInt32BE(0);
|
||||
const request = JSON.parse(frame.subarray(4, bodyLength + 4).toString('utf8')) as {
|
||||
requestId: string;
|
||||
pid: number;
|
||||
generation: string;
|
||||
};
|
||||
setTimeout(() => {
|
||||
if (this.destroyed) return;
|
||||
this.emit(
|
||||
'data',
|
||||
encodeSnapshotBridgeFrame(
|
||||
this.responseErrorKind
|
||||
? {
|
||||
protocolVersion: SNAPSHOT_SOURCE_PROTOCOL_VERSION,
|
||||
sourceVersion: SNAPSHOT_SOURCE_VERSION,
|
||||
requestId: request.requestId,
|
||||
ok: false,
|
||||
pid: this.responsePid || request.pid,
|
||||
generation: this.responseGeneration ?? request.generation,
|
||||
error_kind: this.responseErrorKind,
|
||||
error_code: 'fixture-error',
|
||||
}
|
||||
: {
|
||||
protocolVersion: SNAPSHOT_SOURCE_PROTOCOL_VERSION,
|
||||
sourceVersion: SNAPSHOT_SOURCE_VERSION,
|
||||
requestId: request.requestId,
|
||||
ok: true,
|
||||
pid: this.responsePid || request.pid,
|
||||
generation: this.responseGeneration ?? request.generation,
|
||||
truncated: false,
|
||||
automationEnabled: true,
|
||||
tree: {
|
||||
XC_kAXXCAttributeElementType: 'Application',
|
||||
XC_kAXXCAttributeChildren: [],
|
||||
},
|
||||
},
|
||||
limits,
|
||||
),
|
||||
);
|
||||
}, this.responseDelayMs);
|
||||
return true;
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
if (this.destroyed) return;
|
||||
this.destroyed = true;
|
||||
queueMicrotask(() => this.emit('close'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import path from 'node:path';
|
||||
import { asSnapshotSourceError, snapshotSourceError, SnapshotSourceError } from './errors.ts';
|
||||
import {
|
||||
remainingSnapshotSourceMs,
|
||||
waitForSnapshotSourceDelay,
|
||||
type SnapshotSourceDeadline,
|
||||
} from './deadline.ts';
|
||||
import { snapshotSourceSocketPath } from './host.ts';
|
||||
import { bridgeProcessExited } from './process.ts';
|
||||
import { createSnapshotBridgeDescribeRequest, encodeSnapshotBridgeFrame } from './protocol.ts';
|
||||
import { roundTripSnapshotBridge } from './transport.ts';
|
||||
import type {
|
||||
SnapshotSourceBridgeBinary,
|
||||
SnapshotSourceHost,
|
||||
SnapshotSourceLimits,
|
||||
SnapshotSourceProcess,
|
||||
SnapshotSourceSocket,
|
||||
SnapshotSourceTarget,
|
||||
} from './types.ts';
|
||||
import type { SnapshotBridgeEnvelope } from './protocol.ts';
|
||||
|
||||
const CONNECT_RETRY_DELAY_MS = 20;
|
||||
const CONNECT_ATTEMPT_TIMEOUT_MS = 250;
|
||||
const SHUTDOWN_TERM_TIMEOUT_MS = 500;
|
||||
const SHUTDOWN_KILL_TIMEOUT_MS = 500;
|
||||
|
||||
type BridgeSession = {
|
||||
readonly udid: string;
|
||||
readonly bridgePath: string;
|
||||
readonly socketPath: string;
|
||||
readonly process: SnapshotSourceProcess;
|
||||
socket?: SnapshotSourceSocket;
|
||||
};
|
||||
|
||||
type SnapshotBridgeRequest = Readonly<{
|
||||
target: SnapshotSourceTarget;
|
||||
bridge: SnapshotSourceBridgeBinary;
|
||||
limits: SnapshotSourceLimits;
|
||||
maxDepth: number;
|
||||
deadline: SnapshotSourceDeadline;
|
||||
}>;
|
||||
|
||||
export class SnapshotBridgeManager {
|
||||
private readonly sessions = new Map<string, BridgeSession>();
|
||||
private readonly requestQueues = new Map<string, Promise<void>>();
|
||||
private readonly ownerId = randomUUID();
|
||||
private closed = false;
|
||||
private readonly host: SnapshotSourceHost;
|
||||
|
||||
constructor(host: SnapshotSourceHost) {
|
||||
this.host = host;
|
||||
}
|
||||
|
||||
async request(input: SnapshotBridgeRequest): Promise<SnapshotBridgeEnvelope> {
|
||||
if (this.closed) throw snapshotSourceError('unsupported', 'source-closed');
|
||||
return await this.withSimulatorLock(input.target.udid, input.deadline, () =>
|
||||
this.requestInSimulator(input),
|
||||
);
|
||||
}
|
||||
|
||||
private async requestInSimulator(input: SnapshotBridgeRequest): Promise<SnapshotBridgeEnvelope> {
|
||||
if (this.closed) throw snapshotSourceError('unsupported', 'source-closed');
|
||||
const deadline = input.deadline;
|
||||
remainingSnapshotSourceMs(deadline, 'bridge-request-deadline');
|
||||
const previousSession = this.sessions.get(input.target.udid);
|
||||
const session = await this.ensureSession(input, deadline);
|
||||
try {
|
||||
const targetStartTime = await this.readTargetStartTime(input.target, deadline);
|
||||
const envelope = await this.exchange(session, input, deadline);
|
||||
await this.assertTargetStillCurrent(input.target, targetStartTime, deadline);
|
||||
return envelope;
|
||||
} catch (error) {
|
||||
const normalized = await this.handleRequestFailure(error, session, previousSession);
|
||||
throw normalized;
|
||||
}
|
||||
}
|
||||
|
||||
private async handleRequestFailure(
|
||||
error: unknown,
|
||||
session: BridgeSession,
|
||||
previousSession: BridgeSession | undefined,
|
||||
): Promise<SnapshotSourceError> {
|
||||
const normalized = asSnapshotSourceError(error);
|
||||
if (shouldDiscardSession(normalized, session, previousSession)) {
|
||||
await this.removeSession(session, true);
|
||||
} else if (normalized.failureKind === 'process-crash') {
|
||||
await this.removeSession(session, false);
|
||||
} else if (normalized.failureKind === 'transport-failure') {
|
||||
session.socket?.destroy();
|
||||
session.socket = undefined;
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private async withSimulatorLock<T>(
|
||||
udid: string,
|
||||
deadline: SnapshotSourceDeadline,
|
||||
action: () => Promise<T>,
|
||||
): Promise<T> {
|
||||
const previous = this.requestQueues.get(udid) ?? Promise.resolve();
|
||||
let release!: () => void;
|
||||
const turnFinished = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
const current = previous.then(() => turnFinished);
|
||||
this.requestQueues.set(udid, current);
|
||||
try {
|
||||
await waitForSimulatorTurn(previous, deadline);
|
||||
return await action();
|
||||
} finally {
|
||||
release();
|
||||
if (this.requestQueues.get(udid) === current) this.requestQueues.delete(udid);
|
||||
}
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
if (this.closed) return;
|
||||
this.closed = true;
|
||||
const sessions = [...this.sessions.values()];
|
||||
this.sessions.clear();
|
||||
await Promise.all(sessions.map(async (session) => await this.dispose(session, true)));
|
||||
}
|
||||
|
||||
private async ensureSession(
|
||||
input: SnapshotBridgeRequest,
|
||||
deadline: SnapshotSourceDeadline,
|
||||
): Promise<BridgeSession> {
|
||||
const key = input.target.udid;
|
||||
const existing = this.sessions.get(key);
|
||||
if (existing && existing.bridgePath === input.bridge.path && existing.process.isAlive()) {
|
||||
if (!existing.socket || existing.socket.destroyed) {
|
||||
existing.socket = await this.connectUntilReady(existing, deadline);
|
||||
}
|
||||
return existing;
|
||||
}
|
||||
if (existing) await this.removeSession(existing, true);
|
||||
|
||||
const socketPath = snapshotSourceSocketPath(this.host, input.target.udid, this.ownerId);
|
||||
await this.host.ensureDirectory(path.dirname(socketPath));
|
||||
await this.host.remove(socketPath);
|
||||
const bridgeProcess = this.host.start(input.target.udid, input.bridge.path, socketPath, {
|
||||
signal: deadline.signal,
|
||||
});
|
||||
const session: BridgeSession = {
|
||||
udid: input.target.udid,
|
||||
bridgePath: input.bridge.path,
|
||||
socketPath,
|
||||
process: bridgeProcess,
|
||||
};
|
||||
this.sessions.set(key, session);
|
||||
try {
|
||||
session.socket = await this.connectUntilReady(session, deadline);
|
||||
return session;
|
||||
} catch (error) {
|
||||
await this.removeSession(session, true);
|
||||
throw asSnapshotSourceError(error);
|
||||
}
|
||||
}
|
||||
|
||||
private async connectUntilReady(
|
||||
session: BridgeSession,
|
||||
deadline: SnapshotSourceDeadline,
|
||||
): Promise<SnapshotSourceSocket> {
|
||||
let lastError: unknown;
|
||||
while (true) {
|
||||
const remainingMs = remainingSnapshotSourceMs(deadline, 'bridge-connect-deadline');
|
||||
if (!session.process.isAlive()) {
|
||||
throw await bridgeProcessExited(this.host, session.process);
|
||||
}
|
||||
try {
|
||||
return await this.host.connect(session.socketPath, {
|
||||
signal: deadline.signal,
|
||||
timeoutMs: Math.min(CONNECT_ATTEMPT_TIMEOUT_MS, remainingMs),
|
||||
});
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
if (error instanceof SnapshotSourceError && error.failureKind === 'cancelled') {
|
||||
throw error;
|
||||
}
|
||||
const delayMs = Math.min(
|
||||
CONNECT_RETRY_DELAY_MS,
|
||||
remainingSnapshotSourceMs(deadline, 'bridge-connect-deadline'),
|
||||
);
|
||||
try {
|
||||
await waitForSnapshotSourceDelay(deadline, delayMs, 'bridge-connect-deadline');
|
||||
} catch (sleepError) {
|
||||
throw asSnapshotSourceError(sleepError);
|
||||
}
|
||||
}
|
||||
if (lastError instanceof SnapshotSourceError && lastError.failureKind === 'timeout') {
|
||||
throw lastError;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async exchange(
|
||||
session: BridgeSession,
|
||||
input: SnapshotBridgeRequest,
|
||||
deadline: SnapshotSourceDeadline,
|
||||
): Promise<SnapshotBridgeEnvelope> {
|
||||
if (!session.socket || session.socket.destroyed) {
|
||||
session.socket = await this.connectUntilReady(session, deadline);
|
||||
}
|
||||
const requestId = randomUUID();
|
||||
const request = createSnapshotBridgeDescribeRequest({
|
||||
requestId,
|
||||
pid: input.target.pid,
|
||||
generation: input.target.generation,
|
||||
maxDepth: Math.min(input.limits.maxTraversalDepth, input.maxDepth),
|
||||
maxNodes: input.limits.maxNodes,
|
||||
maxDurationMs: remainingSnapshotSourceMs(deadline, 'bridge-request-deadline'),
|
||||
maxResponseBytes: input.limits.maxResponseBytes,
|
||||
});
|
||||
const frame = encodeSnapshotBridgeFrame(request, input.limits);
|
||||
return await roundTripSnapshotBridge({
|
||||
process: session.process,
|
||||
socket: session.socket,
|
||||
frame,
|
||||
requestId,
|
||||
deadline,
|
||||
limits: input.limits,
|
||||
expectedPid: input.target.pid,
|
||||
expectedGeneration: input.target.generation,
|
||||
host: this.host,
|
||||
});
|
||||
}
|
||||
|
||||
private async readTargetStartTime(
|
||||
target: SnapshotSourceTarget,
|
||||
deadline: SnapshotSourceDeadline,
|
||||
): Promise<string> {
|
||||
const startTime = await this.host.readTargetProcessStartTime(target.pid, {
|
||||
signal: deadline.signal,
|
||||
timeoutMs: remainingSnapshotSourceMs(deadline, 'target-identity-deadline'),
|
||||
});
|
||||
if (!startTime) {
|
||||
throw snapshotSourceError('stale-target', 'target-process-unavailable', {
|
||||
pid: target.pid,
|
||||
generation: target.generation,
|
||||
});
|
||||
}
|
||||
return startTime;
|
||||
}
|
||||
|
||||
private async assertTargetStillCurrent(
|
||||
target: SnapshotSourceTarget,
|
||||
expectedStartTime: string,
|
||||
deadline: SnapshotSourceDeadline,
|
||||
): Promise<void> {
|
||||
const observedStartTime = await this.host.readTargetProcessStartTime(target.pid, {
|
||||
signal: deadline.signal,
|
||||
timeoutMs: remainingSnapshotSourceMs(deadline, 'target-identity-deadline'),
|
||||
});
|
||||
if (observedStartTime !== expectedStartTime) {
|
||||
throw snapshotSourceError('stale-target', 'target-process-changed', {
|
||||
pid: target.pid,
|
||||
generation: target.generation,
|
||||
expectedStartTime,
|
||||
observedStartTime,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async removeSession(session: BridgeSession, stopProcess: boolean): Promise<void> {
|
||||
if (this.sessions.get(session.udid) === session) this.sessions.delete(session.udid);
|
||||
await this.dispose(session, stopProcess);
|
||||
}
|
||||
|
||||
private async dispose(session: BridgeSession, stopProcess: boolean): Promise<void> {
|
||||
session.socket?.destroy();
|
||||
session.socket = undefined;
|
||||
if (stopProcess && session.process.isAlive()) {
|
||||
session.process.signal('SIGTERM');
|
||||
await waitForProcess(session.process, SHUTDOWN_TERM_TIMEOUT_MS);
|
||||
if (session.process.isAlive()) {
|
||||
session.process.signal('SIGKILL');
|
||||
await waitForProcess(session.process, SHUTDOWN_KILL_TIMEOUT_MS);
|
||||
}
|
||||
}
|
||||
await this.host.remove(session.socketPath);
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForSimulatorTurn(
|
||||
previous: Promise<void>,
|
||||
deadline: SnapshotSourceDeadline,
|
||||
): Promise<void> {
|
||||
const timeoutMs = remainingSnapshotSourceMs(deadline, 'bridge-request-deadline');
|
||||
await Promise.race([
|
||||
previous,
|
||||
waitForSnapshotSourceDelay(deadline, timeoutMs, 'bridge-request-deadline').then(() => {
|
||||
throw snapshotSourceError('timeout', 'bridge-request-deadline');
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
||||
function shouldDiscardSession(
|
||||
error: SnapshotSourceError,
|
||||
session: BridgeSession,
|
||||
previousSession: BridgeSession | undefined,
|
||||
): boolean {
|
||||
return (
|
||||
(error.failureKind === 'cancelled' || error.failureKind === 'timeout') &&
|
||||
(error.details?.dispatched === true || previousSession !== session)
|
||||
);
|
||||
}
|
||||
|
||||
async function waitForProcess(
|
||||
bridgeProcess: SnapshotSourceProcess,
|
||||
timeoutMs: number,
|
||||
): Promise<void> {
|
||||
await Promise.race([
|
||||
bridgeProcess.wait.catch(() => undefined),
|
||||
new Promise<void>((resolve) => setTimeout(resolve, timeoutMs)),
|
||||
]);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { AppError } from '@agent-device/kernel/errors';
|
||||
import type { SnapshotSourceLimits } from './types.ts';
|
||||
|
||||
const FRAME_HEADER_BYTES = 4;
|
||||
const MAXIMUM_FRAME_BYTES = 16 * 1024 * 1024;
|
||||
const MAXIMUM_DURATION_MS = 120_000;
|
||||
const MAXIMUM_DEPTH = 128;
|
||||
const MAXIMUM_NODES = 10_000;
|
||||
|
||||
export const DEFAULT_SNAPSHOT_SOURCE_LIMITS: SnapshotSourceLimits = Object.freeze({
|
||||
maxRequestBytes: 64 * 1024,
|
||||
maxResponseBytes: 4 * 1024 * 1024,
|
||||
maxNodes: 1500,
|
||||
maxTraversalDepth: 64,
|
||||
maxDurationMs: 5_000,
|
||||
});
|
||||
|
||||
export function resolveSnapshotSourceLimits(
|
||||
overrides: Partial<SnapshotSourceLimits> | undefined,
|
||||
): SnapshotSourceLimits {
|
||||
const limits = { ...DEFAULT_SNAPSHOT_SOURCE_LIMITS, ...(overrides ?? {}) };
|
||||
for (const [name, value] of Object.entries(limits)) {
|
||||
if (!Number.isSafeInteger(value) || value <= 0) {
|
||||
throw new AppError(
|
||||
'INVALID_ARGS',
|
||||
`Snapshot source limit ${name} must be a positive integer`,
|
||||
{
|
||||
name,
|
||||
value,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
if (limits.maxRequestBytes > limits.maxResponseBytes) {
|
||||
throw new AppError(
|
||||
'INVALID_ARGS',
|
||||
'Snapshot source request bytes cannot exceed response bytes',
|
||||
{
|
||||
maxRequestBytes: limits.maxRequestBytes,
|
||||
maxResponseBytes: limits.maxResponseBytes,
|
||||
},
|
||||
);
|
||||
}
|
||||
if (
|
||||
limits.maxRequestBytes > MAXIMUM_FRAME_BYTES ||
|
||||
limits.maxResponseBytes > MAXIMUM_FRAME_BYTES
|
||||
) {
|
||||
throw new AppError('INVALID_ARGS', 'Snapshot source frame limits exceed the bridge bound', {
|
||||
maxRequestBytes: limits.maxRequestBytes,
|
||||
maxResponseBytes: limits.maxResponseBytes,
|
||||
maximumFrameBytes: MAXIMUM_FRAME_BYTES,
|
||||
});
|
||||
}
|
||||
if (limits.maxNodes > MAXIMUM_NODES || limits.maxTraversalDepth > MAXIMUM_DEPTH) {
|
||||
throw new AppError('INVALID_ARGS', 'Snapshot source tree limits exceed the bridge bound', {
|
||||
maxNodes: limits.maxNodes,
|
||||
maxTraversalDepth: limits.maxTraversalDepth,
|
||||
maximumNodes: MAXIMUM_NODES,
|
||||
maximumDepth: MAXIMUM_DEPTH,
|
||||
});
|
||||
}
|
||||
if (limits.maxDurationMs > MAXIMUM_DURATION_MS) {
|
||||
throw new AppError('INVALID_ARGS', 'Snapshot source duration exceeds the bridge bound', {
|
||||
maxDurationMs: limits.maxDurationMs,
|
||||
maximumDurationMs: MAXIMUM_DURATION_MS,
|
||||
});
|
||||
}
|
||||
if (
|
||||
limits.maxRequestBytes <= FRAME_HEADER_BYTES ||
|
||||
limits.maxResponseBytes <= FRAME_HEADER_BYTES
|
||||
) {
|
||||
throw new AppError('INVALID_ARGS', 'Snapshot source frame limits must leave room for a body', {
|
||||
maxRequestBytes: limits.maxRequestBytes,
|
||||
maxResponseBytes: limits.maxResponseBytes,
|
||||
});
|
||||
}
|
||||
return Object.freeze(limits);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { Buffer } from 'node:buffer';
|
||||
import type { ExecResult } from '@agent-device/host-kit/command';
|
||||
import { snapshotSourceError, type SnapshotSourceError } from './errors.ts';
|
||||
import type { SnapshotSourceHost, SnapshotSourceProcess } from './types.ts';
|
||||
|
||||
const MAX_PROCESS_LOG_BYTES = 64 * 1024;
|
||||
const diagnosedProcesses = new WeakSet<SnapshotSourceProcess>();
|
||||
|
||||
export async function bridgeProcessExited(
|
||||
host: SnapshotSourceHost,
|
||||
bridgeProcess: SnapshotSourceProcess,
|
||||
): Promise<SnapshotSourceError> {
|
||||
let exitCode: ExecResult['exitCode'] | undefined;
|
||||
try {
|
||||
exitCode = (await bridgeProcess.wait).exitCode;
|
||||
} catch {
|
||||
exitCode = undefined;
|
||||
}
|
||||
if (!diagnosedProcesses.has(bridgeProcess)) {
|
||||
diagnosedProcesses.add(bridgeProcess);
|
||||
host.emitDiagnostic({
|
||||
level: 'error',
|
||||
phase: 'ios.snapshot-source.bridge-process-exit',
|
||||
data: {
|
||||
pid: bridgeProcess.pid,
|
||||
...(exitCode === undefined ? {} : { exitCode }),
|
||||
stderr: boundedProcessLog(bridgeProcess.readLog()),
|
||||
},
|
||||
});
|
||||
}
|
||||
return snapshotSourceError('process-crash', 'bridge-exited', {
|
||||
pid: bridgeProcess.pid,
|
||||
...(exitCode === undefined ? {} : { exitCode }),
|
||||
});
|
||||
}
|
||||
|
||||
function boundedProcessLog(log: string): string {
|
||||
const bytes = Buffer.from(log);
|
||||
if (bytes.length <= MAX_PROCESS_LOG_BYTES) return log;
|
||||
return bytes.subarray(-MAX_PROCESS_LOG_BYTES).toString('utf8');
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { test } from 'vitest';
|
||||
import {
|
||||
assertSnapshotBridgeEnvelope,
|
||||
bridgeFailureFromEnvelope,
|
||||
createSnapshotBridgeDescribeRequest,
|
||||
encodeSnapshotBridgeFrame,
|
||||
parseSnapshotBridgeEnvelope,
|
||||
SNAPSHOT_SOURCE_PROTOCOL_VERSION,
|
||||
SNAPSHOT_SOURCE_ATTRIBUTE_KEYS,
|
||||
SNAPSHOT_SOURCE_RESPONSE_KEYS,
|
||||
SNAPSHOT_SOURCE_VERSION,
|
||||
SNAPSHOT_SOURCE_WIRE_KEYS,
|
||||
SnapshotBridgeFrameDecoder,
|
||||
} from './protocol.ts';
|
||||
import type { SnapshotSourceLimits } from './types.ts';
|
||||
|
||||
const limits: SnapshotSourceLimits = {
|
||||
maxRequestBytes: 1024,
|
||||
maxResponseBytes: 4096,
|
||||
maxNodes: 20,
|
||||
maxTraversalDepth: 10,
|
||||
maxDurationMs: 1000,
|
||||
};
|
||||
|
||||
const wireVocabulary = JSON.parse(
|
||||
await readFile(path.join(import.meta.dirname, 'fixtures', 'wire-vocabulary.json'), 'utf8'),
|
||||
) as {
|
||||
protocolVersion: number;
|
||||
sourceVersion: string;
|
||||
requestKeys: string[];
|
||||
responseKeys: string[];
|
||||
attributeKeys: string[];
|
||||
};
|
||||
|
||||
test('snapshot bridge frames decode a split frame and reject trailing frames', () => {
|
||||
const first = encodeSnapshotBridgeFrame({ requestId: 'one', value: 1 }, limits);
|
||||
const second = encodeSnapshotBridgeFrame({ requestId: 'two', value: 2 }, limits);
|
||||
const decoder = new SnapshotBridgeFrameDecoder(limits.maxResponseBytes);
|
||||
|
||||
assert.equal(decoder.push(first.subarray(0, 3)), undefined);
|
||||
assert.deepEqual(decoder.push(first.subarray(3)), Buffer.from('{"requestId":"one","value":1}'));
|
||||
assert.deepEqual(decoder.finish(), Buffer.from('{"requestId":"one","value":1}'));
|
||||
assert.throws(() => decoder.push(second), /multiple-frames/);
|
||||
});
|
||||
|
||||
test('snapshot bridge frames reject bounded request and response violations', () => {
|
||||
assert.throws(
|
||||
() => encodeSnapshotBridgeFrame({ payload: 'x'.repeat(2000) }, limits),
|
||||
(error: unknown) =>
|
||||
error instanceof Error &&
|
||||
'failureCode' in error &&
|
||||
(error as { failureCode: string }).failureCode === 'request-limit-exceeded',
|
||||
);
|
||||
|
||||
const decoder = new SnapshotBridgeFrameDecoder(10);
|
||||
const oversized = Buffer.alloc(4);
|
||||
oversized.writeUInt32BE(11, 0);
|
||||
assert.throws(() => decoder.push(oversized), /frame-limit-exceeded/);
|
||||
});
|
||||
|
||||
test('snapshot bridge envelopes pin protocol, source, and request identity', () => {
|
||||
const request = createSnapshotBridgeDescribeRequest({
|
||||
requestId: 'request-1',
|
||||
pid: 123,
|
||||
generation: 'generation-1',
|
||||
maxDepth: 4,
|
||||
maxNodes: 10,
|
||||
maxDurationMs: 900,
|
||||
maxResponseBytes: 4096,
|
||||
});
|
||||
assert.deepEqual(request, {
|
||||
verb: 'describe',
|
||||
requestId: 'request-1',
|
||||
pid: 123,
|
||||
generation: 'generation-1',
|
||||
snapshotTree: true,
|
||||
automationMode: true,
|
||||
maxDepth: 4,
|
||||
maxNodes: 10,
|
||||
maxDurationMs: 900,
|
||||
maxResponseBytes: 4096,
|
||||
});
|
||||
|
||||
const envelope = parseSnapshotBridgeEnvelope(
|
||||
Buffer.from(
|
||||
JSON.stringify({
|
||||
protocolVersion: SNAPSHOT_SOURCE_PROTOCOL_VERSION,
|
||||
sourceVersion: SNAPSHOT_SOURCE_VERSION,
|
||||
requestId: 'request-1',
|
||||
}),
|
||||
),
|
||||
);
|
||||
assert.doesNotThrow(() => assertSnapshotBridgeEnvelope(envelope, 'request-1'));
|
||||
assert.throws(() => assertSnapshotBridgeEnvelope(envelope, 'request-2'), /request-id-mismatch/);
|
||||
});
|
||||
|
||||
test('snapshot bridge failures stay typed at the guest boundary', () => {
|
||||
for (const [guestKind, expectedKind] of [
|
||||
['unsupported', 'unsupported'],
|
||||
['malformed_tree', 'malformed-tree'],
|
||||
['application_not_responding', 'timeout'],
|
||||
['application_unavailable', 'transport-failure'],
|
||||
['bad_request', 'malformed-tree'],
|
||||
['response_limit_exceeded', 'transport-failure'],
|
||||
] as const) {
|
||||
assert.throws(
|
||||
() =>
|
||||
bridgeFailureFromEnvelope({
|
||||
error_kind: guestKind,
|
||||
error_code: 'fixture-code',
|
||||
}),
|
||||
(error: unknown) =>
|
||||
error instanceof Error &&
|
||||
'failureKind' in error &&
|
||||
(error as { failureKind: string }).failureKind === expectedKind,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('wire vocabulary guard keeps TS and Objective-C literals aligned', async () => {
|
||||
const native = await Promise.all(
|
||||
['SnapshotBridge.m', 'SnapshotBridgeRuntime.m', 'SnapshotBridgeRuntime.h'].map((fileName) =>
|
||||
readFile(
|
||||
path.join(import.meta.dirname, '../../../../apple/snapshot-bridge', fileName),
|
||||
'utf8',
|
||||
),
|
||||
),
|
||||
);
|
||||
const nativeSource = native.join('\n');
|
||||
assert.equal(wireVocabulary.protocolVersion, SNAPSHOT_SOURCE_PROTOCOL_VERSION);
|
||||
assert.equal(wireVocabulary.sourceVersion, SNAPSHOT_SOURCE_VERSION);
|
||||
assert.deepEqual(wireVocabulary.requestKeys, SNAPSHOT_SOURCE_WIRE_KEYS);
|
||||
assert.deepEqual(wireVocabulary.responseKeys, SNAPSHOT_SOURCE_RESPONSE_KEYS);
|
||||
assert.deepEqual(wireVocabulary.attributeKeys, SNAPSHOT_SOURCE_ATTRIBUTE_KEYS);
|
||||
assert.match(nativeSource, /kProtocolVersion = 1/);
|
||||
assert.match(nativeSource, /kSourceVersion = @"agent-device-simulator-ax-v1\.5\.3"/);
|
||||
for (const key of [
|
||||
...wireVocabulary.requestKeys,
|
||||
...wireVocabulary.responseKeys,
|
||||
...wireVocabulary.attributeKeys,
|
||||
]) {
|
||||
assert.match(
|
||||
nativeSource,
|
||||
new RegExp(`@"${key.replaceAll(/[.*+?^${}()|[\\]\\\\]/g, String.raw`\$&`)}"`),
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,251 @@
|
||||
import { Buffer } from 'node:buffer';
|
||||
import { snapshotSourceError } from './errors.ts';
|
||||
import type { SnapshotSourceLimits } from './types.ts';
|
||||
|
||||
export const SNAPSHOT_SOURCE_PROTOCOL_VERSION = 1;
|
||||
export const SNAPSHOT_SOURCE_VERSION = 'agent-device-simulator-ax-v1.5.3';
|
||||
const FRAME_HEADER_BYTES = 4;
|
||||
|
||||
export const SNAPSHOT_SOURCE_WIRE_KEYS = Object.freeze([
|
||||
'verb',
|
||||
'requestId',
|
||||
'pid',
|
||||
'generation',
|
||||
'snapshotTree',
|
||||
'automationMode',
|
||||
'maxDepth',
|
||||
'maxNodes',
|
||||
'maxDurationMs',
|
||||
'maxResponseBytes',
|
||||
] as const);
|
||||
|
||||
export const SNAPSHOT_SOURCE_RESPONSE_KEYS = Object.freeze([
|
||||
'protocolVersion',
|
||||
'sourceVersion',
|
||||
'requestId',
|
||||
'generation',
|
||||
'ok',
|
||||
'pid',
|
||||
'tree',
|
||||
'truncated',
|
||||
'automationEnabled',
|
||||
'error_kind',
|
||||
'error_code',
|
||||
'error',
|
||||
] as const);
|
||||
|
||||
export const SNAPSHOT_SOURCE_ATTRIBUTE_KEYS = Object.freeze([
|
||||
'XC_kAXXCAttributeElementType',
|
||||
'XC_kAXXCAttributeElementBaseType',
|
||||
'XC_kAXXCAttributeLabel',
|
||||
'XC_kAXXCAttributeValue',
|
||||
'XC_kAXXCAttributeIdentifier',
|
||||
'XC_kAXXCAttributeFrame',
|
||||
'XC_kAXXCAttributeAutomationType',
|
||||
'XC_kAXXCAttributeChildren',
|
||||
] as const);
|
||||
|
||||
export type SnapshotBridgeEnvelope = Readonly<Record<string, unknown>>;
|
||||
|
||||
export function encodeSnapshotBridgeFrame(
|
||||
value: unknown,
|
||||
limits: Pick<SnapshotSourceLimits, 'maxRequestBytes'>,
|
||||
): Buffer {
|
||||
let body: Buffer;
|
||||
try {
|
||||
body = Buffer.from(JSON.stringify(value), 'utf8');
|
||||
} catch (error) {
|
||||
throw snapshotSourceError('malformed-tree', 'request-not-json', {}, error);
|
||||
}
|
||||
const frameBytes = body.length + FRAME_HEADER_BYTES;
|
||||
if (frameBytes > limits.maxRequestBytes) {
|
||||
throw snapshotSourceError('transport-failure', 'request-limit-exceeded', {
|
||||
frameBytes,
|
||||
maxRequestBytes: limits.maxRequestBytes,
|
||||
});
|
||||
}
|
||||
const header = Buffer.alloc(FRAME_HEADER_BYTES);
|
||||
header.writeUInt32BE(body.length, 0);
|
||||
return Buffer.concat([header, body]);
|
||||
}
|
||||
|
||||
export class SnapshotBridgeFrameDecoder {
|
||||
private readonly header = Buffer.alloc(FRAME_HEADER_BYTES);
|
||||
private headerBytes = 0;
|
||||
private readonly chunks: Buffer[] = [];
|
||||
private payloadBytes = 0;
|
||||
private expectedBodyBytes: number | undefined;
|
||||
private frame: Buffer | undefined;
|
||||
private readonly maxFrameBytes: number;
|
||||
|
||||
constructor(maxFrameBytes: number) {
|
||||
if (!Number.isSafeInteger(maxFrameBytes) || maxFrameBytes <= 0) {
|
||||
throw snapshotSourceError('malformed-tree', 'frame-limit-invalid', { maxFrameBytes });
|
||||
}
|
||||
this.maxFrameBytes = maxFrameBytes;
|
||||
}
|
||||
|
||||
push(chunk: Buffer): Buffer | undefined {
|
||||
if (this.frame) return this.acceptTrailingChunk(chunk);
|
||||
const header = this.readHeader(chunk);
|
||||
if (!header) return undefined;
|
||||
this.appendPayload(chunk, header.offset, header.bodyBytes);
|
||||
if (this.payloadBytes !== header.bodyBytes) return undefined;
|
||||
this.frame = Buffer.concat(this.chunks, header.bodyBytes);
|
||||
return this.frame;
|
||||
}
|
||||
|
||||
private acceptTrailingChunk(chunk: Buffer): Buffer {
|
||||
if (chunk.length > 0) {
|
||||
throw snapshotSourceError('malformed-tree', 'multiple-frames', {
|
||||
trailingBytes: chunk.length,
|
||||
});
|
||||
}
|
||||
return this.frame!;
|
||||
}
|
||||
|
||||
private readHeader(chunk: Buffer): { offset: number; bodyBytes: number } | undefined {
|
||||
if (this.headerBytes === FRAME_HEADER_BYTES) {
|
||||
return { offset: 0, bodyBytes: this.expectedBodyBytes! };
|
||||
}
|
||||
const headerBytes = Math.min(FRAME_HEADER_BYTES - this.headerBytes, chunk.length);
|
||||
chunk.copy(this.header, this.headerBytes, 0, headerBytes);
|
||||
this.headerBytes += headerBytes;
|
||||
if (this.headerBytes < FRAME_HEADER_BYTES) return undefined;
|
||||
const bodyBytes = this.header.readUInt32BE(0);
|
||||
if (bodyBytes === 0 || bodyBytes > this.maxFrameBytes) {
|
||||
throw snapshotSourceError('malformed-tree', 'frame-limit-exceeded', {
|
||||
bodyBytes,
|
||||
maxFrameBytes: this.maxFrameBytes,
|
||||
});
|
||||
}
|
||||
this.expectedBodyBytes = bodyBytes;
|
||||
return { offset: headerBytes, bodyBytes };
|
||||
}
|
||||
|
||||
private appendPayload(chunk: Buffer, offset: number, bodyBytes: number): void {
|
||||
const remainingBytes = bodyBytes - this.payloadBytes;
|
||||
const chunkBytes = chunk.length - offset;
|
||||
if (chunkBytes > remainingBytes) {
|
||||
throw snapshotSourceError('malformed-tree', 'multiple-frames', {
|
||||
trailingBytes: chunkBytes - remainingBytes,
|
||||
});
|
||||
}
|
||||
if (chunkBytes === 0) return;
|
||||
this.chunks.push(chunk.subarray(offset));
|
||||
this.payloadBytes += chunkBytes;
|
||||
}
|
||||
|
||||
finish(): Buffer {
|
||||
if (this.frame) return this.frame;
|
||||
throw snapshotSourceError('transport-failure', 'bridge-frame-incomplete', {
|
||||
headerBytes: this.headerBytes,
|
||||
payloadBytes: this.payloadBytes,
|
||||
expectedBodyBytes: this.expectedBodyBytes,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function parseSnapshotBridgeEnvelope(body: Buffer): SnapshotBridgeEnvelope {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(body.toString('utf8'));
|
||||
} catch (error) {
|
||||
throw snapshotSourceError('malformed-tree', 'response-not-json', {}, error);
|
||||
}
|
||||
if (!isRecord(parsed)) {
|
||||
throw snapshotSourceError('malformed-tree', 'response-not-object');
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
export function createSnapshotBridgeDescribeRequest(
|
||||
input: Readonly<{
|
||||
requestId: string;
|
||||
pid: number;
|
||||
generation: string;
|
||||
maxDepth: number;
|
||||
maxNodes: number;
|
||||
maxDurationMs: number;
|
||||
maxResponseBytes: number;
|
||||
}>,
|
||||
): Readonly<Record<string, unknown>> {
|
||||
return Object.freeze({
|
||||
verb: 'describe',
|
||||
requestId: input.requestId,
|
||||
pid: input.pid,
|
||||
generation: input.generation,
|
||||
snapshotTree: true,
|
||||
automationMode: true,
|
||||
maxDepth: input.maxDepth,
|
||||
maxNodes: input.maxNodes,
|
||||
maxDurationMs: input.maxDurationMs,
|
||||
maxResponseBytes: input.maxResponseBytes,
|
||||
});
|
||||
}
|
||||
|
||||
export function assertSnapshotBridgeEnvelope(
|
||||
envelope: SnapshotBridgeEnvelope,
|
||||
requestId: string,
|
||||
): void {
|
||||
if (envelope.protocolVersion !== SNAPSHOT_SOURCE_PROTOCOL_VERSION) {
|
||||
throw snapshotSourceError('transport-failure', 'protocol-version-mismatch', {
|
||||
expected: SNAPSHOT_SOURCE_PROTOCOL_VERSION,
|
||||
observed: envelope.protocolVersion,
|
||||
});
|
||||
}
|
||||
if (envelope.sourceVersion !== SNAPSHOT_SOURCE_VERSION) {
|
||||
throw snapshotSourceError('transport-failure', 'source-version-mismatch', {
|
||||
expected: SNAPSHOT_SOURCE_VERSION,
|
||||
observed: envelope.sourceVersion,
|
||||
});
|
||||
}
|
||||
if (envelope.requestId !== requestId) {
|
||||
throw snapshotSourceError('transport-failure', 'request-id-mismatch', {
|
||||
expected: requestId,
|
||||
observed: envelope.requestId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function assertSnapshotBridgeTargetIdentity(
|
||||
envelope: SnapshotBridgeEnvelope,
|
||||
expected: Readonly<{ pid: number; generation: string }>,
|
||||
): void {
|
||||
if (typeof envelope.pid !== 'number' || envelope.pid !== expected.pid) {
|
||||
throw snapshotSourceError('stale-target', 'bridge-pid-mismatch', {
|
||||
expectedPid: expected.pid,
|
||||
observedPid: envelope.pid,
|
||||
});
|
||||
}
|
||||
if (envelope.generation !== expected.generation) {
|
||||
throw snapshotSourceError('stale-target', 'bridge-generation-mismatch', {
|
||||
expectedGeneration: expected.generation,
|
||||
observedGeneration: envelope.generation,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function bridgeFailureFromEnvelope(envelope: SnapshotBridgeEnvelope): never {
|
||||
const kind = envelope.error_kind;
|
||||
const code = typeof envelope.error_code === 'string' ? envelope.error_code : 'guest-error';
|
||||
const message = typeof envelope.error === 'string' ? envelope.error : undefined;
|
||||
const details = message ? { guestMessage: message.slice(0, 1024) } : {};
|
||||
if (kind === 'unsupported') throw snapshotSourceError('unsupported', code, details);
|
||||
if (kind === 'malformed_tree') throw snapshotSourceError('malformed-tree', code, details);
|
||||
if (kind === 'application_not_responding') {
|
||||
throw snapshotSourceError('timeout', code, details);
|
||||
}
|
||||
if (kind === 'application_unavailable') {
|
||||
throw snapshotSourceError('transport-failure', code, details);
|
||||
}
|
||||
if (kind === 'bad_request') throw snapshotSourceError('malformed-tree', code, details);
|
||||
if (kind === 'response_limit_exceeded') {
|
||||
throw snapshotSourceError('transport-failure', code, details);
|
||||
}
|
||||
throw snapshotSourceError('transport-failure', code, details);
|
||||
}
|
||||
|
||||
export function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import { asSnapshotSourceError, snapshotSourceError } from './errors.ts';
|
||||
import { remainingSnapshotSourceMs, type SnapshotSourceDeadline } from './deadline.ts';
|
||||
import { bridgeProcessExited } from './process.ts';
|
||||
import {
|
||||
assertSnapshotBridgeEnvelope,
|
||||
assertSnapshotBridgeTargetIdentity,
|
||||
bridgeFailureFromEnvelope,
|
||||
parseSnapshotBridgeEnvelope,
|
||||
SnapshotBridgeFrameDecoder,
|
||||
type SnapshotBridgeEnvelope,
|
||||
} from './protocol.ts';
|
||||
import type {
|
||||
SnapshotSourceHost,
|
||||
SnapshotSourceLimits,
|
||||
SnapshotSourceProcess,
|
||||
SnapshotSourceSocket,
|
||||
} from './types.ts';
|
||||
|
||||
export async function roundTripSnapshotBridge(
|
||||
input: Readonly<{
|
||||
process: SnapshotSourceProcess;
|
||||
socket: SnapshotSourceSocket;
|
||||
frame: Buffer;
|
||||
requestId: string;
|
||||
deadline: SnapshotSourceDeadline;
|
||||
limits: SnapshotSourceLimits;
|
||||
expectedPid: number;
|
||||
expectedGeneration: string;
|
||||
host: SnapshotSourceHost;
|
||||
}>,
|
||||
): Promise<SnapshotBridgeEnvelope> {
|
||||
const decoder = new SnapshotBridgeFrameDecoder(input.limits.maxResponseBytes - 4);
|
||||
const timeoutMs = remainingSnapshotSourceMs(input.deadline, 'bridge-request-deadline');
|
||||
return await new Promise<SnapshotBridgeEnvelope>((resolve, reject) => {
|
||||
let settled = false;
|
||||
let dispatched = false;
|
||||
const timer = setTimeout(() => {
|
||||
finishReject(snapshotSourceError('timeout', 'bridge-request-deadline', { dispatched }));
|
||||
input.socket.destroy();
|
||||
}, timeoutMs);
|
||||
const onAbort = () => {
|
||||
finishReject(snapshotSourceError('cancelled', 'abort-signal', { dispatched }));
|
||||
input.socket.destroy();
|
||||
};
|
||||
const onData = (chunk: unknown) => {
|
||||
try {
|
||||
if (!Buffer.isBuffer(chunk))
|
||||
throw snapshotSourceError('transport-failure', 'bridge-data-invalid');
|
||||
const body = decoder.push(chunk);
|
||||
if (body) {
|
||||
const envelope = parseSnapshotBridgeEnvelope(body);
|
||||
assertSnapshotBridgeEnvelope(envelope, input.requestId);
|
||||
assertSnapshotBridgeTargetIdentity(envelope, {
|
||||
pid: input.expectedPid,
|
||||
generation: input.expectedGeneration,
|
||||
});
|
||||
if (envelope.ok !== true) bridgeFailureFromEnvelope(envelope);
|
||||
if (typeof envelope.truncated !== 'boolean') {
|
||||
throw snapshotSourceError('malformed-tree', 'truncated-invalid');
|
||||
}
|
||||
finishResolve(envelope);
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
finishReject(error);
|
||||
input.socket.destroy();
|
||||
}
|
||||
};
|
||||
const onError = (error: unknown) => finishReject(asSnapshotSourceError(error));
|
||||
const onClose = () => {
|
||||
if (!settled) {
|
||||
if (input.process.isAlive()) {
|
||||
finishReject(snapshotSourceError('transport-failure', 'bridge-connection-closed'));
|
||||
} else {
|
||||
void bridgeProcessExited(input.host, input.process).then(finishReject);
|
||||
}
|
||||
}
|
||||
};
|
||||
input.process.wait.then(
|
||||
() => {
|
||||
if (!settled) void bridgeProcessExited(input.host, input.process).then(finishReject);
|
||||
},
|
||||
(error: unknown) => {
|
||||
if (!settled) finishReject(asSnapshotSourceError(error));
|
||||
},
|
||||
);
|
||||
const finishResolve = (value: SnapshotBridgeEnvelope) => finish(() => resolve(value));
|
||||
const finishReject = (error: unknown) => finish(() => reject(error));
|
||||
const finish = (action: () => void) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
input.deadline.signal?.removeEventListener('abort', onAbort);
|
||||
input.socket.off('data', onData);
|
||||
input.socket.off('error', onError);
|
||||
input.socket.off('close', onClose);
|
||||
action();
|
||||
};
|
||||
|
||||
input.socket.on('data', onData);
|
||||
input.socket.on('error', onError);
|
||||
input.socket.on('close', onClose);
|
||||
input.deadline.signal?.addEventListener('abort', onAbort, { once: true });
|
||||
try {
|
||||
if (input.deadline.signal?.aborted) throw snapshotSourceError('cancelled', 'abort-signal');
|
||||
dispatched = true;
|
||||
input.socket.write(input.frame);
|
||||
} catch (error) {
|
||||
finishReject(asSnapshotSourceError(error));
|
||||
input.socket.destroy();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { test } from 'vitest';
|
||||
import { decodeSnapshotBridgeTree } from './tree.ts';
|
||||
import type { SnapshotSourceLimits } from './types.ts';
|
||||
|
||||
const limits: SnapshotSourceLimits = {
|
||||
maxRequestBytes: 1024,
|
||||
maxResponseBytes: 4096,
|
||||
maxNodes: 20,
|
||||
maxTraversalDepth: 10,
|
||||
maxDurationMs: 1000,
|
||||
};
|
||||
|
||||
const application = 'XC_kAXXCAttributeElementType';
|
||||
const baseType = 'XC_kAXXCAttributeElementBaseType';
|
||||
const frame = 'XC_kAXXCAttributeFrame';
|
||||
const children = 'XC_kAXXCAttributeChildren';
|
||||
const label = 'XC_kAXXCAttributeLabel';
|
||||
const automationType = 'XC_kAXXCAttributeAutomationType';
|
||||
|
||||
test('the bridge tree becomes one depth-first raw snapshot with viewport evidence', () => {
|
||||
const result = decodeSnapshotBridgeTree(
|
||||
{
|
||||
[application]: 'Application',
|
||||
[frame]: { X: 0, Y: 0, Width: 390, Height: 844 },
|
||||
[children]: [
|
||||
{
|
||||
[application]: 'Window',
|
||||
[baseType]: 'UIWindow',
|
||||
[frame]: { X: 0, Y: 0, Width: 390, Height: 844 },
|
||||
[children]: [
|
||||
{
|
||||
[automationType]: 9,
|
||||
[label]: 'Continue',
|
||||
[frame]: { X: 20, Y: 700, Width: 120, Height: 48 },
|
||||
[children]: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{ truncated: false },
|
||||
limits,
|
||||
);
|
||||
|
||||
assert.deepEqual(result.nodes, [
|
||||
{
|
||||
index: 0,
|
||||
type: 'Application',
|
||||
role: 'Application',
|
||||
rect: { x: 0, y: 0, width: 390, height: 844 },
|
||||
depth: 0,
|
||||
},
|
||||
{
|
||||
index: 1,
|
||||
parentIndex: 0,
|
||||
type: 'Window',
|
||||
role: 'Window',
|
||||
subrole: 'UIWindow',
|
||||
rect: { x: 0, y: 0, width: 390, height: 844 },
|
||||
depth: 1,
|
||||
},
|
||||
{
|
||||
index: 2,
|
||||
parentIndex: 1,
|
||||
type: 'Button',
|
||||
label: 'Continue',
|
||||
rect: { x: 20, y: 700, width: 120, height: 48 },
|
||||
depth: 2,
|
||||
},
|
||||
]);
|
||||
assert.deepEqual(result.viewport, {
|
||||
kind: 'reported',
|
||||
rect: { x: 0, y: 0, width: 390, height: 844 },
|
||||
});
|
||||
assert.equal(result.maxTraversalDepth, 2);
|
||||
});
|
||||
|
||||
test('the bridge tree rejects unknown fields, invalid frames, and bounded overflows', () => {
|
||||
assert.throws(
|
||||
() => decodeSnapshotBridgeTree({ [children]: [], unknown: true }, { truncated: false }, limits),
|
||||
/node-contains-unknown-field/,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
decodeSnapshotBridgeTree(
|
||||
{ [frame]: { X: 0, Y: 0, Width: -1, Height: 1 }, [children]: [] },
|
||||
{ truncated: false },
|
||||
limits,
|
||||
),
|
||||
/frame-invalid/,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
decodeSnapshotBridgeTree(
|
||||
{
|
||||
[children]: Array.from({ length: limits.maxNodes + 1 }, () => ({ [children]: [] })),
|
||||
},
|
||||
{ truncated: false },
|
||||
limits,
|
||||
),
|
||||
/node-limit-exceeded/,
|
||||
);
|
||||
});
|
||||
|
||||
test('the bridge tree requires a typed truncation flag', () => {
|
||||
assert.throws(
|
||||
() => decodeSnapshotBridgeTree({ [children]: [] }, { truncated: 'yes' }, limits),
|
||||
/truncated-invalid/,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,266 @@
|
||||
import { isPositiveFiniteRect } from '@agent-device/kernel/rect';
|
||||
import type { RawSnapshotNode, Rect } from '@agent-device/kernel/snapshot';
|
||||
import type { IosViewportEvidence } from '@agent-device/contracts/ios-snapshot';
|
||||
import { snapshotSourceError } from './errors.ts';
|
||||
import type { SnapshotSourceDecodedTree, SnapshotSourceLimits } from './types.ts';
|
||||
import { isRecord } from './protocol.ts';
|
||||
|
||||
// fallow-ignore-next-line code-duplication
|
||||
const ATTRIBUTE = Object.freeze({
|
||||
elementType: 'XC_kAXXCAttributeElementType',
|
||||
elementBaseType: 'XC_kAXXCAttributeElementBaseType',
|
||||
label: 'XC_kAXXCAttributeLabel',
|
||||
value: 'XC_kAXXCAttributeValue',
|
||||
identifier: 'XC_kAXXCAttributeIdentifier',
|
||||
frame: 'XC_kAXXCAttributeFrame',
|
||||
automationType: 'XC_kAXXCAttributeAutomationType',
|
||||
children: 'XC_kAXXCAttributeChildren',
|
||||
});
|
||||
|
||||
const ELEMENT_TYPE_NAMES: readonly string[] = [
|
||||
'Other',
|
||||
'Other',
|
||||
'Application',
|
||||
'Group',
|
||||
'Window',
|
||||
'Sheet',
|
||||
'Drawer',
|
||||
'Alert',
|
||||
'Dialog',
|
||||
'Button',
|
||||
'RadioButton',
|
||||
'RadioGroup',
|
||||
'CheckBox',
|
||||
'DisclosureTriangle',
|
||||
'PopUpButton',
|
||||
'ComboBox',
|
||||
'MenuButton',
|
||||
'ToolbarButton',
|
||||
'Popover',
|
||||
'Keyboard',
|
||||
'Key',
|
||||
'NavigationBar',
|
||||
'TabBar',
|
||||
'TabGroup',
|
||||
'Toolbar',
|
||||
'StatusBar',
|
||||
'Table',
|
||||
'TableRow',
|
||||
'TableColumn',
|
||||
'Outline',
|
||||
'OutlineRow',
|
||||
'Browser',
|
||||
'CollectionView',
|
||||
'Slider',
|
||||
'PageIndicator',
|
||||
'ProgressIndicator',
|
||||
'ActivityIndicator',
|
||||
'SegmentedControl',
|
||||
'Picker',
|
||||
'PickerWheel',
|
||||
'Switch',
|
||||
'Toggle',
|
||||
'Link',
|
||||
'Image',
|
||||
'Icon',
|
||||
'SearchField',
|
||||
'ScrollView',
|
||||
'ScrollBar',
|
||||
'StaticText',
|
||||
'TextField',
|
||||
'SecureTextField',
|
||||
'DatePicker',
|
||||
'TextView',
|
||||
'Menu',
|
||||
'MenuItem',
|
||||
'MenuBar',
|
||||
'MenuBarItem',
|
||||
'Map',
|
||||
'WebView',
|
||||
'IncrementArrow',
|
||||
'DecrementArrow',
|
||||
'Timeline',
|
||||
'RatingIndicator',
|
||||
'ValueIndicator',
|
||||
'SplitGroup',
|
||||
'Splitter',
|
||||
'RelevanceIndicator',
|
||||
'ColorWell',
|
||||
'HelpTag',
|
||||
'Matte',
|
||||
'DockItem',
|
||||
'Ruler',
|
||||
'RulerMarker',
|
||||
'Grid',
|
||||
'LevelIndicator',
|
||||
'Cell',
|
||||
'LayoutArea',
|
||||
'LayoutItem',
|
||||
'Handle',
|
||||
'Stepper',
|
||||
'Tab',
|
||||
'TouchBar',
|
||||
'StatusItem',
|
||||
];
|
||||
|
||||
const CLASS_PROMOTED_TYPES: Readonly<Record<string, string>> = {
|
||||
UIApplication: 'Application',
|
||||
UIWindow: 'Window',
|
||||
};
|
||||
|
||||
const NODE_KEYS = new Set<string>(Object.values(ATTRIBUTE));
|
||||
|
||||
export function decodeSnapshotBridgeTree(
|
||||
tree: unknown,
|
||||
envelope: Readonly<{ truncated: unknown }>,
|
||||
limits: SnapshotSourceLimits,
|
||||
): SnapshotSourceDecodedTree {
|
||||
const roots = Array.isArray(tree) ? tree : [tree];
|
||||
if (roots.length === 0 || roots.some((root) => !isRecord(root))) {
|
||||
throw snapshotSourceError('malformed-tree', 'guest-tree-root-invalid');
|
||||
}
|
||||
const nodes: RawSnapshotNode[] = [];
|
||||
let maxTraversalDepth = 0;
|
||||
for (const root of roots) {
|
||||
visitNode(root, undefined, 0);
|
||||
}
|
||||
if (nodes.length > limits.maxNodes) {
|
||||
throw snapshotSourceError('malformed-tree', 'node-limit-exceeded', {
|
||||
nodeCount: nodes.length,
|
||||
maxNodes: limits.maxNodes,
|
||||
});
|
||||
}
|
||||
if (maxTraversalDepth > limits.maxTraversalDepth) {
|
||||
throw snapshotSourceError('malformed-tree', 'traversal-depth-exceeded', {
|
||||
maxTraversalDepth,
|
||||
maxAllowedDepth: limits.maxTraversalDepth,
|
||||
});
|
||||
}
|
||||
if (typeof envelope.truncated !== 'boolean') {
|
||||
throw snapshotSourceError('malformed-tree', 'truncated-invalid');
|
||||
}
|
||||
return {
|
||||
nodes,
|
||||
maxTraversalDepth,
|
||||
viewport: viewportFromRoot(
|
||||
nodes.find((node) => node.type === 'Application' || node.type === 'Window'),
|
||||
),
|
||||
};
|
||||
|
||||
function visitNode(
|
||||
value: Record<string, unknown>,
|
||||
parentIndex: number | undefined,
|
||||
depth: number,
|
||||
): void {
|
||||
if (nodes.length >= limits.maxNodes) {
|
||||
throw snapshotSourceError('malformed-tree', 'node-limit-exceeded', {
|
||||
maxNodes: limits.maxNodes,
|
||||
});
|
||||
}
|
||||
for (const key of Object.keys(value)) {
|
||||
if (!NODE_KEYS.has(key)) {
|
||||
throw snapshotSourceError('malformed-tree', 'node-contains-unknown-field', { key });
|
||||
}
|
||||
}
|
||||
const children = value[ATTRIBUTE.children];
|
||||
if (!Array.isArray(children)) {
|
||||
throw snapshotSourceError('malformed-tree', 'children-invalid');
|
||||
}
|
||||
const index = nodes.length;
|
||||
const node = nodeFacts(value, index, parentIndex, depth);
|
||||
nodes.push(node);
|
||||
maxTraversalDepth = Math.max(maxTraversalDepth, depth);
|
||||
for (const child of children) {
|
||||
if (!isRecord(child)) throw snapshotSourceError('malformed-tree', 'child-invalid');
|
||||
visitNode(child, index, depth + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
function nodeFacts(
|
||||
value: Record<string, unknown>,
|
||||
index: number,
|
||||
parentIndex: number | undefined,
|
||||
depth: number,
|
||||
): RawSnapshotNode {
|
||||
const elementClass = optionalString(value[ATTRIBUTE.elementType]);
|
||||
const baseClass = optionalString(value[ATTRIBUTE.elementBaseType]);
|
||||
const automationType = optionalInteger(value[ATTRIBUTE.automationType]);
|
||||
const frame = frameFromGuest(value[ATTRIBUTE.frame]);
|
||||
return {
|
||||
index,
|
||||
...(parentIndex === undefined ? {} : { parentIndex }),
|
||||
...(elementTypeName(elementClass, automationType)
|
||||
? { type: elementTypeName(elementClass, automationType) }
|
||||
: {}),
|
||||
...(elementClass ? { role: elementClass } : {}),
|
||||
...(baseClass && baseClass !== elementClass ? { subrole: baseClass } : {}),
|
||||
...(optionalString(value[ATTRIBUTE.label])
|
||||
? { label: optionalString(value[ATTRIBUTE.label]) }
|
||||
: {}),
|
||||
...(optionalScalar(value[ATTRIBUTE.value])
|
||||
? { value: optionalScalar(value[ATTRIBUTE.value]) }
|
||||
: {}),
|
||||
...(optionalString(value[ATTRIBUTE.identifier])
|
||||
? { identifier: optionalString(value[ATTRIBUTE.identifier]) }
|
||||
: {}),
|
||||
...(frame ? { rect: frame } : {}),
|
||||
depth,
|
||||
};
|
||||
}
|
||||
|
||||
function elementTypeName(
|
||||
elementClass: string | undefined,
|
||||
automationType: number | undefined,
|
||||
): string | undefined {
|
||||
if (elementClass !== undefined && CLASS_PROMOTED_TYPES[elementClass]) {
|
||||
return CLASS_PROMOTED_TYPES[elementClass];
|
||||
}
|
||||
if (elementClass !== undefined && ELEMENT_TYPE_NAMES.includes(elementClass)) {
|
||||
return elementClass;
|
||||
}
|
||||
if (automationType === undefined) return undefined;
|
||||
return ELEMENT_TYPE_NAMES[automationType] ?? 'Other';
|
||||
}
|
||||
|
||||
function frameFromGuest(value: unknown): Rect | undefined {
|
||||
if (value === undefined) return undefined;
|
||||
if (!isRecord(value)) throw snapshotSourceError('malformed-tree', 'frame-invalid');
|
||||
const numbers = ['X', 'Y', 'Width', 'Height'].map((key) => value[key]);
|
||||
if (!numbers.every((entry) => typeof entry === 'number' && Number.isFinite(entry))) {
|
||||
throw snapshotSourceError('malformed-tree', 'frame-invalid');
|
||||
}
|
||||
const [x, y, width, height] = numbers as [number, number, number, number];
|
||||
if (width < 0 || height < 0) throw snapshotSourceError('malformed-tree', 'frame-invalid');
|
||||
return { x, y, width, height };
|
||||
}
|
||||
|
||||
function viewportFromRoot(root: RawSnapshotNode | undefined): IosViewportEvidence {
|
||||
if (!root || (root.type !== 'Application' && root.type !== 'Window')) {
|
||||
return { kind: 'missing', reason: 'not-provided' };
|
||||
}
|
||||
if (isPositiveFiniteRect(root.rect)) return { kind: 'reported', rect: root.rect };
|
||||
return { kind: 'missing', reason: root.rect ? 'invalid' : 'not-provided' };
|
||||
}
|
||||
|
||||
// fallow-ignore-next-line code-duplication
|
||||
function optionalString(value: unknown): string | undefined {
|
||||
return typeof value === 'string' && value.length > 0 ? value : undefined;
|
||||
}
|
||||
|
||||
function optionalScalar(value: unknown): string | undefined {
|
||||
if (typeof value === 'string') return value.length > 0 ? value : undefined;
|
||||
if (typeof value === 'number' || typeof value === 'boolean') return String(value);
|
||||
if (value !== undefined && value !== null) {
|
||||
throw snapshotSourceError('malformed-tree', 'scalar-invalid');
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function optionalInteger(value: unknown): number | undefined {
|
||||
if (value === undefined || value === null) return undefined;
|
||||
if (!Number.isSafeInteger(value))
|
||||
throw snapshotSourceError('malformed-tree', 'automation-type-invalid');
|
||||
return value as number;
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import type { ExecOptions, ExecResult } from '@agent-device/host-kit/command';
|
||||
import type {
|
||||
CaptureHint,
|
||||
IosSnapshotAcquisition,
|
||||
IosViewportEvidence,
|
||||
} from '@agent-device/contracts/ios-snapshot';
|
||||
import type { RawSnapshotNode } from '@agent-device/kernel/snapshot';
|
||||
import type { SnapshotSourceDeadline } from './deadline.ts';
|
||||
|
||||
export type SnapshotSourceLimits = Readonly<{
|
||||
maxRequestBytes: number;
|
||||
maxResponseBytes: number;
|
||||
maxNodes: number;
|
||||
maxTraversalDepth: number;
|
||||
maxDurationMs: number;
|
||||
}>;
|
||||
|
||||
export type SnapshotSourceTarget = Readonly<{
|
||||
udid: string;
|
||||
runtime: string;
|
||||
pid: number;
|
||||
generation: string;
|
||||
targetId?: string;
|
||||
}>;
|
||||
|
||||
export type SnapshotSourceRequest = Readonly<{
|
||||
target: SnapshotSourceTarget;
|
||||
hint: CaptureHint;
|
||||
limits?: Partial<SnapshotSourceLimits>;
|
||||
signal?: AbortSignal;
|
||||
}>;
|
||||
|
||||
export type SnapshotSourceSuccess = Readonly<{
|
||||
stage: 'acquired';
|
||||
acquisition: IosSnapshotAcquisition;
|
||||
}>;
|
||||
|
||||
export type SnapshotSourceFailureKind =
|
||||
| 'unsupported'
|
||||
| 'malformed-tree'
|
||||
| 'stale-target'
|
||||
| 'timeout'
|
||||
| 'cancelled'
|
||||
| 'process-crash'
|
||||
| 'transport-failure';
|
||||
|
||||
export type SnapshotSourceFailure = Readonly<{
|
||||
kind: SnapshotSourceFailureKind;
|
||||
code: string;
|
||||
details?: Readonly<Record<string, unknown>>;
|
||||
}>;
|
||||
|
||||
export type SnapshotSourceOutcome =
|
||||
| SnapshotSourceSuccess
|
||||
| Readonly<{
|
||||
stage: 'failed';
|
||||
failure: SnapshotSourceFailure;
|
||||
}>;
|
||||
|
||||
export type SnapshotSourceProcess = Readonly<{
|
||||
pid: number;
|
||||
wait: Promise<ExecResult>;
|
||||
isAlive(): boolean;
|
||||
signal(signal: NodeJS.Signals): void;
|
||||
readLog(): string;
|
||||
}>;
|
||||
|
||||
export type SnapshotSourceSocket = Readonly<{
|
||||
destroyed: boolean;
|
||||
on(event: string, listener: (...args: unknown[]) => void): void;
|
||||
once(event: string, listener: (...args: unknown[]) => void): void;
|
||||
off(event: string, listener: (...args: unknown[]) => void): void;
|
||||
write(data: Buffer): boolean;
|
||||
destroy(error?: Error): void;
|
||||
}>;
|
||||
|
||||
export type SnapshotSourceHost = Readonly<{
|
||||
projectRoot(): string;
|
||||
homeDirectory(): string;
|
||||
run(command: string, args: string[], options?: ExecOptions): Promise<ExecResult>;
|
||||
start(
|
||||
udid: string,
|
||||
bridgePath: string,
|
||||
socketPath: string,
|
||||
options?: { signal?: AbortSignal },
|
||||
): SnapshotSourceProcess;
|
||||
connect(
|
||||
socketPath: string,
|
||||
options: { signal?: AbortSignal; timeoutMs: number },
|
||||
): Promise<SnapshotSourceSocket>;
|
||||
readText(path: string): Promise<string>;
|
||||
readBinary(path: string): Promise<Buffer>;
|
||||
writeText(path: string, contents: string): Promise<void>;
|
||||
ensureDirectory(path: string): Promise<void>;
|
||||
chmod(path: string, mode: number): Promise<void>;
|
||||
exists(path: string): boolean;
|
||||
rename(sourcePath: string, destinationPath: string): Promise<void>;
|
||||
remove(path: string): Promise<void>;
|
||||
acquireLock(
|
||||
path: string,
|
||||
options: { deadline: SnapshotSourceDeadline },
|
||||
): Promise<() => Promise<void>>;
|
||||
emitDiagnostic(event: {
|
||||
level?: 'debug' | 'info' | 'warn' | 'error';
|
||||
phase: string;
|
||||
durationMs?: number;
|
||||
data?: Record<string, unknown>;
|
||||
}): void;
|
||||
withDiagnosticTimer<T>(
|
||||
phase: string,
|
||||
action: () => Promise<T> | T,
|
||||
data?: Record<string, unknown>,
|
||||
): Promise<T>;
|
||||
processId(): number;
|
||||
readTargetProcessStartTime(
|
||||
pid: number,
|
||||
options: { signal?: AbortSignal; timeoutMs: number },
|
||||
): Promise<string | null>;
|
||||
}>;
|
||||
|
||||
export type SnapshotSourceBridgeBinary = Readonly<{
|
||||
path: string;
|
||||
sourceHash: string;
|
||||
cacheKey: string;
|
||||
protocolVersion: number;
|
||||
sourceVersion: string;
|
||||
}>;
|
||||
|
||||
export type SnapshotSourceDecodedTree = Readonly<{
|
||||
nodes: readonly RawSnapshotNode[];
|
||||
viewport: IosViewportEvidence;
|
||||
maxTraversalDepth: number;
|
||||
}>;
|
||||
@@ -5,6 +5,9 @@
|
||||
{ "path": "dist/src/index.d.ts", "size": 102 },
|
||||
{ "path": "dist/apple/runner/RunnerTests.swift", "size": 503 },
|
||||
{ "path": "dist/apple/snapshot-presentation/Package.swift", "size": 113 },
|
||||
{ "path": "apple/snapshot-bridge/SnapshotBridge.m", "size": 0 },
|
||||
{ "path": "apple/snapshot-bridge/SnapshotBridgeRuntime.m", "size": 0 },
|
||||
{ "path": "apple/snapshot-bridge/SnapshotBridgeRuntime.h", "size": 0 },
|
||||
{ "path": "apple/macos-helper/Sources/main.swift", "size": 211 },
|
||||
{ "path": "android/snapshot-helper/dist/helper.apk", "size": 307 },
|
||||
{ "path": "android/snapshot-helper/dist/helper.manifest.json", "size": 99 },
|
||||
|
||||
@@ -23,6 +23,9 @@ test('classifies every shipped entry into one named component', () => {
|
||||
['dist/src/index.d.ts', 'js'],
|
||||
['dist/apple/runner/RunnerTests.swift', 'apple-runner'],
|
||||
['dist/apple/snapshot-presentation/Package.swift', 'apple-snapshot-presentation'],
|
||||
['apple/snapshot-bridge/SnapshotBridge.m', 'apple-snapshot-bridge'],
|
||||
['apple/snapshot-bridge/SnapshotBridgeRuntime.m', 'apple-snapshot-bridge'],
|
||||
['apple/snapshot-bridge/SnapshotBridgeRuntime.h', 'apple-snapshot-bridge'],
|
||||
['apple/macos-helper/Sources/main.swift', 'macos-helper'],
|
||||
['android/snapshot-helper/dist/helper.apk', 'android-helpers'],
|
||||
['android/snapshot-helper/dist/helper.manifest.json', 'android-helpers'],
|
||||
@@ -50,6 +53,21 @@ test('publish package requires both Android helpers and excludes benchmark scrip
|
||||
),
|
||||
/android\/ime-helper/,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
assertPublishPackageContents(
|
||||
fixturePack.files.filter(
|
||||
(entry) => entry.path !== 'apple/snapshot-bridge/SnapshotBridgeRuntime.m',
|
||||
),
|
||||
{ requireSnapshotBridge: true },
|
||||
),
|
||||
/SnapshotBridgeRuntime\.m/,
|
||||
);
|
||||
assert.doesNotThrow(() =>
|
||||
assertPublishPackageContents(
|
||||
fixturePack.files.filter((entry) => !entry.path.startsWith('apple/snapshot-bridge/')),
|
||||
),
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
assertPublishPackageContents([
|
||||
@@ -73,6 +91,7 @@ test('component bytes sum exactly to npm pack unpackedSize', () => {
|
||||
js: 503,
|
||||
'apple-runner': 503,
|
||||
'apple-snapshot-presentation': 113,
|
||||
'apple-snapshot-bridge': 0,
|
||||
'macos-helper': 211,
|
||||
'android-helpers': 812,
|
||||
other: 177,
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
auditDependencyClosure,
|
||||
type PackedManifest as PackedDependencies,
|
||||
} from './lib/shipped-imports.ts';
|
||||
import { assertInstalledSnapshotBridge } from './size-report-install.mjs';
|
||||
|
||||
type PackedManifest = PackedDependencies & {
|
||||
exports: Record<string, unknown>;
|
||||
@@ -34,6 +35,9 @@ type PackedManifest = PackedDependencies & {
|
||||
|
||||
const repoRoot = path.resolve(import.meta.dirname, '..');
|
||||
const packDestinationFlag = '--pack-destination';
|
||||
const verifySnapshotBridgePreparation = process.argv.includes(
|
||||
'--verify-snapshot-bridge-preparation',
|
||||
);
|
||||
const suppliedPackDestination = process.argv
|
||||
.slice(2)
|
||||
.find((arg, index, args) => (args[index - 1] === packDestinationFlag ? arg : undefined));
|
||||
@@ -177,6 +181,25 @@ try {
|
||||
const tarball = packTarball();
|
||||
lintTarball(tarball);
|
||||
const installedRoot = installIntoCleanConsumer(tarball);
|
||||
assertInstalledSnapshotBridge(installedRoot);
|
||||
if (verifySnapshotBridgePreparation) {
|
||||
if (process.platform !== 'darwin') {
|
||||
throw new Error('--verify-snapshot-bridge-preparation requires macOS and Xcode.');
|
||||
}
|
||||
run(
|
||||
'pnpm',
|
||||
[
|
||||
'--filter',
|
||||
'@agent-device/platform-apple',
|
||||
'run',
|
||||
'verify-installed-snapshot-bridge',
|
||||
installedRoot,
|
||||
path.join(workDir, 'snapshot-bridge-cache'),
|
||||
],
|
||||
repoRoot,
|
||||
);
|
||||
step('Prepared the Simulator snapshot bridge from the clean-installed package.');
|
||||
}
|
||||
const manifest = JSON.parse(
|
||||
fs.readFileSync(path.join(installedRoot, 'package.json'), 'utf8'),
|
||||
) as PackedManifest;
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdir, writeFile } from 'node:fs/promises';
|
||||
import { mkdir, rm, writeFile } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { test } from 'vitest';
|
||||
import { mkdtempForTest } from '../../src/__tests__/test-utils/tmp-dir.ts';
|
||||
import { measureDirectory } from '../size-report-install.mjs';
|
||||
import { assertInstalledSnapshotBridge, measureDirectory } from '../size-report-install.mjs';
|
||||
|
||||
test('measures the clean-installed package tree without counting the consumer', async () => {
|
||||
const root = await mkdtempForTest('agent-device-size-tree-');
|
||||
@@ -12,3 +12,20 @@ test('measures the clean-installed package tree without counting the consumer',
|
||||
await writeFile(join(root, 'nested', 'data.json'), '{}');
|
||||
assert.deepEqual(measureDirectory(root), { packageBytes: 6, files: 2 });
|
||||
});
|
||||
|
||||
test('clean-installed snapshot bridge validates all native assets when present', async () => {
|
||||
const root = await mkdtempForTest('agent-device-size-bridge-');
|
||||
const bridge = join(root, 'apple', 'snapshot-bridge');
|
||||
try {
|
||||
assert.doesNotThrow(() => assertInstalledSnapshotBridge(root));
|
||||
await mkdir(bridge, { recursive: true });
|
||||
await writeFile(join(bridge, 'SnapshotBridge.m'), 'native source');
|
||||
await writeFile(join(bridge, 'SnapshotBridgeRuntime.m'), 'native runtime');
|
||||
await writeFile(join(bridge, 'SnapshotBridgeRuntime.h'), 'native header');
|
||||
assert.doesNotThrow(() => assertInstalledSnapshotBridge(root));
|
||||
await rm(join(bridge, 'SnapshotBridgeRuntime.h'));
|
||||
assert.throws(() => assertInstalledSnapshotBridge(root), /SnapshotBridgeRuntime\.h/);
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -552,6 +552,7 @@ test('the real tree parses, declares, and passes R11', () => {
|
||||
'@agent-device/platform-apple/runner/test-host',
|
||||
'@agent-device/platform-apple/simctl',
|
||||
'@agent-device/platform-apple/simulator',
|
||||
'@agent-device/platform-apple/snapshot-source',
|
||||
'@agent-device/platform-apple/tool-provider',
|
||||
]);
|
||||
assert.deepEqual([...platformApplePackage.workspaceDependencies].sort(), [
|
||||
|
||||
@@ -40,6 +40,7 @@ function declarations(): PlatformPackageDeclaration[] {
|
||||
'@agent-device/platform-apple/runner/operations',
|
||||
'@agent-device/platform-apple/runner-owner',
|
||||
'@agent-device/platform-apple/simctl',
|
||||
'@agent-device/platform-apple/snapshot-source',
|
||||
'@agent-device/platform-apple/simulator',
|
||||
'@agent-device/platform-apple/tool-provider',
|
||||
]
|
||||
@@ -315,6 +316,7 @@ test('the Apple domain facades preserve synchronous helpers without widening the
|
||||
'@agent-device/platform-apple/physical-device',
|
||||
'@agent-device/platform-apple/runner-owner',
|
||||
'@agent-device/platform-apple/simctl',
|
||||
'@agent-device/platform-apple/snapshot-source',
|
||||
'@agent-device/platform-apple/simulator',
|
||||
'@agent-device/platform-apple/tool-provider',
|
||||
]) {
|
||||
|
||||
@@ -87,6 +87,7 @@ const MECHANICS_FACET_SUBPATHS: Readonly<Partial<Record<PlatformFamily, readonly
|
||||
'@agent-device/platform-apple/physical-device',
|
||||
'@agent-device/platform-apple/runner-owner',
|
||||
'@agent-device/platform-apple/runner/operations',
|
||||
'@agent-device/platform-apple/snapshot-source',
|
||||
'@agent-device/platform-apple/simctl',
|
||||
'@agent-device/platform-apple/simulator',
|
||||
'@agent-device/platform-apple/tool-provider',
|
||||
|
||||
@@ -2,6 +2,7 @@ import { execFileSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { assertSnapshotBridgeAssets, SNAPSHOT_BRIDGE_ASSET_PATHS } from './size-report-package.mjs';
|
||||
|
||||
export function measureCleanInstalledPackage(tarballPath, packageName) {
|
||||
const workDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-size-install-'));
|
||||
@@ -33,12 +34,22 @@ export function measureCleanInstalledPackage(tarballPath, packageName) {
|
||||
if (!fs.existsSync(packageDir)) {
|
||||
throw new Error(`Clean install did not create node_modules/${packageName}.`);
|
||||
}
|
||||
assertInstalledSnapshotBridge(packageDir);
|
||||
return measureDirectory(packageDir);
|
||||
} finally {
|
||||
fs.rmSync(workDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
export function assertInstalledSnapshotBridge(packageDir) {
|
||||
const bridgeRoot = path.join(packageDir, 'apple', 'snapshot-bridge');
|
||||
if (!fs.existsSync(bridgeRoot)) return;
|
||||
const present = SNAPSHOT_BRIDGE_ASSET_PATHS.filter((assetPath) =>
|
||||
fs.existsSync(path.join(packageDir, assetPath)),
|
||||
);
|
||||
assertSnapshotBridgeAssets(present, 'Clean-installed snapshot bridge');
|
||||
}
|
||||
|
||||
export function measureDirectory(root) {
|
||||
const entries = fs.readdirSync(root, { withFileTypes: true });
|
||||
return entries.reduce(
|
||||
|
||||
@@ -8,6 +8,20 @@ import {
|
||||
formatSignedBytes,
|
||||
} from './size-report-format.mjs';
|
||||
|
||||
export const SNAPSHOT_BRIDGE_ASSET_PATHS = Object.freeze([
|
||||
'apple/snapshot-bridge/SnapshotBridge.m',
|
||||
'apple/snapshot-bridge/SnapshotBridgeRuntime.m',
|
||||
'apple/snapshot-bridge/SnapshotBridgeRuntime.h',
|
||||
]);
|
||||
|
||||
export function assertSnapshotBridgeAssets(presentPaths, context) {
|
||||
const present = new Set(presentPaths);
|
||||
const missing = SNAPSHOT_BRIDGE_ASSET_PATHS.filter((assetPath) => !present.has(assetPath));
|
||||
if (missing.length > 0) {
|
||||
throw new Error(`${context} is missing: ${missing.join(', ')}`);
|
||||
}
|
||||
}
|
||||
|
||||
const PACKAGE_COMPONENTS = [
|
||||
{
|
||||
id: 'js',
|
||||
@@ -27,6 +41,12 @@ const PACKAGE_COMPONENTS = [
|
||||
entryPath === 'dist/apple/snapshot-presentation' ||
|
||||
entryPath.startsWith('dist/apple/snapshot-presentation/'),
|
||||
},
|
||||
{
|
||||
id: 'apple-snapshot-bridge',
|
||||
label: 'Apple Simulator snapshot bridge source',
|
||||
matches: (entryPath) =>
|
||||
entryPath === 'apple/snapshot-bridge' || entryPath.startsWith('apple/snapshot-bridge/'),
|
||||
},
|
||||
{
|
||||
id: 'macos-helper',
|
||||
label: 'macOS helper source',
|
||||
@@ -52,7 +72,9 @@ export function collectNpmPack(root) {
|
||||
);
|
||||
const pack = parseNpmPackOutput(stdout);
|
||||
const entries = normalizeNpmPackEntries(pack);
|
||||
assertPublishPackageContents(entries);
|
||||
assertPublishPackageContents(entries, {
|
||||
requireSnapshotBridge: fs.existsSync(path.join(root, 'apple', 'snapshot-bridge')),
|
||||
});
|
||||
return {
|
||||
filename: pack.filename,
|
||||
tarballPath: path.join(cachePath, pack.filename),
|
||||
@@ -64,7 +86,7 @@ export function collectNpmPack(root) {
|
||||
};
|
||||
}
|
||||
|
||||
export function assertPublishPackageContents(entries) {
|
||||
export function assertPublishPackageContents(entries, options = {}) {
|
||||
const paths = entries.map((entry) => entry.path);
|
||||
const requiredAssets = [
|
||||
{ directory: 'android/snapshot-helper/dist/', suffix: '.apk' },
|
||||
@@ -72,16 +94,26 @@ export function assertPublishPackageContents(entries) {
|
||||
{ directory: 'android/ime-helper/dist/', suffix: '.apk' },
|
||||
{ directory: 'android/ime-helper/dist/', suffix: '.manifest.json' },
|
||||
];
|
||||
const missingAssets = requiredAssets.filter(
|
||||
(asset) =>
|
||||
!paths.some(
|
||||
(entryPath) => entryPath.startsWith(asset.directory) && entryPath.endsWith(asset.suffix),
|
||||
),
|
||||
if (
|
||||
options.requireSnapshotBridge ??
|
||||
paths.some((entryPath) => entryPath.startsWith('apple/snapshot-bridge/'))
|
||||
) {
|
||||
assertSnapshotBridgeAssets(
|
||||
paths.filter((entryPath) => SNAPSHOT_BRIDGE_ASSET_PATHS.includes(entryPath)),
|
||||
'npm pack snapshot bridge',
|
||||
);
|
||||
}
|
||||
const missingAssets = requiredAssets.filter((asset) =>
|
||||
asset.path
|
||||
? !paths.includes(asset.path)
|
||||
: !paths.some(
|
||||
(entryPath) => entryPath.startsWith(asset.directory) && entryPath.endsWith(asset.suffix),
|
||||
),
|
||||
);
|
||||
if (missingAssets.length > 0) {
|
||||
throw new Error(
|
||||
`npm pack is missing publish assets: ${missingAssets
|
||||
.map((asset) => `${asset.directory}*${asset.suffix}`)
|
||||
.map((asset) => asset.path ?? `${asset.directory}*${asset.suffix}`)
|
||||
.join(', ')}`,
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user