@fluentui-react-native/desktop-driver 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.cache/tsconfig.tsbuildinfo +1 -0
- package/AGENTS.md +68 -0
- package/CHANGELOG.md +16 -0
- package/PLAN.md +1394 -0
- package/README.md +223 -0
- package/SPEC.md +105 -0
- package/config/cli.cjs +8 -0
- package/jest.config.cjs +9 -0
- package/lib/agent/DesktopAgent.d.ts +56 -0
- package/lib/agent/DesktopAgent.d.ts.map +1 -0
- package/lib/agent/DesktopAgent.js +118 -0
- package/lib/agent/DesktopAgent.js.map +1 -0
- package/lib/agent/DesktopAgent.test.d.ts +2 -0
- package/lib/agent/DesktopAgent.test.d.ts.map +1 -0
- package/lib/agent/DesktopAgent.test.js +97 -0
- package/lib/agent/DesktopAgent.test.js.map +1 -0
- package/lib/agent/agent.contract.cjs +66 -0
- package/lib/agent/agent.contract.cjs.map +1 -0
- package/lib/agent/agent.contract.d.cts +2 -0
- package/lib/agent/agent.contract.d.cts.map +1 -0
- package/lib/agent/index.d.ts +3 -0
- package/lib/agent/index.d.ts.map +1 -0
- package/lib/agent/index.js +2 -0
- package/lib/agent/index.js.map +1 -0
- package/lib/artifacts/ArtifactManager.d.ts +13 -0
- package/lib/artifacts/ArtifactManager.d.ts.map +1 -0
- package/lib/artifacts/ArtifactManager.js +88 -0
- package/lib/artifacts/ArtifactManager.js.map +1 -0
- package/lib/artifacts/ArtifactManager.test.d.ts +2 -0
- package/lib/artifacts/ArtifactManager.test.d.ts.map +1 -0
- package/lib/artifacts/ArtifactManager.test.js +19 -0
- package/lib/artifacts/ArtifactManager.test.js.map +1 -0
- package/lib/artifacts/index.d.ts +2 -0
- package/lib/artifacts/index.d.ts.map +1 -0
- package/lib/artifacts/index.js +2 -0
- package/lib/artifacts/index.js.map +1 -0
- package/lib/authoring/index.d.ts +4 -0
- package/lib/authoring/index.d.ts.map +1 -0
- package/lib/authoring/index.js +2 -0
- package/lib/authoring/index.js.map +1 -0
- package/lib/authoring/results.d.ts +43 -0
- package/lib/authoring/results.d.ts.map +1 -0
- package/lib/authoring/results.js +2 -0
- package/lib/authoring/results.js.map +1 -0
- package/lib/authoring/storyTests.d.ts +81 -0
- package/lib/authoring/storyTests.d.ts.map +1 -0
- package/lib/authoring/storyTests.js +272 -0
- package/lib/authoring/storyTests.js.map +1 -0
- package/lib/authoring/storyTests.test.d.ts +2 -0
- package/lib/authoring/storyTests.test.d.ts.map +1 -0
- package/lib/authoring/storyTests.test.js +46 -0
- package/lib/authoring/storyTests.test.js.map +1 -0
- package/lib/cli/DesktopDriverCli.test.d.ts +2 -0
- package/lib/cli/DesktopDriverCli.test.d.ts.map +1 -0
- package/lib/cli/DesktopDriverCli.test.js +141 -0
- package/lib/cli/DesktopDriverCli.test.js.map +1 -0
- package/lib/cli/createDesktopDriverCommand.d.ts +8 -0
- package/lib/cli/createDesktopDriverCommand.d.ts.map +1 -0
- package/lib/cli/createDesktopDriverCommand.js +242 -0
- package/lib/cli/createDesktopDriverCommand.js.map +1 -0
- package/lib/cli/index.d.ts +3 -0
- package/lib/cli/index.d.ts.map +1 -0
- package/lib/cli/index.js +2 -0
- package/lib/cli/index.js.map +1 -0
- package/lib/client/DesktopDriverClient.d.ts +60 -0
- package/lib/client/DesktopDriverClient.d.ts.map +1 -0
- package/lib/client/DesktopDriverClient.js +155 -0
- package/lib/client/DesktopDriverClient.js.map +1 -0
- package/lib/client/index.d.ts +3 -0
- package/lib/client/index.d.ts.map +1 -0
- package/lib/client/index.js +2 -0
- package/lib/client/index.js.map +1 -0
- package/lib/host/types.d.ts +153 -0
- package/lib/host/types.d.ts.map +1 -0
- package/lib/host/types.js +2 -0
- package/lib/host/types.js.map +1 -0
- package/lib/hosts/fake/FakeDesktopHost.d.ts +72 -0
- package/lib/hosts/fake/FakeDesktopHost.d.ts.map +1 -0
- package/lib/hosts/fake/FakeDesktopHost.js +452 -0
- package/lib/hosts/fake/FakeDesktopHost.js.map +1 -0
- package/lib/index.d.ts +23 -0
- package/lib/index.d.ts.map +1 -0
- package/lib/index.js +11 -0
- package/lib/index.js.map +1 -0
- package/lib/protocol/actions.d.ts +11 -0
- package/lib/protocol/actions.d.ts.map +1 -0
- package/lib/protocol/actions.js +137 -0
- package/lib/protocol/actions.js.map +1 -0
- package/lib/protocol/capabilities.d.ts +11 -0
- package/lib/protocol/capabilities.d.ts.map +1 -0
- package/lib/protocol/capabilities.js +103 -0
- package/lib/protocol/capabilities.js.map +1 -0
- package/lib/protocol/constants.d.ts +2 -0
- package/lib/protocol/constants.d.ts.map +1 -0
- package/lib/protocol/constants.js +2 -0
- package/lib/protocol/constants.js.map +1 -0
- package/lib/protocol/errors.d.ts +37 -0
- package/lib/protocol/errors.d.ts.map +1 -0
- package/lib/protocol/errors.js +66 -0
- package/lib/protocol/errors.js.map +1 -0
- package/lib/protocol/timeouts.d.ts +2 -0
- package/lib/protocol/timeouts.d.ts.map +1 -0
- package/lib/protocol/timeouts.js +23 -0
- package/lib/protocol/timeouts.js.map +1 -0
- package/lib/protocol/types.d.ts +46 -0
- package/lib/protocol/types.d.ts.map +1 -0
- package/lib/protocol/types.js +2 -0
- package/lib/protocol/types.js.map +1 -0
- package/lib/runner/StoryTestRunner.d.ts +38 -0
- package/lib/runner/StoryTestRunner.d.ts.map +1 -0
- package/lib/runner/StoryTestRunner.js +497 -0
- package/lib/runner/StoryTestRunner.js.map +1 -0
- package/lib/runner/StoryTestRunner.test.d.ts +2 -0
- package/lib/runner/StoryTestRunner.test.d.ts.map +1 -0
- package/lib/runner/StoryTestRunner.test.js +349 -0
- package/lib/runner/StoryTestRunner.test.js.map +1 -0
- package/lib/runner/index.d.ts +3 -0
- package/lib/runner/index.d.ts.map +1 -0
- package/lib/runner/index.js +2 -0
- package/lib/runner/index.js.map +1 -0
- package/lib/server/SessionManager.d.ts +52 -0
- package/lib/server/SessionManager.d.ts.map +1 -0
- package/lib/server/SessionManager.js +208 -0
- package/lib/server/SessionManager.js.map +1 -0
- package/lib/server/TargetRegistry.d.ts +9 -0
- package/lib/server/TargetRegistry.d.ts.map +1 -0
- package/lib/server/TargetRegistry.js +24 -0
- package/lib/server/TargetRegistry.js.map +1 -0
- package/lib/server/createDesktopDriverServer.d.ts +19 -0
- package/lib/server/createDesktopDriverServer.d.ts.map +1 -0
- package/lib/server/createDesktopDriverServer.js +609 -0
- package/lib/server/createDesktopDriverServer.js.map +1 -0
- package/lib/server/desktopDriver.test.d.ts +2 -0
- package/lib/server/desktopDriver.test.d.ts.map +1 -0
- package/lib/server/desktopDriver.test.js +389 -0
- package/lib/server/desktopDriver.test.js.map +1 -0
- package/lib/server/index.d.ts +6 -0
- package/lib/server/index.d.ts.map +1 -0
- package/lib/server/index.js +4 -0
- package/lib/server/index.js.map +1 -0
- package/lib/server/webdriverio.contract.cjs +35 -0
- package/lib/server/webdriverio.contract.cjs.map +1 -0
- package/lib/server/webdriverio.contract.d.cts +2 -0
- package/lib/server/webdriverio.contract.d.cts.map +1 -0
- package/lib/server/webdriverio.test.d.ts +2 -0
- package/lib/server/webdriverio.test.d.ts.map +1 -0
- package/lib/server/webdriverio.test.js +40 -0
- package/lib/server/webdriverio.test.js.map +1 -0
- package/lib/storybook.d.ts +36 -0
- package/lib/storybook.d.ts.map +1 -0
- package/lib/storybook.js +2 -0
- package/lib/storybook.js.map +1 -0
- package/lib/testing/FakeStoryOrchestrator.d.ts +21 -0
- package/lib/testing/FakeStoryOrchestrator.d.ts.map +1 -0
- package/lib/testing/FakeStoryOrchestrator.js +44 -0
- package/lib/testing/FakeStoryOrchestrator.js.map +1 -0
- package/lib/testing/fakeStoryElements.d.ts +4 -0
- package/lib/testing/fakeStoryElements.d.ts.map +1 -0
- package/lib/testing/fakeStoryElements.js +143 -0
- package/lib/testing/fakeStoryElements.js.map +1 -0
- package/lib/testing/fakeStoryElements.test.d.ts +2 -0
- package/lib/testing/fakeStoryElements.test.d.ts.map +1 -0
- package/lib/testing/fakeStoryElements.test.js +38 -0
- package/lib/testing/fakeStoryElements.test.js.map +1 -0
- package/lib/testing/index.d.ts +7 -0
- package/lib/testing/index.d.ts.map +1 -0
- package/lib/testing/index.js +5 -0
- package/lib/testing/index.js.map +1 -0
- package/lib/testing/protocolHarness.d.ts +17 -0
- package/lib/testing/protocolHarness.d.ts.map +1 -0
- package/lib/testing/protocolHarness.js +46 -0
- package/lib/testing/protocolHarness.js.map +1 -0
- package/lib/wdio/DesktopWebdriver.d.ts +47 -0
- package/lib/wdio/DesktopWebdriver.d.ts.map +1 -0
- package/lib/wdio/DesktopWebdriver.js +76 -0
- package/lib/wdio/DesktopWebdriver.js.map +1 -0
- package/lib/wdio/DesktopWebdriver.test.d.ts +2 -0
- package/lib/wdio/DesktopWebdriver.test.d.ts.map +1 -0
- package/lib/wdio/DesktopWebdriver.test.js +74 -0
- package/lib/wdio/DesktopWebdriver.test.js.map +1 -0
- package/lib/wdio/index.d.ts +3 -0
- package/lib/wdio/index.d.ts.map +1 -0
- package/lib/wdio/index.js +2 -0
- package/lib/wdio/index.js.map +1 -0
- package/lib/wdio/wdioRunner.contract.cjs +64 -0
- package/lib/wdio/wdioRunner.contract.cjs.map +1 -0
- package/lib/wdio/wdioRunner.contract.d.cts +2 -0
- package/lib/wdio/wdioRunner.contract.d.cts.map +1 -0
- package/package.json +96 -0
- package/src/agent/DesktopAgent.test.ts +102 -0
- package/src/agent/DesktopAgent.ts +170 -0
- package/src/agent/agent.contract.cjs +32 -0
- package/src/agent/index.ts +8 -0
- package/src/artifacts/ArtifactManager.test.ts +20 -0
- package/src/artifacts/ArtifactManager.ts +105 -0
- package/src/artifacts/index.ts +1 -0
- package/src/authoring/index.ts +26 -0
- package/src/authoring/results.ts +47 -0
- package/src/authoring/storyTests.test.ts +58 -0
- package/src/authoring/storyTests.ts +345 -0
- package/src/cli/DesktopDriverCli.test.ts +150 -0
- package/src/cli/createDesktopDriverCommand.ts +290 -0
- package/src/cli/index.ts +2 -0
- package/src/client/DesktopDriverClient.ts +218 -0
- package/src/client/index.ts +2 -0
- package/src/host/types.ts +158 -0
- package/src/hosts/fake/FakeDesktopHost.ts +558 -0
- package/src/index.ts +101 -0
- package/src/protocol/actions.ts +163 -0
- package/src/protocol/capabilities.ts +129 -0
- package/src/protocol/constants.ts +1 -0
- package/src/protocol/errors.ts +76 -0
- package/src/protocol/timeouts.ts +26 -0
- package/src/protocol/types.ts +55 -0
- package/src/runner/StoryTestRunner.test.ts +362 -0
- package/src/runner/StoryTestRunner.ts +624 -0
- package/src/runner/index.ts +9 -0
- package/src/server/SessionManager.ts +302 -0
- package/src/server/TargetRegistry.ts +29 -0
- package/src/server/createDesktopDriverServer.ts +775 -0
- package/src/server/desktopDriver.test.ts +421 -0
- package/src/server/index.ts +5 -0
- package/src/server/webdriverio.contract.cjs +38 -0
- package/src/server/webdriverio.test.ts +41 -0
- package/src/storybook.ts +40 -0
- package/src/testing/FakeStoryOrchestrator.ts +55 -0
- package/src/testing/fakeStoryElements.test.ts +40 -0
- package/src/testing/fakeStoryElements.ts +167 -0
- package/src/testing/index.ts +6 -0
- package/src/testing/protocolHarness.ts +66 -0
- package/src/wdio/DesktopWebdriver.test.ts +76 -0
- package/src/wdio/DesktopWebdriver.ts +130 -0
- package/src/wdio/index.ts +2 -0
- package/src/wdio/wdioRunner.contract.cjs +30 -0
- package/tsconfig.json +15 -0
|
@@ -0,0 +1,775 @@
|
|
|
1
|
+
import { Buffer } from 'node:buffer';
|
|
2
|
+
import { createServer } from 'node:http';
|
|
3
|
+
import type { IncomingMessage, Server, ServerResponse } from 'node:http';
|
|
4
|
+
|
|
5
|
+
import type { DesktopTarget, DesktopTreeNode, NativeActionSequence, NativeElementSnapshot, NativeSelector, Rect } from '../host/types.js';
|
|
6
|
+
import { createInputState, parseActionSequences } from '../protocol/actions.js';
|
|
7
|
+
import { createReturnedCapabilities, matchCapabilities } from '../protocol/capabilities.js';
|
|
8
|
+
import { webElementIdentifier } from '../protocol/constants.js';
|
|
9
|
+
import { invalidArgument, toWebDriverError, WebDriverError } from '../protocol/errors.js';
|
|
10
|
+
import { withCommandTimeout } from '../protocol/timeouts.js';
|
|
11
|
+
import type { NewSessionRequest, WebDriverActionSequence, WebDriverElement, WebDriverResponse } from '../protocol/types.js';
|
|
12
|
+
import { SessionManager } from './SessionManager.js';
|
|
13
|
+
import type { DesktopSession, ElementRecord } from './SessionManager.js';
|
|
14
|
+
import { TargetRegistry } from './TargetRegistry.js';
|
|
15
|
+
|
|
16
|
+
export type DesktopDriverServerOptions = {
|
|
17
|
+
host?: string;
|
|
18
|
+
maxBodyBytes?: number;
|
|
19
|
+
port?: number;
|
|
20
|
+
targets?: readonly DesktopTarget[];
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export type DesktopDriverServer = {
|
|
24
|
+
host: string;
|
|
25
|
+
port: number;
|
|
26
|
+
sessions: SessionManager;
|
|
27
|
+
targets: TargetRegistry;
|
|
28
|
+
url: string;
|
|
29
|
+
close(): Promise<void>;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
type RouteContext = {
|
|
33
|
+
body: unknown;
|
|
34
|
+
method: string;
|
|
35
|
+
segments: string[];
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export async function createDesktopDriverServer(options: DesktopDriverServerOptions = {}): Promise<DesktopDriverServer> {
|
|
39
|
+
const host = options.host ?? '127.0.0.1';
|
|
40
|
+
if (!isLoopbackHost(host)) {
|
|
41
|
+
throw new Error(`Desktop Driver binds to loopback by default; "${host}" is not a supported loopback host.`);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const targets = new TargetRegistry(options.targets);
|
|
45
|
+
const sessions = new SessionManager();
|
|
46
|
+
let closing = false;
|
|
47
|
+
const httpServer = createServer((request, response) => {
|
|
48
|
+
if (closing) {
|
|
49
|
+
const error = new WebDriverError('unknown error', 'Desktop Driver is shutting down.');
|
|
50
|
+
writeJson(response, error.status, { value: error.toJSON() });
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
void handleRequest(request, response, sessions, targets, options.maxBodyBytes ?? 1024 * 1024);
|
|
54
|
+
});
|
|
55
|
+
const port = await listen(httpServer, host, options.port ?? 0);
|
|
56
|
+
|
|
57
|
+
return {
|
|
58
|
+
host,
|
|
59
|
+
port,
|
|
60
|
+
sessions,
|
|
61
|
+
targets,
|
|
62
|
+
// eslint-disable-next-line @microsoft/sdl/no-insecure-url -- WebDriver is an intentionally loopback-only local protocol.
|
|
63
|
+
url: `http://${formatHost(host)}:${port}`,
|
|
64
|
+
async close() {
|
|
65
|
+
closing = true;
|
|
66
|
+
sessions.beginClose();
|
|
67
|
+
let sessionError: unknown;
|
|
68
|
+
await closeServer(httpServer);
|
|
69
|
+
await sessions.waitForCreates();
|
|
70
|
+
try {
|
|
71
|
+
await sessions.deleteAll();
|
|
72
|
+
} catch (error) {
|
|
73
|
+
sessionError = error;
|
|
74
|
+
}
|
|
75
|
+
const hosts = new Set(targets.list().map(({ host: targetHost }) => targetHost));
|
|
76
|
+
const results = await Promise.allSettled(
|
|
77
|
+
[...hosts].map((targetHost) => withCommandTimeout((signal) => targetHost.dispose(signal), 10_000, 'Disposing a desktop host')),
|
|
78
|
+
);
|
|
79
|
+
const hostErrors = results
|
|
80
|
+
.filter((result): result is PromiseRejectedResult => result.status === 'rejected')
|
|
81
|
+
.map(({ reason }) => reason);
|
|
82
|
+
if (sessionError || hostErrors.length > 0) {
|
|
83
|
+
throw new AggregateError([...(sessionError ? [sessionError] : []), ...hostErrors], 'Desktop Driver cleanup failed.');
|
|
84
|
+
}
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function handleRequest(
|
|
90
|
+
request: IncomingMessage,
|
|
91
|
+
response: ServerResponse,
|
|
92
|
+
sessions: SessionManager,
|
|
93
|
+
targets: TargetRegistry,
|
|
94
|
+
maxBodyBytes: number,
|
|
95
|
+
): Promise<void> {
|
|
96
|
+
try {
|
|
97
|
+
if (request.headers.origin) {
|
|
98
|
+
throw new WebDriverError('unknown error', 'Browser-origin requests are not accepted by Desktop Driver.');
|
|
99
|
+
}
|
|
100
|
+
// eslint-disable-next-line @microsoft/sdl/no-insecure-url -- This base URL only parses a loopback listener path.
|
|
101
|
+
const url = new URL(request.url ?? '/', 'http://127.0.0.1');
|
|
102
|
+
const segments = url.pathname.split('/').filter(Boolean).map(decodeURIComponent);
|
|
103
|
+
const method = request.method ?? 'GET';
|
|
104
|
+
const body = method === 'GET' || method === 'DELETE' ? undefined : await readJsonBody(request, maxBodyBytes);
|
|
105
|
+
const operation = () => route({ body, method, segments }, sessions, targets);
|
|
106
|
+
const value = segments[0] === 'session' && segments[1] ? await sessions.runCommand(segments[1], operation) : await operation();
|
|
107
|
+
writeJson(response, 200, { value });
|
|
108
|
+
} catch (error) {
|
|
109
|
+
const webdriverError = toWebDriverError(error);
|
|
110
|
+
writeJson(response, webdriverError.status, { value: webdriverError.toJSON() });
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async function route(context: RouteContext, sessions: SessionManager, targets: TargetRegistry): Promise<unknown> {
|
|
115
|
+
const { body, method, segments } = context;
|
|
116
|
+
|
|
117
|
+
if (method === 'GET' && segments.length === 1 && segments[0] === 'status') {
|
|
118
|
+
const targetInfo = await Promise.all(
|
|
119
|
+
targets.list().map(async (target) => ({
|
|
120
|
+
endpoint: target.endpoint,
|
|
121
|
+
id: target.id,
|
|
122
|
+
platformName: target.platformName,
|
|
123
|
+
renderer: target.renderer,
|
|
124
|
+
host: await withCommandTimeout((signal) => target.host.probe(signal), 10_000, `Probing target "${target.id}"`),
|
|
125
|
+
})),
|
|
126
|
+
);
|
|
127
|
+
return { message: 'Desktop Driver is ready.', ready: true, targets: targetInfo };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (method === 'POST' && segments.length === 1 && segments[0] === 'session') {
|
|
131
|
+
const request = requireObject(body, 'New Session request') as NewSessionRequest;
|
|
132
|
+
if (!request.capabilities) {
|
|
133
|
+
throw invalidArgument('New Session requires "capabilities".');
|
|
134
|
+
}
|
|
135
|
+
const matched = await matchCapabilities(request.capabilities, targets.list());
|
|
136
|
+
const hostInfo = await withCommandTimeout(
|
|
137
|
+
(signal) => matched.target.host.probe(signal),
|
|
138
|
+
10_000,
|
|
139
|
+
`Probing target "${matched.target.id}"`,
|
|
140
|
+
);
|
|
141
|
+
const launchMode = matched.requested['furn:launchMode'] ?? 'launch';
|
|
142
|
+
if (launchMode !== 'attach' && launchMode !== 'launch') {
|
|
143
|
+
throw invalidArgument('"furn:launchMode" must be "attach" or "launch".');
|
|
144
|
+
}
|
|
145
|
+
const session = await sessions.create(matched.target, hostInfo, matched.clickMode, launchMode);
|
|
146
|
+
return {
|
|
147
|
+
sessionId: session.id,
|
|
148
|
+
capabilities: createReturnedCapabilities(matched, hostInfo, session.timeouts),
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (segments[0] !== 'session' || !segments[1]) {
|
|
153
|
+
throw new WebDriverError('unknown command', `No WebDriver command matches ${method} /${segments.join('/')}.`);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const session = sessions.get(segments[1]);
|
|
157
|
+
const command = segments.slice(2);
|
|
158
|
+
|
|
159
|
+
if (method === 'DELETE' && command.length === 0) {
|
|
160
|
+
await sessions.delete(session.id);
|
|
161
|
+
return null;
|
|
162
|
+
}
|
|
163
|
+
if (command[0] === 'timeouts') {
|
|
164
|
+
return handleTimeouts(method, body, session);
|
|
165
|
+
}
|
|
166
|
+
if (command[0] === 'window') {
|
|
167
|
+
return handleWindowCommand(method, command.slice(1), body, session, sessions);
|
|
168
|
+
}
|
|
169
|
+
if (method === 'POST' && (command[0] === 'element' || command[0] === 'elements') && command.length === 1) {
|
|
170
|
+
return findElements(session, sessions, body, command[0] === 'element');
|
|
171
|
+
}
|
|
172
|
+
if (method === 'GET' && command[0] === 'element' && command[1] === 'active') {
|
|
173
|
+
const active = await hostCommand(session, 'Reading the active element', (signal) =>
|
|
174
|
+
session.target.host.activeElement(session.currentWindowId, signal),
|
|
175
|
+
);
|
|
176
|
+
return active ? toElementReference(sessions.registerElement(session, active)) : null;
|
|
177
|
+
}
|
|
178
|
+
if (command[0] === 'element' && command[1]) {
|
|
179
|
+
return handleElementCommand(method, command.slice(1), body, session, sessions);
|
|
180
|
+
}
|
|
181
|
+
if (command[0] === 'actions') {
|
|
182
|
+
if (method === 'POST') {
|
|
183
|
+
const { actions, nextState } = parseActionSequences(requireObject(body, 'actions request').actions, session.inputState);
|
|
184
|
+
const resolvedActions = await resolveActionOrigins(actions, session, sessions);
|
|
185
|
+
await sessions.runInputCommand(async () => {
|
|
186
|
+
session.inputState = nextState;
|
|
187
|
+
try {
|
|
188
|
+
await hostCommand(session, 'Performing input actions', (signal) => session.target.host.performActions(resolvedActions, signal));
|
|
189
|
+
} catch (error) {
|
|
190
|
+
await hostCommand(session, 'Releasing failed input actions', (signal) => session.target.host.releaseActions(signal)).catch(
|
|
191
|
+
() => undefined,
|
|
192
|
+
);
|
|
193
|
+
session.inputState = createInputState();
|
|
194
|
+
throw error;
|
|
195
|
+
}
|
|
196
|
+
});
|
|
197
|
+
return null;
|
|
198
|
+
}
|
|
199
|
+
if (method === 'DELETE') {
|
|
200
|
+
await sessions.runInputCommand(() =>
|
|
201
|
+
hostCommand(session, 'Releasing input actions', (signal) => session.target.host.releaseActions(signal)),
|
|
202
|
+
);
|
|
203
|
+
session.inputState = createInputState();
|
|
204
|
+
return null;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
if (method === 'GET' && command[0] === 'screenshot' && command.length === 1) {
|
|
208
|
+
const image = await hostCommand(session, 'Capturing the current window', (signal) =>
|
|
209
|
+
session.target.host.captureWindow(session.currentWindowId, signal),
|
|
210
|
+
);
|
|
211
|
+
return Buffer.from(image.data).toString('base64');
|
|
212
|
+
}
|
|
213
|
+
if (method === 'GET' && command[0] === 'source' && command.length === 1) {
|
|
214
|
+
return hostCommand(session, 'Reading the accessibility source', (signal) =>
|
|
215
|
+
session.target.host.source(session.currentWindowId, signal),
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
if (command[0] === 'furn') {
|
|
219
|
+
if (method === 'GET' && command[1] === 'tree' && command.length === 2) {
|
|
220
|
+
const snapshots = await hostCommand(session, 'Reading the compact accessibility tree', (signal) =>
|
|
221
|
+
session.target.host.tree(session.currentWindowId, signal),
|
|
222
|
+
);
|
|
223
|
+
return buildDesktopTree(snapshots);
|
|
224
|
+
}
|
|
225
|
+
return handleStorybookCommand(method, command.slice(1), body, session, sessions);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function buildDesktopTree(snapshots: readonly NativeElementSnapshot[]): DesktopTreeNode[] {
|
|
229
|
+
const children = new Map<string | undefined, NativeElementSnapshot[]>();
|
|
230
|
+
for (const snapshot of snapshots) {
|
|
231
|
+
const siblings = children.get(snapshot.parentId) ?? [];
|
|
232
|
+
siblings.push(snapshot);
|
|
233
|
+
children.set(snapshot.parentId, siblings);
|
|
234
|
+
}
|
|
235
|
+
const build = (snapshot: NativeElementSnapshot): DesktopTreeNode => ({
|
|
236
|
+
children: (children.get(snapshot.id) ?? []).map(build),
|
|
237
|
+
...(snapshot.name ? { name: snapshot.name } : {}),
|
|
238
|
+
rect: snapshot.rect,
|
|
239
|
+
role: snapshot.role,
|
|
240
|
+
states: {
|
|
241
|
+
...(snapshot.checked.supported ? { checked: snapshot.checked.value } : {}),
|
|
242
|
+
...(snapshot.enabled.supported ? { enabled: snapshot.enabled.value } : {}),
|
|
243
|
+
...(snapshot.expanded.supported ? { expanded: snapshot.expanded.value } : {}),
|
|
244
|
+
...(snapshot.focused.supported ? { focused: snapshot.focused.value } : {}),
|
|
245
|
+
...(snapshot.selected.supported ? { selected: snapshot.selected.value } : {}),
|
|
246
|
+
...(snapshot.visible.supported ? { visible: snapshot.visible.value } : {}),
|
|
247
|
+
},
|
|
248
|
+
...(snapshot.automationId ? { testId: snapshot.automationId } : {}),
|
|
249
|
+
...(snapshot.text ? { text: snapshot.text } : {}),
|
|
250
|
+
...(snapshot.value !== undefined ? { value: snapshot.value } : {}),
|
|
251
|
+
});
|
|
252
|
+
return (children.get(undefined) ?? []).map(build);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
async function resolveActionOrigins(
|
|
256
|
+
actions: readonly WebDriverActionSequence[],
|
|
257
|
+
session: DesktopSession,
|
|
258
|
+
sessions: SessionManager,
|
|
259
|
+
): Promise<NativeActionSequence[]> {
|
|
260
|
+
const resolved: NativeActionSequence[] = [];
|
|
261
|
+
for (const sequence of actions) {
|
|
262
|
+
const resolvedSequence: NativeActionSequence = { ...sequence, actions: [] };
|
|
263
|
+
for (const action of sequence.actions) {
|
|
264
|
+
const origin = action.origin;
|
|
265
|
+
if (!origin || origin === 'viewport' || origin === 'pointer') {
|
|
266
|
+
resolvedSequence.actions.push(action);
|
|
267
|
+
continue;
|
|
268
|
+
}
|
|
269
|
+
const elementId = typeof origin === 'object' ? origin[webElementIdentifier] : undefined;
|
|
270
|
+
if (typeof elementId !== 'string') {
|
|
271
|
+
throw invalidArgument('Action element origin is missing a valid WebDriver element reference.');
|
|
272
|
+
}
|
|
273
|
+
const record = sessions.resolveElement(session, elementId);
|
|
274
|
+
await hostCommand(session, `Resolving action origin element "${elementId}"`, (signal) =>
|
|
275
|
+
session.target.host.snapshot(record.nativeId, signal),
|
|
276
|
+
);
|
|
277
|
+
resolvedSequence.actions.push({
|
|
278
|
+
...action,
|
|
279
|
+
origin: { elementId: record.nativeId },
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
resolved.push(resolvedSequence);
|
|
283
|
+
}
|
|
284
|
+
return resolved;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
if (isUnsupportedBrowserCommand(command)) {
|
|
288
|
+
throw new WebDriverError('unsupported operation', `/${command.join('/')} is not supported for native desktop applications.`);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
async function handleStorybookCommand(
|
|
292
|
+
method: string,
|
|
293
|
+
command: string[],
|
|
294
|
+
body: unknown,
|
|
295
|
+
session: DesktopSession,
|
|
296
|
+
sessions: SessionManager,
|
|
297
|
+
): Promise<unknown> {
|
|
298
|
+
const orchestrator = session.target.storyOrchestrator;
|
|
299
|
+
if (!orchestrator) {
|
|
300
|
+
throw new WebDriverError('unsupported operation', `Target "${session.target.id}" does not provide Storybook orchestration.`);
|
|
301
|
+
}
|
|
302
|
+
if (method === 'GET' && command[0] === 'manifest' && command.length === 1) {
|
|
303
|
+
return withCommandTimeout(() => orchestrator.getManifest(), session.desktopTimeouts.nativeCommand, 'Reading the Storybook manifest');
|
|
304
|
+
}
|
|
305
|
+
if (method === 'GET' && command[0] === 'story' && command.length === 1) {
|
|
306
|
+
return session.story;
|
|
307
|
+
}
|
|
308
|
+
if (method === 'POST' && command[0] === 'story' && command.length === 1) {
|
|
309
|
+
const request = parseStorySelection(body);
|
|
310
|
+
const deadline = Date.now() + session.desktopTimeouts.storyRender;
|
|
311
|
+
sessions.invalidatePreview(session);
|
|
312
|
+
session.story = null;
|
|
313
|
+
const result = await withCommandTimeout(
|
|
314
|
+
() => orchestrator.selectStory(request),
|
|
315
|
+
remainingTime(deadline),
|
|
316
|
+
`Selecting Storybook story "${request.storyId}"`,
|
|
317
|
+
);
|
|
318
|
+
await verifyStoryMarker(session, result, deadline);
|
|
319
|
+
session.story = result;
|
|
320
|
+
return result;
|
|
321
|
+
}
|
|
322
|
+
if (method === 'POST' && command[0] === 'story' && command[1] === 'reset') {
|
|
323
|
+
const request = parseStorySelection(body);
|
|
324
|
+
const deadline = Date.now() + session.desktopTimeouts.storyRender;
|
|
325
|
+
sessions.invalidatePreview(session);
|
|
326
|
+
session.story = null;
|
|
327
|
+
const result = await withCommandTimeout(
|
|
328
|
+
() => orchestrator.resetStory(request),
|
|
329
|
+
remainingTime(deadline),
|
|
330
|
+
`Resetting Storybook story "${request.storyId}"`,
|
|
331
|
+
);
|
|
332
|
+
await verifyStoryMarker(session, result, deadline);
|
|
333
|
+
session.story = result;
|
|
334
|
+
return result;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
async function verifyStoryMarker(
|
|
338
|
+
session: DesktopSession,
|
|
339
|
+
expected: { previewGeneration: number; runId: string; storyId: string },
|
|
340
|
+
deadline: number,
|
|
341
|
+
): Promise<void> {
|
|
342
|
+
const testId = session.target.storyRootTestId;
|
|
343
|
+
if (!testId) {
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
do {
|
|
347
|
+
const matches = await hostCommand(session, 'Finding the native Storybook story marker', (signal) =>
|
|
348
|
+
session.target.host.find({ windowId: session.currentWindowId }, { strategy: 'accessibility id', value: testId }, signal),
|
|
349
|
+
);
|
|
350
|
+
for (const marker of matches) {
|
|
351
|
+
const value = marker.name ?? marker.value ?? marker.text;
|
|
352
|
+
if (value && matchesStoryMarker(value, expected)) {
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
await delay(25);
|
|
357
|
+
} while (Date.now() <= deadline);
|
|
358
|
+
throw new WebDriverError(
|
|
359
|
+
'timeout',
|
|
360
|
+
`The native Storybook marker did not confirm story "${expected.storyId}" run "${expected.runId}".`,
|
|
361
|
+
);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function matchesStoryMarker(value: string, expected: { previewGeneration: number; runId: string; storyId: string }): boolean {
|
|
365
|
+
try {
|
|
366
|
+
const marker = JSON.parse(value) as Record<string, unknown>;
|
|
367
|
+
return (
|
|
368
|
+
marker.storyId === expected.storyId && marker.runId === expected.runId && marker.previewGeneration === expected.previewGeneration
|
|
369
|
+
);
|
|
370
|
+
} catch {
|
|
371
|
+
return false;
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
if (method === 'POST' && command[0] === 'story' && command[1] === 'args') {
|
|
375
|
+
if (!orchestrator.updateArgs) {
|
|
376
|
+
throw new WebDriverError('unsupported operation', 'This Storybook target does not support arg updates.');
|
|
377
|
+
}
|
|
378
|
+
const request = requireObject(body, 'Storybook args request');
|
|
379
|
+
if (typeof request.storyId !== 'string') {
|
|
380
|
+
throw invalidArgument('Storybook args request requires a string "storyId".');
|
|
381
|
+
}
|
|
382
|
+
const args = requireObject(request.args, 'Storybook args request "args"');
|
|
383
|
+
sessions.invalidatePreview(session);
|
|
384
|
+
session.story = null;
|
|
385
|
+
await withCommandTimeout(
|
|
386
|
+
() => orchestrator.updateArgs!(request.storyId as string, args),
|
|
387
|
+
session.desktopTimeouts.storyRender,
|
|
388
|
+
`Updating args for Storybook story "${request.storyId}"`,
|
|
389
|
+
);
|
|
390
|
+
return null;
|
|
391
|
+
}
|
|
392
|
+
throw new WebDriverError('unknown command', `Unknown Desktop Driver extension command "furn/${command.join('/')}".`);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
function parseStorySelection(body: unknown): { requestId: string; runId: string; storyId: string } {
|
|
396
|
+
const request = requireObject(body, 'Storybook selection request');
|
|
397
|
+
for (const field of ['requestId', 'runId', 'storyId'] as const) {
|
|
398
|
+
if (typeof request[field] !== 'string' || !request[field]) {
|
|
399
|
+
throw invalidArgument(`Storybook selection request requires a non-empty string "${field}".`);
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
return {
|
|
403
|
+
requestId: request.requestId as string,
|
|
404
|
+
runId: request.runId as string,
|
|
405
|
+
storyId: request.storyId as string,
|
|
406
|
+
};
|
|
407
|
+
}
|
|
408
|
+
throw new WebDriverError('unknown command', `No WebDriver command matches ${method} /session/${session.id}/${command.join('/')}.`);
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
function handleTimeouts(method: string, body: unknown, session: DesktopSession): unknown {
|
|
412
|
+
if (method === 'GET') {
|
|
413
|
+
return { ...session.timeouts };
|
|
414
|
+
}
|
|
415
|
+
if (method !== 'POST') {
|
|
416
|
+
throw new WebDriverError('unknown method', `Method ${method} is not allowed for timeouts.`);
|
|
417
|
+
}
|
|
418
|
+
const updates = requireObject(body, 'timeouts');
|
|
419
|
+
for (const name of ['implicit', 'pageLoad', 'script'] as const) {
|
|
420
|
+
if (updates[name] !== undefined) {
|
|
421
|
+
const value = updates[name];
|
|
422
|
+
if (!Number.isInteger(value) || (value as number) < 0) {
|
|
423
|
+
throw invalidArgument(`Timeout "${name}" must be a non-negative integer.`);
|
|
424
|
+
}
|
|
425
|
+
session.timeouts[name] = value as number;
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
return null;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
async function handleWindowCommand(
|
|
432
|
+
method: string,
|
|
433
|
+
command: string[],
|
|
434
|
+
body: unknown,
|
|
435
|
+
session: DesktopSession,
|
|
436
|
+
sessions: SessionManager,
|
|
437
|
+
): Promise<unknown> {
|
|
438
|
+
const host = session.target.host;
|
|
439
|
+
if (command.length === 0 && method === 'GET') {
|
|
440
|
+
ensureCurrentWindow(session);
|
|
441
|
+
return session.currentWindowId;
|
|
442
|
+
}
|
|
443
|
+
if (command.length === 0 && method === 'POST') {
|
|
444
|
+
const handle = requireObject(body, 'switch window request').handle;
|
|
445
|
+
if (typeof handle !== 'string' || !session.windows.some(({ id }) => id === handle)) {
|
|
446
|
+
throw new WebDriverError('no such window', `Window "${String(handle)}" does not exist in this session.`);
|
|
447
|
+
}
|
|
448
|
+
await sessions.runInputCommand(() => hostCommand(session, `Activating window "${handle}"`, (signal) => host.activate(handle, signal)));
|
|
449
|
+
session.currentWindowId = handle;
|
|
450
|
+
return null;
|
|
451
|
+
}
|
|
452
|
+
if (command.length === 0 && method === 'DELETE') {
|
|
453
|
+
ensureCurrentWindow(session);
|
|
454
|
+
await sessions.runInputCommand(() =>
|
|
455
|
+
hostCommand(session, `Closing window "${session.currentWindowId}"`, (signal) => host.closeWindow(session.currentWindowId, signal)),
|
|
456
|
+
);
|
|
457
|
+
session.windows = await hostCommand(session, 'Reading application windows', (signal) => host.windows(session.lease, signal));
|
|
458
|
+
session.currentWindowId = session.windows[0]?.id ?? '';
|
|
459
|
+
return session.windows.map(({ id }) => id);
|
|
460
|
+
}
|
|
461
|
+
if (command[0] === 'handles' && command.length === 1 && method === 'GET') {
|
|
462
|
+
session.windows = await hostCommand(session, 'Reading application windows', (signal) => host.windows(session.lease, signal));
|
|
463
|
+
return session.windows.map(({ id }) => id);
|
|
464
|
+
}
|
|
465
|
+
if (command[0] === 'rect' && command.length === 1) {
|
|
466
|
+
ensureCurrentWindow(session);
|
|
467
|
+
if (method === 'GET') {
|
|
468
|
+
return hostCommand(session, 'Reading the current window rectangle', (signal) => host.getWindowRect(session.currentWindowId, signal));
|
|
469
|
+
}
|
|
470
|
+
if (method === 'POST') {
|
|
471
|
+
const rect = validatePartialRect(requireObject(body, 'window rectangle'));
|
|
472
|
+
return hostCommand(session, 'Setting the current window rectangle', (signal) =>
|
|
473
|
+
host.setWindowRect(session.currentWindowId, rect, signal),
|
|
474
|
+
);
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
throw new WebDriverError('unknown command', `Unknown window command "${command.join('/')}".`);
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
async function handleElementCommand(
|
|
481
|
+
method: string,
|
|
482
|
+
command: string[],
|
|
483
|
+
body: unknown,
|
|
484
|
+
session: DesktopSession,
|
|
485
|
+
sessions: SessionManager,
|
|
486
|
+
): Promise<unknown> {
|
|
487
|
+
const record = sessions.resolveElement(session, command[0]);
|
|
488
|
+
const snapshot = await hostCommand(session, `Reading element "${record.id}"`, (signal) =>
|
|
489
|
+
session.target.host.snapshot(record.nativeId, signal),
|
|
490
|
+
);
|
|
491
|
+
const operation = command.slice(1);
|
|
492
|
+
|
|
493
|
+
if (operation[0] === 'shadow') {
|
|
494
|
+
throw new WebDriverError('unsupported operation', 'Shadow roots are not supported for native desktop applications.');
|
|
495
|
+
}
|
|
496
|
+
if (method === 'POST' && (operation[0] === 'element' || operation[0] === 'elements') && operation.length === 1) {
|
|
497
|
+
return findElements(session, sessions, body, operation[0] === 'element', record);
|
|
498
|
+
}
|
|
499
|
+
if (method === 'GET' && operation[0] === 'name') {
|
|
500
|
+
return snapshot.role;
|
|
501
|
+
}
|
|
502
|
+
if (method === 'GET' && operation[0] === 'text') {
|
|
503
|
+
return snapshot.text ?? snapshot.value ?? snapshot.name ?? '';
|
|
504
|
+
}
|
|
505
|
+
if (method === 'GET' && operation[0] === 'rect') {
|
|
506
|
+
return snapshot.rect;
|
|
507
|
+
}
|
|
508
|
+
if (method === 'GET' && operation[0] === 'enabled') {
|
|
509
|
+
return requireSupported(snapshot.enabled, 'enabled');
|
|
510
|
+
}
|
|
511
|
+
if (method === 'GET' && operation[0] === 'selected') {
|
|
512
|
+
return requireSupported(snapshot.selected, 'selected');
|
|
513
|
+
}
|
|
514
|
+
if (method === 'GET' && operation[0] === 'displayed') {
|
|
515
|
+
return requireSupported(snapshot.visible, 'displayed');
|
|
516
|
+
}
|
|
517
|
+
if (method === 'GET' && operation[0] === 'attribute' && operation[1]) {
|
|
518
|
+
return getElementValue(snapshot, operation[1]);
|
|
519
|
+
}
|
|
520
|
+
if (method === 'GET' && operation[0] === 'property' && operation[1]) {
|
|
521
|
+
return getElementValue(snapshot, operation[1]);
|
|
522
|
+
}
|
|
523
|
+
if (method === 'POST' && operation[0] === 'click') {
|
|
524
|
+
if (!requireSupported(snapshot.enabled, 'enabled') || !requireSupported(snapshot.visible, 'visible')) {
|
|
525
|
+
throw new WebDriverError('element not interactable', `Element "${record.id}" cannot be clicked.`);
|
|
526
|
+
}
|
|
527
|
+
await sessions.runInputCommand(async () => {
|
|
528
|
+
await hostCommand(session, `Activating window "${snapshot.windowId}"`, (signal) =>
|
|
529
|
+
session.target.host.activate(snapshot.windowId, signal),
|
|
530
|
+
);
|
|
531
|
+
if (session.clickMode !== 'accessibility') {
|
|
532
|
+
const point = {
|
|
533
|
+
x: snapshot.rect.x + snapshot.rect.width / 2,
|
|
534
|
+
y: snapshot.rect.y + snapshot.rect.height / 2,
|
|
535
|
+
};
|
|
536
|
+
const hit = await hostCommand(session, `Hit testing element "${record.id}"`, (signal) =>
|
|
537
|
+
session.target.host.hitTest(snapshot.windowId, point.x, point.y, signal),
|
|
538
|
+
);
|
|
539
|
+
if (hit && hit.id !== snapshot.id) {
|
|
540
|
+
throw new WebDriverError('element click intercepted', `Element "${record.id}" is obscured by another native element.`);
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
await hostCommand(session, `Clicking element "${record.id}"`, (signal) =>
|
|
544
|
+
session.target.host.click(record.nativeId, session.clickMode, signal),
|
|
545
|
+
);
|
|
546
|
+
});
|
|
547
|
+
return null;
|
|
548
|
+
}
|
|
549
|
+
if (method === 'POST' && operation[0] === 'clear') {
|
|
550
|
+
await sessions.runInputCommand(() =>
|
|
551
|
+
hostCommand(session, `Clearing element "${record.id}"`, (signal) => session.target.host.clear(record.nativeId, signal)),
|
|
552
|
+
);
|
|
553
|
+
return null;
|
|
554
|
+
}
|
|
555
|
+
if (method === 'POST' && operation[0] === 'value') {
|
|
556
|
+
const request = requireObject(body, 'send keys request');
|
|
557
|
+
const text =
|
|
558
|
+
typeof request.text === 'string'
|
|
559
|
+
? request.text
|
|
560
|
+
: Array.isArray(request.value) && request.value.every((part) => typeof part === 'string')
|
|
561
|
+
? request.value.join('')
|
|
562
|
+
: undefined;
|
|
563
|
+
if (text === undefined) {
|
|
564
|
+
throw invalidArgument('Send Keys requires a string "text" or string-array "value".');
|
|
565
|
+
}
|
|
566
|
+
await sessions.runInputCommand(() =>
|
|
567
|
+
hostCommand(session, `Sending keys to element "${record.id}"`, (signal) =>
|
|
568
|
+
session.target.host.sendKeys(record.nativeId, text, signal),
|
|
569
|
+
),
|
|
570
|
+
);
|
|
571
|
+
return null;
|
|
572
|
+
}
|
|
573
|
+
if (method === 'GET' && operation[0] === 'screenshot') {
|
|
574
|
+
const image = await hostCommand(session, `Capturing element "${record.id}"`, (signal) =>
|
|
575
|
+
session.target.host.captureElement(record.nativeId, signal),
|
|
576
|
+
);
|
|
577
|
+
return Buffer.from(image.data).toString('base64');
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
throw new WebDriverError('unknown command', `Unknown element command "${operation.join('/')}".`);
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
async function findElements(
|
|
584
|
+
session: DesktopSession,
|
|
585
|
+
sessions: SessionManager,
|
|
586
|
+
body: unknown,
|
|
587
|
+
single: boolean,
|
|
588
|
+
rootElement?: ElementRecord,
|
|
589
|
+
): Promise<WebDriverElement | WebDriverElement[]> {
|
|
590
|
+
const selector = parseSelector(body);
|
|
591
|
+
const deadline = Date.now() + session.timeouts.implicit;
|
|
592
|
+
let matches: NativeElementSnapshot[] = [];
|
|
593
|
+
do {
|
|
594
|
+
matches = await hostCommand(session, `Finding elements by ${selector.strategy}`, (signal) =>
|
|
595
|
+
session.target.host.find({ elementId: rootElement?.nativeId, windowId: session.currentWindowId }, selector, signal),
|
|
596
|
+
);
|
|
597
|
+
if (matches.length > 0 || Date.now() >= deadline) {
|
|
598
|
+
break;
|
|
599
|
+
}
|
|
600
|
+
await delay(25);
|
|
601
|
+
} while (Date.now() <= deadline);
|
|
602
|
+
|
|
603
|
+
if (single) {
|
|
604
|
+
const match = matches[0];
|
|
605
|
+
if (!match) {
|
|
606
|
+
throw new WebDriverError('no such element', `No element matched ${selector.strategy} "${selector.value}".`);
|
|
607
|
+
}
|
|
608
|
+
return toElementReference(sessions.registerElement(session, match));
|
|
609
|
+
}
|
|
610
|
+
return matches.map((match) => toElementReference(sessions.registerElement(session, match)));
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
function parseSelector(body: unknown): NativeSelector {
|
|
614
|
+
const request = requireObject(body, 'element lookup');
|
|
615
|
+
const strategy = request.using;
|
|
616
|
+
const value = request.value;
|
|
617
|
+
if (
|
|
618
|
+
strategy !== '-furn:text' &&
|
|
619
|
+
strategy !== 'accessibility id' &&
|
|
620
|
+
strategy !== 'tag name' &&
|
|
621
|
+
strategy !== 'link text' &&
|
|
622
|
+
strategy !== 'partial link text'
|
|
623
|
+
) {
|
|
624
|
+
throw new WebDriverError('invalid selector', `Locator strategy "${String(strategy)}" is not supported.`);
|
|
625
|
+
}
|
|
626
|
+
if (typeof value !== 'string') {
|
|
627
|
+
throw invalidArgument('Element locator "value" must be a string.');
|
|
628
|
+
}
|
|
629
|
+
return { strategy, value };
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
function toElementReference(record: ElementRecord): WebDriverElement {
|
|
633
|
+
return { [webElementIdentifier]: record.id };
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
function requireSupported<T>(value: { supported: true; value: T } | { supported: false; reason: string }, name: string): T {
|
|
637
|
+
if (!value.supported) {
|
|
638
|
+
const reason = 'reason' in value ? value.reason : 'not reported by the host';
|
|
639
|
+
throw new WebDriverError('unsupported operation', `Element property "${name}" is unavailable: ${reason}`);
|
|
640
|
+
}
|
|
641
|
+
return value.value;
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
function getElementValue(snapshot: NativeElementSnapshot, name: string): unknown {
|
|
645
|
+
switch (name) {
|
|
646
|
+
case 'testID':
|
|
647
|
+
case 'automationId':
|
|
648
|
+
return snapshot.automationId ?? null;
|
|
649
|
+
case 'name':
|
|
650
|
+
case 'label':
|
|
651
|
+
return snapshot.name ?? null;
|
|
652
|
+
case 'role':
|
|
653
|
+
return snapshot.role;
|
|
654
|
+
case 'value':
|
|
655
|
+
return snapshot.value ?? null;
|
|
656
|
+
case 'focused':
|
|
657
|
+
return requireSupported(snapshot.focused, 'focused');
|
|
658
|
+
case 'enabled':
|
|
659
|
+
return requireSupported(snapshot.enabled, 'enabled');
|
|
660
|
+
case 'checked':
|
|
661
|
+
return requireSupported(snapshot.checked, 'checked');
|
|
662
|
+
case 'expanded':
|
|
663
|
+
return requireSupported(snapshot.expanded, 'expanded');
|
|
664
|
+
case 'selected':
|
|
665
|
+
return requireSupported(snapshot.selected, 'selected');
|
|
666
|
+
default:
|
|
667
|
+
return null;
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
function validatePartialRect(value: Record<string, unknown>): Partial<Rect> {
|
|
672
|
+
const result: Partial<Rect> = {};
|
|
673
|
+
for (const name of ['x', 'y', 'width', 'height'] as const) {
|
|
674
|
+
if (value[name] !== undefined) {
|
|
675
|
+
if (typeof value[name] !== 'number' || !Number.isFinite(value[name])) {
|
|
676
|
+
throw invalidArgument(`Window rectangle "${name}" must be a finite number.`);
|
|
677
|
+
}
|
|
678
|
+
if ((name === 'width' || name === 'height') && (value[name] as number) < 0) {
|
|
679
|
+
throw invalidArgument(`Window rectangle "${name}" must not be negative.`);
|
|
680
|
+
}
|
|
681
|
+
result[name] = value[name] as number;
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
return result;
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
function ensureCurrentWindow(session: DesktopSession): void {
|
|
688
|
+
if (!session.currentWindowId || !session.windows.some(({ id }) => id === session.currentWindowId)) {
|
|
689
|
+
throw new WebDriverError('no such window', 'The session does not have a current window.');
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
function hostCommand<T>(session: DesktopSession, description: string, operation: (signal: AbortSignal) => Promise<T>): Promise<T> {
|
|
694
|
+
return withCommandTimeout(operation, session.desktopTimeouts.nativeCommand, description);
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
function requireObject(value: unknown, name: string): Record<string, unknown> {
|
|
698
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
699
|
+
throw invalidArgument(`${name} must be an object.`);
|
|
700
|
+
}
|
|
701
|
+
return value as Record<string, unknown>;
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
function isUnsupportedBrowserCommand(command: readonly string[]): boolean {
|
|
705
|
+
return ['url', 'back', 'forward', 'refresh', 'cookie', 'frame', 'alert', 'execute', 'print'].includes(command[0]);
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
async function readJsonBody(request: IncomingMessage, maxBytes: number): Promise<unknown> {
|
|
709
|
+
const chunks: Buffer[] = [];
|
|
710
|
+
let size = 0;
|
|
711
|
+
for await (const chunk of request) {
|
|
712
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
713
|
+
size += buffer.length;
|
|
714
|
+
if (size > maxBytes) {
|
|
715
|
+
throw invalidArgument(`Request body exceeds the ${maxBytes}-byte limit.`);
|
|
716
|
+
}
|
|
717
|
+
chunks.push(buffer);
|
|
718
|
+
}
|
|
719
|
+
if (chunks.length === 0) {
|
|
720
|
+
return {};
|
|
721
|
+
}
|
|
722
|
+
try {
|
|
723
|
+
return JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
|
724
|
+
} catch {
|
|
725
|
+
throw invalidArgument('Request body must contain valid JSON.');
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
function writeJson(response: ServerResponse, status: number, body: WebDriverResponse): void {
|
|
730
|
+
const json = JSON.stringify(body);
|
|
731
|
+
response.writeHead(status, {
|
|
732
|
+
'Cache-Control': 'no-cache',
|
|
733
|
+
'Content-Length': Buffer.byteLength(json),
|
|
734
|
+
'Content-Type': 'application/json; charset=utf-8',
|
|
735
|
+
});
|
|
736
|
+
response.end(json);
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
function isLoopbackHost(host: string): boolean {
|
|
740
|
+
return host === '127.0.0.1' || host === '::1' || host === 'localhost';
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
function formatHost(host: string): string {
|
|
744
|
+
return host.includes(':') ? `[${host}]` : host;
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
function listen(server: Server, host: string, port: number): Promise<number> {
|
|
748
|
+
return new Promise((resolve, reject) => {
|
|
749
|
+
const onError = (error: Error) => reject(error);
|
|
750
|
+
server.once('error', onError);
|
|
751
|
+
server.listen(port, host, () => {
|
|
752
|
+
server.off('error', onError);
|
|
753
|
+
const address = server.address();
|
|
754
|
+
if (!address || typeof address === 'string') {
|
|
755
|
+
reject(new Error('Desktop Driver did not receive a TCP address.'));
|
|
756
|
+
return;
|
|
757
|
+
}
|
|
758
|
+
resolve(address.port);
|
|
759
|
+
});
|
|
760
|
+
});
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
function closeServer(server: Server): Promise<void> {
|
|
764
|
+
return new Promise((resolve, reject) => {
|
|
765
|
+
server.close((error) => (error ? reject(error) : resolve()));
|
|
766
|
+
});
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
function delay(milliseconds: number): Promise<void> {
|
|
770
|
+
return new Promise((resolve) => setTimeout(resolve, milliseconds));
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
function remainingTime(deadline: number): number {
|
|
774
|
+
return Math.max(1, deadline - Date.now());
|
|
775
|
+
}
|