@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.
Files changed (235) hide show
  1. package/.cache/tsconfig.tsbuildinfo +1 -0
  2. package/AGENTS.md +68 -0
  3. package/CHANGELOG.md +16 -0
  4. package/PLAN.md +1394 -0
  5. package/README.md +223 -0
  6. package/SPEC.md +105 -0
  7. package/config/cli.cjs +8 -0
  8. package/jest.config.cjs +9 -0
  9. package/lib/agent/DesktopAgent.d.ts +56 -0
  10. package/lib/agent/DesktopAgent.d.ts.map +1 -0
  11. package/lib/agent/DesktopAgent.js +118 -0
  12. package/lib/agent/DesktopAgent.js.map +1 -0
  13. package/lib/agent/DesktopAgent.test.d.ts +2 -0
  14. package/lib/agent/DesktopAgent.test.d.ts.map +1 -0
  15. package/lib/agent/DesktopAgent.test.js +97 -0
  16. package/lib/agent/DesktopAgent.test.js.map +1 -0
  17. package/lib/agent/agent.contract.cjs +66 -0
  18. package/lib/agent/agent.contract.cjs.map +1 -0
  19. package/lib/agent/agent.contract.d.cts +2 -0
  20. package/lib/agent/agent.contract.d.cts.map +1 -0
  21. package/lib/agent/index.d.ts +3 -0
  22. package/lib/agent/index.d.ts.map +1 -0
  23. package/lib/agent/index.js +2 -0
  24. package/lib/agent/index.js.map +1 -0
  25. package/lib/artifacts/ArtifactManager.d.ts +13 -0
  26. package/lib/artifacts/ArtifactManager.d.ts.map +1 -0
  27. package/lib/artifacts/ArtifactManager.js +88 -0
  28. package/lib/artifacts/ArtifactManager.js.map +1 -0
  29. package/lib/artifacts/ArtifactManager.test.d.ts +2 -0
  30. package/lib/artifacts/ArtifactManager.test.d.ts.map +1 -0
  31. package/lib/artifacts/ArtifactManager.test.js +19 -0
  32. package/lib/artifacts/ArtifactManager.test.js.map +1 -0
  33. package/lib/artifacts/index.d.ts +2 -0
  34. package/lib/artifacts/index.d.ts.map +1 -0
  35. package/lib/artifacts/index.js +2 -0
  36. package/lib/artifacts/index.js.map +1 -0
  37. package/lib/authoring/index.d.ts +4 -0
  38. package/lib/authoring/index.d.ts.map +1 -0
  39. package/lib/authoring/index.js +2 -0
  40. package/lib/authoring/index.js.map +1 -0
  41. package/lib/authoring/results.d.ts +43 -0
  42. package/lib/authoring/results.d.ts.map +1 -0
  43. package/lib/authoring/results.js +2 -0
  44. package/lib/authoring/results.js.map +1 -0
  45. package/lib/authoring/storyTests.d.ts +81 -0
  46. package/lib/authoring/storyTests.d.ts.map +1 -0
  47. package/lib/authoring/storyTests.js +272 -0
  48. package/lib/authoring/storyTests.js.map +1 -0
  49. package/lib/authoring/storyTests.test.d.ts +2 -0
  50. package/lib/authoring/storyTests.test.d.ts.map +1 -0
  51. package/lib/authoring/storyTests.test.js +46 -0
  52. package/lib/authoring/storyTests.test.js.map +1 -0
  53. package/lib/cli/DesktopDriverCli.test.d.ts +2 -0
  54. package/lib/cli/DesktopDriverCli.test.d.ts.map +1 -0
  55. package/lib/cli/DesktopDriverCli.test.js +141 -0
  56. package/lib/cli/DesktopDriverCli.test.js.map +1 -0
  57. package/lib/cli/createDesktopDriverCommand.d.ts +8 -0
  58. package/lib/cli/createDesktopDriverCommand.d.ts.map +1 -0
  59. package/lib/cli/createDesktopDriverCommand.js +242 -0
  60. package/lib/cli/createDesktopDriverCommand.js.map +1 -0
  61. package/lib/cli/index.d.ts +3 -0
  62. package/lib/cli/index.d.ts.map +1 -0
  63. package/lib/cli/index.js +2 -0
  64. package/lib/cli/index.js.map +1 -0
  65. package/lib/client/DesktopDriverClient.d.ts +60 -0
  66. package/lib/client/DesktopDriverClient.d.ts.map +1 -0
  67. package/lib/client/DesktopDriverClient.js +155 -0
  68. package/lib/client/DesktopDriverClient.js.map +1 -0
  69. package/lib/client/index.d.ts +3 -0
  70. package/lib/client/index.d.ts.map +1 -0
  71. package/lib/client/index.js +2 -0
  72. package/lib/client/index.js.map +1 -0
  73. package/lib/host/types.d.ts +153 -0
  74. package/lib/host/types.d.ts.map +1 -0
  75. package/lib/host/types.js +2 -0
  76. package/lib/host/types.js.map +1 -0
  77. package/lib/hosts/fake/FakeDesktopHost.d.ts +72 -0
  78. package/lib/hosts/fake/FakeDesktopHost.d.ts.map +1 -0
  79. package/lib/hosts/fake/FakeDesktopHost.js +452 -0
  80. package/lib/hosts/fake/FakeDesktopHost.js.map +1 -0
  81. package/lib/index.d.ts +23 -0
  82. package/lib/index.d.ts.map +1 -0
  83. package/lib/index.js +11 -0
  84. package/lib/index.js.map +1 -0
  85. package/lib/protocol/actions.d.ts +11 -0
  86. package/lib/protocol/actions.d.ts.map +1 -0
  87. package/lib/protocol/actions.js +137 -0
  88. package/lib/protocol/actions.js.map +1 -0
  89. package/lib/protocol/capabilities.d.ts +11 -0
  90. package/lib/protocol/capabilities.d.ts.map +1 -0
  91. package/lib/protocol/capabilities.js +103 -0
  92. package/lib/protocol/capabilities.js.map +1 -0
  93. package/lib/protocol/constants.d.ts +2 -0
  94. package/lib/protocol/constants.d.ts.map +1 -0
  95. package/lib/protocol/constants.js +2 -0
  96. package/lib/protocol/constants.js.map +1 -0
  97. package/lib/protocol/errors.d.ts +37 -0
  98. package/lib/protocol/errors.d.ts.map +1 -0
  99. package/lib/protocol/errors.js +66 -0
  100. package/lib/protocol/errors.js.map +1 -0
  101. package/lib/protocol/timeouts.d.ts +2 -0
  102. package/lib/protocol/timeouts.d.ts.map +1 -0
  103. package/lib/protocol/timeouts.js +23 -0
  104. package/lib/protocol/timeouts.js.map +1 -0
  105. package/lib/protocol/types.d.ts +46 -0
  106. package/lib/protocol/types.d.ts.map +1 -0
  107. package/lib/protocol/types.js +2 -0
  108. package/lib/protocol/types.js.map +1 -0
  109. package/lib/runner/StoryTestRunner.d.ts +38 -0
  110. package/lib/runner/StoryTestRunner.d.ts.map +1 -0
  111. package/lib/runner/StoryTestRunner.js +497 -0
  112. package/lib/runner/StoryTestRunner.js.map +1 -0
  113. package/lib/runner/StoryTestRunner.test.d.ts +2 -0
  114. package/lib/runner/StoryTestRunner.test.d.ts.map +1 -0
  115. package/lib/runner/StoryTestRunner.test.js +349 -0
  116. package/lib/runner/StoryTestRunner.test.js.map +1 -0
  117. package/lib/runner/index.d.ts +3 -0
  118. package/lib/runner/index.d.ts.map +1 -0
  119. package/lib/runner/index.js +2 -0
  120. package/lib/runner/index.js.map +1 -0
  121. package/lib/server/SessionManager.d.ts +52 -0
  122. package/lib/server/SessionManager.d.ts.map +1 -0
  123. package/lib/server/SessionManager.js +208 -0
  124. package/lib/server/SessionManager.js.map +1 -0
  125. package/lib/server/TargetRegistry.d.ts +9 -0
  126. package/lib/server/TargetRegistry.d.ts.map +1 -0
  127. package/lib/server/TargetRegistry.js +24 -0
  128. package/lib/server/TargetRegistry.js.map +1 -0
  129. package/lib/server/createDesktopDriverServer.d.ts +19 -0
  130. package/lib/server/createDesktopDriverServer.d.ts.map +1 -0
  131. package/lib/server/createDesktopDriverServer.js +609 -0
  132. package/lib/server/createDesktopDriverServer.js.map +1 -0
  133. package/lib/server/desktopDriver.test.d.ts +2 -0
  134. package/lib/server/desktopDriver.test.d.ts.map +1 -0
  135. package/lib/server/desktopDriver.test.js +389 -0
  136. package/lib/server/desktopDriver.test.js.map +1 -0
  137. package/lib/server/index.d.ts +6 -0
  138. package/lib/server/index.d.ts.map +1 -0
  139. package/lib/server/index.js +4 -0
  140. package/lib/server/index.js.map +1 -0
  141. package/lib/server/webdriverio.contract.cjs +35 -0
  142. package/lib/server/webdriverio.contract.cjs.map +1 -0
  143. package/lib/server/webdriverio.contract.d.cts +2 -0
  144. package/lib/server/webdriverio.contract.d.cts.map +1 -0
  145. package/lib/server/webdriverio.test.d.ts +2 -0
  146. package/lib/server/webdriverio.test.d.ts.map +1 -0
  147. package/lib/server/webdriverio.test.js +40 -0
  148. package/lib/server/webdriverio.test.js.map +1 -0
  149. package/lib/storybook.d.ts +36 -0
  150. package/lib/storybook.d.ts.map +1 -0
  151. package/lib/storybook.js +2 -0
  152. package/lib/storybook.js.map +1 -0
  153. package/lib/testing/FakeStoryOrchestrator.d.ts +21 -0
  154. package/lib/testing/FakeStoryOrchestrator.d.ts.map +1 -0
  155. package/lib/testing/FakeStoryOrchestrator.js +44 -0
  156. package/lib/testing/FakeStoryOrchestrator.js.map +1 -0
  157. package/lib/testing/fakeStoryElements.d.ts +4 -0
  158. package/lib/testing/fakeStoryElements.d.ts.map +1 -0
  159. package/lib/testing/fakeStoryElements.js +143 -0
  160. package/lib/testing/fakeStoryElements.js.map +1 -0
  161. package/lib/testing/fakeStoryElements.test.d.ts +2 -0
  162. package/lib/testing/fakeStoryElements.test.d.ts.map +1 -0
  163. package/lib/testing/fakeStoryElements.test.js +38 -0
  164. package/lib/testing/fakeStoryElements.test.js.map +1 -0
  165. package/lib/testing/index.d.ts +7 -0
  166. package/lib/testing/index.d.ts.map +1 -0
  167. package/lib/testing/index.js +5 -0
  168. package/lib/testing/index.js.map +1 -0
  169. package/lib/testing/protocolHarness.d.ts +17 -0
  170. package/lib/testing/protocolHarness.d.ts.map +1 -0
  171. package/lib/testing/protocolHarness.js +46 -0
  172. package/lib/testing/protocolHarness.js.map +1 -0
  173. package/lib/wdio/DesktopWebdriver.d.ts +47 -0
  174. package/lib/wdio/DesktopWebdriver.d.ts.map +1 -0
  175. package/lib/wdio/DesktopWebdriver.js +76 -0
  176. package/lib/wdio/DesktopWebdriver.js.map +1 -0
  177. package/lib/wdio/DesktopWebdriver.test.d.ts +2 -0
  178. package/lib/wdio/DesktopWebdriver.test.d.ts.map +1 -0
  179. package/lib/wdio/DesktopWebdriver.test.js +74 -0
  180. package/lib/wdio/DesktopWebdriver.test.js.map +1 -0
  181. package/lib/wdio/index.d.ts +3 -0
  182. package/lib/wdio/index.d.ts.map +1 -0
  183. package/lib/wdio/index.js +2 -0
  184. package/lib/wdio/index.js.map +1 -0
  185. package/lib/wdio/wdioRunner.contract.cjs +64 -0
  186. package/lib/wdio/wdioRunner.contract.cjs.map +1 -0
  187. package/lib/wdio/wdioRunner.contract.d.cts +2 -0
  188. package/lib/wdio/wdioRunner.contract.d.cts.map +1 -0
  189. package/package.json +96 -0
  190. package/src/agent/DesktopAgent.test.ts +102 -0
  191. package/src/agent/DesktopAgent.ts +170 -0
  192. package/src/agent/agent.contract.cjs +32 -0
  193. package/src/agent/index.ts +8 -0
  194. package/src/artifacts/ArtifactManager.test.ts +20 -0
  195. package/src/artifacts/ArtifactManager.ts +105 -0
  196. package/src/artifacts/index.ts +1 -0
  197. package/src/authoring/index.ts +26 -0
  198. package/src/authoring/results.ts +47 -0
  199. package/src/authoring/storyTests.test.ts +58 -0
  200. package/src/authoring/storyTests.ts +345 -0
  201. package/src/cli/DesktopDriverCli.test.ts +150 -0
  202. package/src/cli/createDesktopDriverCommand.ts +290 -0
  203. package/src/cli/index.ts +2 -0
  204. package/src/client/DesktopDriverClient.ts +218 -0
  205. package/src/client/index.ts +2 -0
  206. package/src/host/types.ts +158 -0
  207. package/src/hosts/fake/FakeDesktopHost.ts +558 -0
  208. package/src/index.ts +101 -0
  209. package/src/protocol/actions.ts +163 -0
  210. package/src/protocol/capabilities.ts +129 -0
  211. package/src/protocol/constants.ts +1 -0
  212. package/src/protocol/errors.ts +76 -0
  213. package/src/protocol/timeouts.ts +26 -0
  214. package/src/protocol/types.ts +55 -0
  215. package/src/runner/StoryTestRunner.test.ts +362 -0
  216. package/src/runner/StoryTestRunner.ts +624 -0
  217. package/src/runner/index.ts +9 -0
  218. package/src/server/SessionManager.ts +302 -0
  219. package/src/server/TargetRegistry.ts +29 -0
  220. package/src/server/createDesktopDriverServer.ts +775 -0
  221. package/src/server/desktopDriver.test.ts +421 -0
  222. package/src/server/index.ts +5 -0
  223. package/src/server/webdriverio.contract.cjs +38 -0
  224. package/src/server/webdriverio.test.ts +41 -0
  225. package/src/storybook.ts +40 -0
  226. package/src/testing/FakeStoryOrchestrator.ts +55 -0
  227. package/src/testing/fakeStoryElements.test.ts +40 -0
  228. package/src/testing/fakeStoryElements.ts +167 -0
  229. package/src/testing/index.ts +6 -0
  230. package/src/testing/protocolHarness.ts +66 -0
  231. package/src/wdio/DesktopWebdriver.test.ts +76 -0
  232. package/src/wdio/DesktopWebdriver.ts +130 -0
  233. package/src/wdio/index.ts +2 -0
  234. package/src/wdio/wdioRunner.contract.cjs +30 -0
  235. package/tsconfig.json +15 -0
@@ -0,0 +1,624 @@
1
+ import { randomUUID } from 'node:crypto';
2
+
3
+ import type {
4
+ DesktopArtifact,
5
+ DesktopStoryRunResult,
6
+ DesktopStoryStepResult,
7
+ DesktopStoryTestResult,
8
+ DesktopTestStatus,
9
+ } from '../authoring/results.js';
10
+ import type {
11
+ DesktopStoryCapability,
12
+ DesktopStoryExpectation,
13
+ DesktopStorySelector,
14
+ DesktopStoryStep,
15
+ DesktopStoryTest,
16
+ } from '../authoring/storyTests.js';
17
+ import type { ArtifactManager } from '../artifacts/ArtifactManager.js';
18
+ import type { DesktopElementClient, DesktopSessionClient } from '../client/DesktopDriverClient.js';
19
+ import { WebDriverError } from '../protocol/errors.js';
20
+ import { webElementIdentifier } from '../protocol/constants.js';
21
+ import type { DesktopEndpoint, DesktopPlatformName, WebDriverActionSequence } from '../protocol/types.js';
22
+ import type { DesktopStoryManifest, DesktopStoryManifestEntry } from '../storybook.js';
23
+
24
+ export type DesktopStoryTestSelection = {
25
+ shardCount?: number;
26
+ shardIndex?: number;
27
+ story?: string;
28
+ tag?: string;
29
+ test?: string;
30
+ };
31
+
32
+ export type DesktopStoryTestRunnerOptions = {
33
+ artifacts?: ArtifactManager;
34
+ endpoint: DesktopEndpoint;
35
+ manifest: DesktopStoryManifest;
36
+ platformName: DesktopPlatformName;
37
+ runId?: string;
38
+ selection?: DesktopStoryTestSelection;
39
+ session: DesktopSessionClient;
40
+ signal?: AbortSignal;
41
+ targetId: string;
42
+ };
43
+
44
+ type SelectedTest = {
45
+ entry: DesktopStoryManifestEntry;
46
+ test: DesktopStoryTest;
47
+ };
48
+
49
+ export async function runDesktopStoryTests({
50
+ artifacts,
51
+ endpoint,
52
+ manifest,
53
+ platformName,
54
+ runId = randomUUID(),
55
+ selection,
56
+ session,
57
+ signal,
58
+ targetId,
59
+ }: DesktopStoryTestRunnerOptions): Promise<DesktopStoryRunResult> {
60
+ const startedAt = new Date();
61
+ const selected = selectDesktopStoryTests(manifest, endpoint, selection);
62
+ if (selected.length === 0) {
63
+ throw new Error('No desktop story tests matched the requested selection.');
64
+ }
65
+ const results: DesktopStoryTestResult[] = [];
66
+ artifacts?.writeMetadata('host', {
67
+ capabilities: session.capabilities,
68
+ endpoint,
69
+ platformName,
70
+ targetId,
71
+ });
72
+
73
+ for (const [index, item] of selected.entries()) {
74
+ if (signal?.aborted) {
75
+ results.push(cancelledResult(item, 'Run cancelled before the test started.'));
76
+ continue;
77
+ }
78
+ results.push(
79
+ await runTest({
80
+ artifacts,
81
+ item,
82
+ runId: `${runId}-${index + 1}`,
83
+ session,
84
+ signal,
85
+ }),
86
+ );
87
+ }
88
+
89
+ const result: DesktopStoryRunResult = {
90
+ endpoint,
91
+ finishedAt: new Date().toISOString(),
92
+ manifest: {
93
+ platform: manifest.platformManifestDigest,
94
+ portable: manifest.portablePlanDigest,
95
+ },
96
+ platformName,
97
+ runId,
98
+ schemaVersion: 1,
99
+ startedAt: startedAt.toISOString(),
100
+ status: results.every(({ status }) => status === 'passed' || status === 'skipped') ? 'passed' : 'failed',
101
+ targetId,
102
+ tests: results,
103
+ };
104
+ artifacts?.writeRunResult(result);
105
+ return result;
106
+ }
107
+
108
+ export function selectDesktopStoryTests(
109
+ manifest: DesktopStoryManifest,
110
+ endpoint: DesktopEndpoint,
111
+ selection: DesktopStoryTestSelection = {},
112
+ ): SelectedTest[] {
113
+ validateShard(selection);
114
+ const selected = manifest.entries
115
+ .filter((entry) => !selection.story || matchesPattern(entry.id, selection.story))
116
+ .filter((entry) => !selection.tag || entry.tags.includes(selection.tag))
117
+ .flatMap((entry) =>
118
+ (entry.tests?.tests ?? [])
119
+ .filter((test) => !test.platforms || test.platforms.includes(endpoint))
120
+ .filter((test) => !selection.test || matchesPattern(test.id, selection.test))
121
+ .map((test) => ({ entry, test })),
122
+ )
123
+ .sort((left, right) => `${left.entry.id}/${left.test.id}`.localeCompare(`${right.entry.id}/${right.test.id}`));
124
+
125
+ const shardCount = selection.shardCount;
126
+ const shardIndex = selection.shardIndex;
127
+ if (shardCount === undefined || shardIndex === undefined) {
128
+ return selected;
129
+ }
130
+ return selected.filter((_item, index) => index % shardCount === shardIndex);
131
+ }
132
+
133
+ async function runTest({
134
+ artifacts,
135
+ item,
136
+ runId,
137
+ session,
138
+ signal,
139
+ }: {
140
+ artifacts?: ArtifactManager;
141
+ item: SelectedTest;
142
+ runId: string;
143
+ session: DesktopSessionClient;
144
+ signal?: AbortSignal;
145
+ }): Promise<DesktopStoryTestResult> {
146
+ const started = Date.now();
147
+ const title = item.test.title ?? item.test.id;
148
+ const missing = missingCapabilities(session.capabilities, item.test.requires ?? []);
149
+ if (missing.length > 0) {
150
+ return {
151
+ artifacts: [],
152
+ durationMs: Date.now() - started,
153
+ skipReason: `Unsupported capabilities: ${missing.join(', ')}`,
154
+ status: 'skipped',
155
+ steps: [],
156
+ storyId: item.entry.id,
157
+ testId: item.test.id,
158
+ title,
159
+ };
160
+ }
161
+
162
+ const steps: DesktopStoryStepResult[] = [];
163
+ const testArtifacts: DesktopArtifact[] = [];
164
+ let status: DesktopTestStatus = 'passed';
165
+ let errorMessage: string | undefined;
166
+ try {
167
+ throwIfAborted(signal);
168
+ await withAbort(session.selectStory(item.entry.id, runId), signal);
169
+ for (const [index, step] of item.test.steps.entries()) {
170
+ throwIfAborted(signal);
171
+ const result = await runStep(session, step, index, artifacts, `${item.entry.id}-${item.test.id}`, signal);
172
+ steps.push(result);
173
+ testArtifacts.push(...result.artifacts);
174
+ if (result.status === 'failed') {
175
+ status = classifyError(result.errorObject);
176
+ errorMessage = result.error;
177
+ break;
178
+ }
179
+ }
180
+ } catch (error) {
181
+ status = classifyError(error);
182
+ errorMessage = error instanceof Error ? error.message : String(error);
183
+ }
184
+ try {
185
+ await session.releaseActions();
186
+ } catch (error) {
187
+ const cleanupError = error instanceof Error ? error.message : String(error);
188
+ if (status === 'passed') {
189
+ status = 'infrastructure-error';
190
+ errorMessage = `Input cleanup failed: ${cleanupError}`;
191
+ } else {
192
+ errorMessage = `${errorMessage ?? 'Test failed.'} Input cleanup failed: ${cleanupError}`;
193
+ }
194
+ }
195
+ if (signal?.aborted && status === 'passed') {
196
+ status = 'cancelled';
197
+ errorMessage = 'Run cancelled during input cleanup.';
198
+ }
199
+
200
+ if (status !== 'passed' && artifacts) {
201
+ const captured = await captureFailureArtifacts(session, artifacts, `${item.entry.id}-${item.test.id}`);
202
+ testArtifacts.push(...captured.artifacts);
203
+ if (captured.error) {
204
+ errorMessage = `${errorMessage ?? 'Test failed.'} Evidence capture failed: ${captured.error}`;
205
+ }
206
+ }
207
+
208
+ return {
209
+ artifacts: testArtifacts,
210
+ durationMs: Date.now() - started,
211
+ ...(errorMessage ? { error: errorMessage } : {}),
212
+ status,
213
+ steps,
214
+ storyId: item.entry.id,
215
+ testId: item.test.id,
216
+ title,
217
+ };
218
+ }
219
+
220
+ type InternalStepResult = DesktopStoryStepResult & {
221
+ errorObject?: unknown;
222
+ };
223
+
224
+ async function runStep(
225
+ session: DesktopSessionClient,
226
+ step: DesktopStoryStep,
227
+ index: number,
228
+ artifacts: ArtifactManager | undefined,
229
+ testDirectory: string,
230
+ signal?: AbortSignal,
231
+ ): Promise<InternalStepResult> {
232
+ const started = Date.now();
233
+ const stepArtifacts: DesktopArtifact[] = [];
234
+ try {
235
+ throwIfAborted(signal);
236
+ if ('expect' in step) {
237
+ await withAbort(assertDesktopExpectation(session, step.expect), signal);
238
+ } else {
239
+ await withAbort(performAction(session, step, artifacts, testDirectory, stepArtifacts, signal), signal);
240
+ }
241
+ throwIfAborted(signal);
242
+ return { artifacts: stepArtifacts, durationMs: Date.now() - started, index, status: 'passed' };
243
+ } catch (error) {
244
+ return {
245
+ artifacts: stepArtifacts,
246
+ durationMs: Date.now() - started,
247
+ error: error instanceof Error ? error.message : String(error),
248
+ errorObject: error,
249
+ index,
250
+ status: 'failed',
251
+ };
252
+ }
253
+ }
254
+
255
+ async function performAction(
256
+ session: DesktopSessionClient,
257
+ step: Exclude<DesktopStoryStep, { expect: DesktopStoryExpectation }>,
258
+ artifacts: ArtifactManager | undefined,
259
+ testDirectory: string,
260
+ stepArtifacts: DesktopArtifact[],
261
+ signal?: AbortSignal,
262
+ ): Promise<void> {
263
+ switch (step.action) {
264
+ case 'actions':
265
+ await session.performActions(step.sequences);
266
+ return;
267
+ case 'clear':
268
+ await (await findDesktopElement(session, step.target)).clear();
269
+ return;
270
+ case 'click':
271
+ await (await findDesktopElement(session, step.target)).click();
272
+ return;
273
+ case 'doubleClick': {
274
+ const element = await findDesktopElement(session, step.target);
275
+ await element.click();
276
+ await element.click();
277
+ return;
278
+ }
279
+ case 'keys':
280
+ await session.performActions(keySequences(step.value));
281
+ return;
282
+ case 'note':
283
+ return;
284
+ case 'screenshot': {
285
+ const image = step.target ? await (await findDesktopElement(session, step.target)).takeScreenshot() : await session.takeScreenshot();
286
+ if (artifacts) {
287
+ stepArtifacts.push(artifacts.writeScreenshot(testDirectory, step.name, image));
288
+ }
289
+ return;
290
+ }
291
+ case 'scroll': {
292
+ const origin = step.target ? toElementReference(await findDesktopElement(session, step.target)) : 'viewport';
293
+ await session.performActions([
294
+ {
295
+ id: 'desktop-story-wheel',
296
+ type: 'wheel',
297
+ actions: [
298
+ {
299
+ type: 'scroll',
300
+ deltaX: step.deltaX ?? 0,
301
+ deltaY: step.deltaY,
302
+ duration: 0,
303
+ origin,
304
+ x: 0,
305
+ y: 0,
306
+ },
307
+ ],
308
+ },
309
+ ]);
310
+ return;
311
+ }
312
+ case 'setArgs':
313
+ await session.updateStoryArgs(requireCurrentStory(await session.getCurrentStory()), step.args);
314
+ return;
315
+ case 'source': {
316
+ const source = await session.getPageSource();
317
+ if (artifacts) {
318
+ stepArtifacts.push(artifacts.writeSource(testDirectory, step.name, source));
319
+ }
320
+ return;
321
+ }
322
+ case 'type':
323
+ await (await findDesktopElement(session, step.target)).sendKeys(step.text);
324
+ return;
325
+ case 'wait':
326
+ await waitFor(session, step, signal);
327
+ return;
328
+ }
329
+ }
330
+
331
+ export async function assertDesktopExpectation(session: DesktopSessionClient, expectation: DesktopStoryExpectation): Promise<void> {
332
+ const elements = await findDesktopElements(session, expectation.target);
333
+ if (expectation.state === 'count') {
334
+ assertEqual(elements.length, expectation.value, 'count');
335
+ return;
336
+ }
337
+ if (expectation.state === 'exists') {
338
+ assertEqual(elements.length > 0, expectation.value ?? true, 'exists');
339
+ return;
340
+ }
341
+ const element = elements[0];
342
+ if (!element) {
343
+ throw new DesktopAssertionError('Expected element does not exist.');
344
+ }
345
+ let actual: unknown;
346
+ switch (expectation.state) {
347
+ case 'accessibleName':
348
+ actual = await element.getAttribute('name');
349
+ break;
350
+ case 'checked':
351
+ actual = await element.getProperty('checked');
352
+ break;
353
+ case 'displayed':
354
+ actual = await element.isDisplayed();
355
+ break;
356
+ case 'enabled':
357
+ actual = await element.isEnabled();
358
+ break;
359
+ case 'expanded':
360
+ actual = await element.getProperty('expanded');
361
+ break;
362
+ case 'focused':
363
+ actual = await element.getProperty('focused');
364
+ break;
365
+ case 'role':
366
+ actual = await element.getTagName();
367
+ break;
368
+ case 'selected':
369
+ actual = await element.isSelected();
370
+ break;
371
+ case 'text':
372
+ actual = await element.getText();
373
+ break;
374
+ case 'value':
375
+ actual = await element.getProperty('value');
376
+ break;
377
+ default:
378
+ throw new DesktopAssertionError(`Unsupported expectation "${expectation.state}".`);
379
+ }
380
+ assertEqual(actual, expectation.value ?? true, expectation.state);
381
+ }
382
+
383
+ async function waitFor(
384
+ session: DesktopSessionClient,
385
+ step: Extract<DesktopStoryStep, { action: 'wait' }>,
386
+ signal?: AbortSignal,
387
+ ): Promise<void> {
388
+ const deadline = Date.now() + (step.timeoutMs ?? 5000);
389
+ let lastError: unknown;
390
+ do {
391
+ throwIfAborted(signal);
392
+ try {
393
+ if (step.until) {
394
+ await assertDesktopExpectation(session, step.until);
395
+ } else if (step.target) {
396
+ await findDesktopElement(session, step.target);
397
+ }
398
+ throwIfAborted(signal);
399
+ return;
400
+ } catch (error) {
401
+ if (error instanceof DesktopRunCancelledError) {
402
+ throw error;
403
+ }
404
+ lastError = error;
405
+ }
406
+ await withAbort(delay(Math.min(50, Math.max(1, deadline - Date.now()))), signal);
407
+ } while (Date.now() < deadline);
408
+ throw new WebDriverError('timeout', `Wait condition was not met: ${(lastError as Error)?.message ?? 'unknown condition'}`);
409
+ }
410
+
411
+ export async function findDesktopElement(session: DesktopSessionClient, selector: DesktopStorySelector): Promise<DesktopElementClient> {
412
+ const elements = await findDesktopElements(session, selector);
413
+ if (!elements[0]) {
414
+ throw new WebDriverError('no such element', `No element matched ${JSON.stringify(selector)}.`);
415
+ }
416
+ return elements[0];
417
+ }
418
+
419
+ export async function findDesktopElements(session: DesktopSessionClient, selector: DesktopStorySelector): Promise<DesktopElementClient[]> {
420
+ if ('testId' in selector) {
421
+ return session.findElements('accessibility id', selector.testId);
422
+ }
423
+ if ('accessibleName' in selector) {
424
+ return session.findElements('link text', selector.accessibleName);
425
+ }
426
+ if ('text' in selector) {
427
+ return session.findElements('-furn:text', selector.text);
428
+ }
429
+ const candidates = await session.findElements('tag name', selector.role);
430
+ if (!selector.name) {
431
+ return candidates;
432
+ }
433
+ const matches: DesktopElementClient[] = [];
434
+ for (const candidate of candidates) {
435
+ if ((await candidate.getAttribute('name')) === selector.name) {
436
+ matches.push(candidate);
437
+ }
438
+ }
439
+ return matches;
440
+ }
441
+
442
+ function classifyError(error: unknown): DesktopTestStatus {
443
+ if (error instanceof DesktopRunCancelledError) {
444
+ return 'cancelled';
445
+ }
446
+ if (error instanceof WebDriverError) {
447
+ if (error.code === 'timeout') {
448
+ return 'timed-out';
449
+ }
450
+ if (error.code === 'invalid session id' || error.code === 'session not created' || error.code === 'unknown error') {
451
+ return 'infrastructure-error';
452
+ }
453
+ }
454
+ return error instanceof DesktopAssertionError ? 'failed' : 'failed';
455
+ }
456
+
457
+ async function captureFailureArtifacts(
458
+ session: DesktopSessionClient,
459
+ artifacts: ArtifactManager,
460
+ testDirectory: string,
461
+ ): Promise<{ artifacts: DesktopArtifact[]; error?: string }> {
462
+ const captured: DesktopArtifact[] = [];
463
+ const errors: string[] = [];
464
+ try {
465
+ captured.push(artifacts.writeScreenshot(testDirectory, 'failure', await session.takeScreenshot()));
466
+ } catch (error) {
467
+ errors.push(error instanceof Error ? error.message : String(error));
468
+ }
469
+ try {
470
+ captured.push(artifacts.writeSource(testDirectory, 'failure-source', await session.getPageSource()));
471
+ } catch (error) {
472
+ errors.push(error instanceof Error ? error.message : String(error));
473
+ }
474
+ try {
475
+ captured.push(artifacts.writeTree(testDirectory, 'failure-tree', await session.getTree()));
476
+ } catch (error) {
477
+ errors.push(error instanceof Error ? error.message : String(error));
478
+ }
479
+ return { artifacts: captured, ...(errors.length > 0 ? { error: errors.join('; ') } : {}) };
480
+ }
481
+
482
+ function missingCapabilities(
483
+ capabilities: Readonly<Record<string, unknown>>,
484
+ required: readonly DesktopStoryCapability[],
485
+ ): DesktopStoryCapability[] {
486
+ const features = capabilities['furn:features'];
487
+ if (!features || typeof features !== 'object') {
488
+ return [...required];
489
+ }
490
+ const values = features as Record<string, unknown>;
491
+ const mappings: Record<DesktopStoryCapability, string> = {
492
+ 'accessibility-click': 'accessibilityClick',
493
+ 'element-screenshot': 'elementScreenshot',
494
+ focus: 'focus',
495
+ keyboard: 'keyboard',
496
+ 'physical-click': 'physicalClick',
497
+ screenshot: 'screenshot',
498
+ wheel: 'wheel',
499
+ };
500
+ return required.filter((capability) => values[mappings[capability]] !== true);
501
+ }
502
+
503
+ function validateShard(selection: DesktopStoryTestSelection): void {
504
+ if (selection.shardCount === undefined && selection.shardIndex === undefined) {
505
+ return;
506
+ }
507
+ const shardCount = selection.shardCount;
508
+ const shardIndex = selection.shardIndex;
509
+ if (
510
+ !Number.isInteger(shardCount) ||
511
+ !Number.isInteger(shardIndex) ||
512
+ shardCount === undefined ||
513
+ shardIndex === undefined ||
514
+ shardCount < 1 ||
515
+ shardIndex < 0 ||
516
+ shardIndex >= shardCount
517
+ ) {
518
+ throw new TypeError('Shard selection requires 0 <= shardIndex < shardCount.');
519
+ }
520
+ }
521
+
522
+ function matchesPattern(value: string, pattern: string): boolean {
523
+ const expression = new RegExp(`^${pattern.split('*').map(escapeRegExp).join('.*')}$`);
524
+ return expression.test(value);
525
+ }
526
+
527
+ function escapeRegExp(value: string): string {
528
+ return value.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&');
529
+ }
530
+
531
+ function keySequences(keys: readonly string[]): WebDriverActionSequence[] {
532
+ return [
533
+ {
534
+ id: 'desktop-story-keyboard',
535
+ type: 'key',
536
+ actions: keys.flatMap((value) => [
537
+ { type: 'keyDown', value },
538
+ { type: 'keyUp', value },
539
+ ]),
540
+ },
541
+ ];
542
+ }
543
+
544
+ function toElementReference(element: DesktopElementClient): { [webElementIdentifier]: string } {
545
+ return { [webElementIdentifier]: element.id };
546
+ }
547
+
548
+ function requireCurrentStory(story: { storyId: string } | null): string {
549
+ if (!story) {
550
+ throw new Error('Storybook did not report a current story.');
551
+ }
552
+ return story.storyId;
553
+ }
554
+
555
+ function assertEqual(actual: unknown, expected: unknown, label: string): void {
556
+ if (!Object.is(actual, expected)) {
557
+ throw new DesktopAssertionError(`Expected ${label} to be ${JSON.stringify(expected)}, received ${JSON.stringify(actual)}.`);
558
+ }
559
+ }
560
+
561
+ function cancelledResult(item: SelectedTest, reason: string): DesktopStoryTestResult {
562
+ return {
563
+ artifacts: [],
564
+ durationMs: 0,
565
+ error: reason,
566
+ status: 'cancelled',
567
+ steps: [],
568
+ storyId: item.entry.id,
569
+ testId: item.test.id,
570
+ title: item.test.title ?? item.test.id,
571
+ };
572
+ }
573
+
574
+ export class DesktopAssertionError extends Error {
575
+ constructor(message: string) {
576
+ super(message);
577
+ this.name = 'DesktopAssertionError';
578
+ }
579
+ }
580
+
581
+ class DesktopRunCancelledError extends Error {
582
+ constructor(message: string) {
583
+ super(message);
584
+ this.name = 'DesktopRunCancelledError';
585
+ }
586
+ }
587
+
588
+ function throwIfAborted(signal: AbortSignal | undefined): void {
589
+ if (signal?.aborted) {
590
+ throw new DesktopRunCancelledError('Run cancelled during the test.');
591
+ }
592
+ }
593
+
594
+ function withAbort<T>(operation: Promise<T>, signal: AbortSignal | undefined): Promise<T> {
595
+ if (!signal) {
596
+ return operation;
597
+ }
598
+ throwIfAborted(signal);
599
+ return new Promise((resolve, reject) => {
600
+ let aborted = false;
601
+ const onAbort = () => {
602
+ aborted = true;
603
+ };
604
+ signal.addEventListener('abort', onAbort, { once: true });
605
+ operation.then(
606
+ (value) => {
607
+ signal.removeEventListener('abort', onAbort);
608
+ if (aborted) {
609
+ reject(new DesktopRunCancelledError('Run cancelled during the test.'));
610
+ } else {
611
+ resolve(value);
612
+ }
613
+ },
614
+ (error: unknown) => {
615
+ signal.removeEventListener('abort', onAbort);
616
+ reject(aborted ? new DesktopRunCancelledError('Run cancelled during the test.') : error);
617
+ },
618
+ );
619
+ });
620
+ }
621
+
622
+ function delay(milliseconds: number): Promise<void> {
623
+ return new Promise((resolve) => setTimeout(resolve, milliseconds));
624
+ }
@@ -0,0 +1,9 @@
1
+ export {
2
+ assertDesktopExpectation,
3
+ DesktopAssertionError,
4
+ findDesktopElement,
5
+ findDesktopElements,
6
+ runDesktopStoryTests,
7
+ selectDesktopStoryTests,
8
+ } from './StoryTestRunner.js';
9
+ export type { DesktopStoryTestRunnerOptions, DesktopStoryTestSelection } from './StoryTestRunner.js';