@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,105 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+
5
+ import type { DesktopArtifact, DesktopStoryRunResult } from '../authoring/results.js';
6
+
7
+ export class ArtifactManager {
8
+ readonly root: string;
9
+
10
+ constructor(root: string) {
11
+ if (!root) {
12
+ throw new TypeError('ArtifactManager requires a non-empty root path.');
13
+ }
14
+ fs.mkdirSync(root, { recursive: true });
15
+ this.root = fs.realpathSync.native(root);
16
+ }
17
+
18
+ writeScreenshot(testDirectory: string, name: string, base64: string): DesktopArtifact {
19
+ return this.write(testDirectory, name, 'png', Buffer.from(base64, 'base64'), 'screenshot');
20
+ }
21
+
22
+ writeSource(testDirectory: string, name: string, source: string): DesktopArtifact {
23
+ return this.write(testDirectory, name, 'xml', source, 'source');
24
+ }
25
+
26
+ writeTree(testDirectory: string, name: string, tree: unknown): DesktopArtifact {
27
+ return this.write(testDirectory, name, 'json', `${JSON.stringify(tree, null, 2)}\n`, 'tree');
28
+ }
29
+
30
+ writeRunResult(result: DesktopStoryRunResult): string {
31
+ const outputPath = this.resolvePath('run.json');
32
+ writeAtomic(outputPath, `${JSON.stringify(result, null, 2)}\n`);
33
+ return outputPath;
34
+ }
35
+
36
+ writeMetadata(name: string, value: unknown): string {
37
+ const outputPath = this.resolvePath(`${sanitizeSegment(name)}.json`);
38
+ writeAtomic(outputPath, `${JSON.stringify(value, null, 2)}\n`);
39
+ return outputPath;
40
+ }
41
+
42
+ private write(
43
+ testDirectory: string,
44
+ name: string,
45
+ extension: string,
46
+ data: string | Uint8Array,
47
+ kind: DesktopArtifact['kind'],
48
+ ): DesktopArtifact {
49
+ const safeDirectory = sanitizeSegment(testDirectory);
50
+ const safeName = sanitizeSegment(name);
51
+ const relativePath = path.join('tests', safeDirectory, `${safeName}.${extension}`);
52
+ const outputPath = this.resolvePath(relativePath);
53
+ writeAtomic(outputPath, data);
54
+ return {
55
+ kind,
56
+ name,
57
+ path: relativePath.split(path.sep).join('/'),
58
+ };
59
+ }
60
+
61
+ private resolvePath(relativePath: string): string {
62
+ if (path.isAbsolute(relativePath)) {
63
+ throw new Error('Artifact paths must be relative to the configured root.');
64
+ }
65
+ const outputPath = path.resolve(this.root, relativePath);
66
+ const relative = path.relative(this.root, outputPath);
67
+ if (relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
68
+ throw new Error(`Artifact path escapes the configured root: ${relativePath}`);
69
+ }
70
+ const parent = path.dirname(outputPath);
71
+ fs.mkdirSync(parent, { recursive: true });
72
+ const realParent = fs.realpathSync.native(parent);
73
+ const realRelative = path.relative(this.root, realParent);
74
+ if (realRelative === '..' || realRelative.startsWith(`..${path.sep}`) || path.isAbsolute(realRelative)) {
75
+ throw new Error(`Artifact path traverses outside the configured root: ${relativePath}`);
76
+ }
77
+ return path.join(realParent, path.basename(outputPath));
78
+ }
79
+ }
80
+
81
+ function sanitizeSegment(value: string): string {
82
+ let sanitized = value
83
+ .replaceAll(/[^A-Za-z0-9._-]/g, '-')
84
+ .replaceAll(/-+/g, '-')
85
+ .replace(/^\.+/, '');
86
+ if (/^(?:aux|con|nul|prn|com[1-9]|lpt[1-9])(?:\.|$)/i.test(sanitized)) {
87
+ sanitized = `_${sanitized}`;
88
+ }
89
+ if (!sanitized || sanitized === '.' || sanitized === '..') {
90
+ throw new Error(`Invalid artifact name "${value}".`);
91
+ }
92
+ return sanitized;
93
+ }
94
+
95
+ function writeAtomic(outputPath: string, data: string | Uint8Array): void {
96
+ fs.mkdirSync(path.dirname(outputPath), { recursive: true });
97
+ const temporaryPath = `${outputPath}.${randomUUID()}.tmp`;
98
+ try {
99
+ fs.writeFileSync(temporaryPath, data);
100
+ fs.renameSync(temporaryPath, outputPath);
101
+ } catch (error) {
102
+ fs.rmSync(temporaryPath, { force: true });
103
+ throw error;
104
+ }
105
+ }
@@ -0,0 +1 @@
1
+ export { ArtifactManager } from './ArtifactManager.js';
@@ -0,0 +1,26 @@
1
+ export type {
2
+ DesktopArtifact,
3
+ DesktopRunStatus,
4
+ DesktopStepStatus,
5
+ DesktopStoryRunResult,
6
+ DesktopStoryStepResult,
7
+ DesktopStoryTestResult,
8
+ DesktopTestStatus,
9
+ } from './results.js';
10
+ export {
11
+ defineDesktopStoryTests,
12
+ desktopBy,
13
+ desktopStoryCapabilities,
14
+ desktopStoryPlatforms,
15
+ validateDesktopStoryTests,
16
+ } from './storyTests.js';
17
+ export type {
18
+ DesktopStoryCapability,
19
+ DesktopStoryExpectation,
20
+ DesktopStoryPlatform,
21
+ DesktopStorySelector,
22
+ DesktopStoryState,
23
+ DesktopStoryStep,
24
+ DesktopStoryTest,
25
+ DesktopStoryTests,
26
+ } from './storyTests.js';
@@ -0,0 +1,47 @@
1
+ import type { DesktopEndpoint, DesktopPlatformName } from '../protocol/types.js';
2
+
3
+ export type DesktopArtifact = {
4
+ kind: 'screenshot' | 'source' | 'tree';
5
+ name: string;
6
+ path: string;
7
+ };
8
+
9
+ export type DesktopStepStatus = 'failed' | 'passed' | 'skipped';
10
+ export type DesktopTestStatus = 'cancelled' | 'failed' | 'infrastructure-error' | 'passed' | 'skipped' | 'timed-out';
11
+ export type DesktopRunStatus = 'failed' | 'passed';
12
+
13
+ export type DesktopStoryStepResult = {
14
+ artifacts: readonly DesktopArtifact[];
15
+ durationMs: number;
16
+ error?: string;
17
+ index: number;
18
+ status: DesktopStepStatus;
19
+ };
20
+
21
+ export type DesktopStoryTestResult = {
22
+ artifacts: readonly DesktopArtifact[];
23
+ durationMs: number;
24
+ error?: string;
25
+ skipReason?: string;
26
+ status: DesktopTestStatus;
27
+ steps: readonly DesktopStoryStepResult[];
28
+ storyId: string;
29
+ testId: string;
30
+ title: string;
31
+ };
32
+
33
+ export type DesktopStoryRunResult = {
34
+ endpoint: DesktopEndpoint;
35
+ finishedAt: string;
36
+ manifest: {
37
+ platform: string;
38
+ portable: string;
39
+ };
40
+ platformName: DesktopPlatformName;
41
+ runId: string;
42
+ schemaVersion: 1;
43
+ startedAt: string;
44
+ status: DesktopRunStatus;
45
+ targetId: string;
46
+ tests: readonly DesktopStoryTestResult[];
47
+ };
@@ -0,0 +1,58 @@
1
+ import { validateDesktopStoryTests } from './storyTests.js';
2
+
3
+ describe('validateDesktopStoryTests', () => {
4
+ test('accepts a serializable versioned plan', () => {
5
+ const plan = {
6
+ version: 1,
7
+ tests: [{ id: 'click', steps: [{ action: 'click', target: { testId: 'button' } }] }],
8
+ } as const;
9
+
10
+ expect(validateDesktopStoryTests(plan)).toBe(plan);
11
+ });
12
+
13
+ test('rejects duplicate ids and executable values', () => {
14
+ expect(() =>
15
+ validateDesktopStoryTests({
16
+ version: 1,
17
+ tests: [
18
+ { id: 'duplicate', steps: [{ action: 'note', message: 'first' }] },
19
+ { id: 'duplicate', steps: [{ action: 'note', message: 'second' }] },
20
+ ],
21
+ }),
22
+ ).toThrow('duplicate test id');
23
+ expect(() =>
24
+ validateDesktopStoryTests({
25
+ version: 1,
26
+ tests: [{ id: 'function', steps: [{ action: 'setArgs', args: { run: () => undefined } }] }],
27
+ }),
28
+ ).toThrow('JSON-serializable');
29
+ });
30
+
31
+ test('rejects non-finite numbers before digest serialization', () => {
32
+ expect(() =>
33
+ validateDesktopStoryTests({
34
+ version: 1,
35
+ tests: [
36
+ {
37
+ id: 'non-finite',
38
+ steps: [{ action: 'setArgs', args: { invalid: Number.POSITIVE_INFINITY } }],
39
+ },
40
+ ],
41
+ }),
42
+ ).toThrow('finite numbers');
43
+ });
44
+
45
+ test('rejects malformed authored W3C action sequences', () => {
46
+ expect(() =>
47
+ validateDesktopStoryTests({
48
+ version: 1,
49
+ tests: [
50
+ {
51
+ id: 'invalid-actions',
52
+ steps: [{ action: 'actions', sequences: [{ id: 'bad', type: 'key', actions: [{ type: 'pointerDown' }] }] }],
53
+ },
54
+ ],
55
+ }),
56
+ ).toThrow('sequences are invalid');
57
+ });
58
+ });
@@ -0,0 +1,345 @@
1
+ import type { WebDriverActionSequence } from '../protocol/types.js';
2
+ import { createInputState, parseActionSequences } from '../protocol/actions.js';
3
+
4
+ export const desktopStoryPlatforms = ['macos', 'windows', 'win32'] as const;
5
+ export const desktopStoryCapabilities = [
6
+ 'accessibility-click',
7
+ 'element-screenshot',
8
+ 'focus',
9
+ 'keyboard',
10
+ 'physical-click',
11
+ 'screenshot',
12
+ 'wheel',
13
+ ] as const;
14
+
15
+ export type DesktopStoryPlatform = (typeof desktopStoryPlatforms)[number];
16
+ export type DesktopStoryCapability = (typeof desktopStoryCapabilities)[number];
17
+
18
+ export type DesktopStorySelector = { testId: string } | { role: string; name?: string } | { accessibleName: string } | { text: string };
19
+
20
+ export type DesktopStoryState =
21
+ | 'accessibleName'
22
+ | 'checked'
23
+ | 'count'
24
+ | 'displayed'
25
+ | 'enabled'
26
+ | 'expanded'
27
+ | 'exists'
28
+ | 'focused'
29
+ | 'role'
30
+ | 'selected'
31
+ | 'text'
32
+ | 'value';
33
+
34
+ export type DesktopStoryExpectation = {
35
+ state: DesktopStoryState;
36
+ target: DesktopStorySelector;
37
+ value?: boolean | number | string;
38
+ };
39
+
40
+ export type DesktopStoryStep =
41
+ | { action: 'actions'; sequences: readonly WebDriverActionSequence[] }
42
+ | { action: 'clear' | 'click' | 'doubleClick'; target: DesktopStorySelector }
43
+ | { action: 'keys'; value: readonly string[] }
44
+ | { action: 'note'; message: string }
45
+ | { action: 'screenshot'; name: string; target?: DesktopStorySelector }
46
+ | { action: 'scroll'; deltaX?: number; deltaY: number; target?: DesktopStorySelector }
47
+ | { action: 'setArgs'; args: Readonly<Record<string, unknown>> }
48
+ | { action: 'source'; name: string }
49
+ | { action: 'type'; target: DesktopStorySelector; text: string }
50
+ | { action: 'wait'; target?: DesktopStorySelector; timeoutMs?: number; until?: DesktopStoryExpectation }
51
+ | { expect: DesktopStoryExpectation };
52
+
53
+ export type DesktopStoryTest = {
54
+ id: string;
55
+ platforms?: readonly DesktopStoryPlatform[];
56
+ requires?: readonly DesktopStoryCapability[];
57
+ steps: readonly DesktopStoryStep[];
58
+ title?: string;
59
+ };
60
+
61
+ export type DesktopStoryTests = {
62
+ portable?: boolean;
63
+ tests: readonly DesktopStoryTest[];
64
+ version: 1;
65
+ };
66
+
67
+ export const desktopBy = {
68
+ accessibleName: (accessibleName: string): DesktopStorySelector => ({ accessibleName }),
69
+ role: (role: string, name?: string): DesktopStorySelector => ({ role, ...(name ? { name } : {}) }),
70
+ testId: (testId: string): DesktopStorySelector => ({ testId }),
71
+ text: (text: string): DesktopStorySelector => ({ text }),
72
+ };
73
+
74
+ export function defineDesktopStoryTests(plan: DesktopStoryTests): DesktopStoryTests {
75
+ return validateDesktopStoryTests(plan);
76
+ }
77
+
78
+ export function validateDesktopStoryTests(value: unknown, source = 'desktopDriver'): DesktopStoryTests {
79
+ const plan = requireObject(value, source);
80
+ requireExactKeys(plan, ['portable', 'tests', 'version'], source);
81
+ if (plan.version !== 1) {
82
+ throw new TypeError(`${source}.version must be 1.`);
83
+ }
84
+ if (plan.portable !== undefined && typeof plan.portable !== 'boolean') {
85
+ throw new TypeError(`${source}.portable must be a boolean when provided.`);
86
+ }
87
+ if (!Array.isArray(plan.tests)) {
88
+ throw new TypeError(`${source}.tests must be an array.`);
89
+ }
90
+
91
+ const ids = new Set<string>();
92
+ for (const [index, value] of plan.tests.entries()) {
93
+ validateTest(value, `${source}.tests[${index}]`, ids);
94
+ }
95
+ assertJsonValue(value, source);
96
+ return value as DesktopStoryTests;
97
+ }
98
+
99
+ function validateTest(value: unknown, source: string, ids: Set<string>): void {
100
+ const test = requireObject(value, source);
101
+ requireExactKeys(test, ['id', 'platforms', 'requires', 'steps', 'title'], source);
102
+ if (typeof test.id !== 'string' || !test.id) {
103
+ throw new TypeError(`${source}.id must be a non-empty string.`);
104
+ }
105
+ if (ids.has(test.id)) {
106
+ throw new TypeError(`${source} contains duplicate test id "${test.id}".`);
107
+ }
108
+ ids.add(test.id);
109
+ if (test.title !== undefined && (typeof test.title !== 'string' || !test.title)) {
110
+ throw new TypeError(`${source}.title must be a non-empty string when provided.`);
111
+ }
112
+ validateEnumArray(test.platforms, desktopStoryPlatforms, `${source}.platforms`);
113
+ validateEnumArray(test.requires, desktopStoryCapabilities, `${source}.requires`);
114
+ if (!Array.isArray(test.steps) || test.steps.length === 0) {
115
+ throw new TypeError(`${source}.steps must be a non-empty array.`);
116
+ }
117
+ for (const [index, step] of test.steps.entries()) {
118
+ validateStep(step, `${source}.steps[${index}]`);
119
+ }
120
+ }
121
+
122
+ function validateStep(value: unknown, source: string): void {
123
+ const step = requireObject(value, source);
124
+ if ('expect' in step) {
125
+ requireExactKeys(step, ['expect'], source);
126
+ validateExpectation(step.expect, `${source}.expect`);
127
+ return;
128
+ }
129
+ if (typeof step.action !== 'string') {
130
+ throw new TypeError(`${source}.action must be a supported action string.`);
131
+ }
132
+ switch (step.action) {
133
+ case 'actions':
134
+ requireExactKeys(step, ['action', 'sequences'], source);
135
+ if (!Array.isArray(step.sequences) || step.sequences.length === 0) {
136
+ throw new TypeError(`${source}.sequences must be a non-empty array.`);
137
+ }
138
+ try {
139
+ parseActionSequences(step.sequences, createInputState());
140
+ } catch (error) {
141
+ throw new TypeError(`${source}.sequences are invalid: ${(error as Error).message}`, { cause: error });
142
+ }
143
+ return;
144
+ case 'clear':
145
+ case 'click':
146
+ case 'doubleClick':
147
+ requireExactKeys(step, ['action', 'target'], source);
148
+ validateSelector(step.target, `${source}.target`);
149
+ return;
150
+ case 'keys':
151
+ requireExactKeys(step, ['action', 'value'], source);
152
+ if (!Array.isArray(step.value) || !step.value.every((key) => typeof key === 'string' && key.length > 0)) {
153
+ throw new TypeError(`${source}.value must be a non-empty string array.`);
154
+ }
155
+ return;
156
+ case 'note':
157
+ requireExactKeys(step, ['action', 'message'], source);
158
+ requireNonEmptyString(step.message, `${source}.message`);
159
+ return;
160
+ case 'screenshot':
161
+ requireExactKeys(step, ['action', 'name', 'target'], source);
162
+ requireNonEmptyString(step.name, `${source}.name`);
163
+ if (step.target !== undefined) {
164
+ validateSelector(step.target, `${source}.target`);
165
+ }
166
+ return;
167
+ case 'scroll':
168
+ requireExactKeys(step, ['action', 'deltaX', 'deltaY', 'target'], source);
169
+ requireFiniteNumber(step.deltaY, `${source}.deltaY`);
170
+ if (step.deltaX !== undefined) {
171
+ requireFiniteNumber(step.deltaX, `${source}.deltaX`);
172
+ }
173
+ if (step.target !== undefined) {
174
+ validateSelector(step.target, `${source}.target`);
175
+ }
176
+ return;
177
+ case 'setArgs':
178
+ requireExactKeys(step, ['action', 'args'], source);
179
+ requireObject(step.args, `${source}.args`);
180
+ return;
181
+ case 'source':
182
+ requireExactKeys(step, ['action', 'name'], source);
183
+ requireNonEmptyString(step.name, `${source}.name`);
184
+ return;
185
+ case 'type':
186
+ requireExactKeys(step, ['action', 'target', 'text'], source);
187
+ validateSelector(step.target, `${source}.target`);
188
+ if (typeof step.text !== 'string') {
189
+ throw new TypeError(`${source}.text must be a string.`);
190
+ }
191
+ return;
192
+ case 'wait':
193
+ requireExactKeys(step, ['action', 'target', 'timeoutMs', 'until'], source);
194
+ if (step.target !== undefined) {
195
+ validateSelector(step.target, `${source}.target`);
196
+ }
197
+ if (step.until !== undefined) {
198
+ validateExpectation(step.until, `${source}.until`);
199
+ }
200
+ if (step.target === undefined && step.until === undefined) {
201
+ throw new TypeError(`${source} requires "target" or "until".`);
202
+ }
203
+ if (step.timeoutMs !== undefined && (!Number.isInteger(step.timeoutMs) || (step.timeoutMs as number) < 0)) {
204
+ throw new TypeError(`${source}.timeoutMs must be a non-negative integer.`);
205
+ }
206
+ return;
207
+ default:
208
+ throw new TypeError(`${source}.action "${step.action}" is not supported.`);
209
+ }
210
+ }
211
+
212
+ function validateExpectation(value: unknown, source: string): void {
213
+ const expectation = requireObject(value, source);
214
+ requireExactKeys(expectation, ['state', 'target', 'value'], source);
215
+ if (
216
+ typeof expectation.state !== 'string' ||
217
+ !(
218
+ [
219
+ 'accessibleName',
220
+ 'checked',
221
+ 'count',
222
+ 'displayed',
223
+ 'enabled',
224
+ 'expanded',
225
+ 'exists',
226
+ 'focused',
227
+ 'role',
228
+ 'selected',
229
+ 'text',
230
+ 'value',
231
+ ] as const
232
+ ).includes(expectation.state as DesktopStoryState)
233
+ ) {
234
+ throw new TypeError(`${source}.state is not supported.`);
235
+ }
236
+ validateSelector(expectation.target, `${source}.target`);
237
+ if (expectation.value !== undefined && !['boolean', 'number', 'string'].includes(typeof expectation.value)) {
238
+ throw new TypeError(`${source}.value must be a boolean, number, or string.`);
239
+ }
240
+ if (typeof expectation.value === 'number' && !Number.isFinite(expectation.value)) {
241
+ throw new TypeError(`${source}.value must be finite.`);
242
+ }
243
+ if (expectation.state === 'count' && typeof expectation.value !== 'number') {
244
+ throw new TypeError(`${source}.value must be a number for a count assertion.`);
245
+ }
246
+ if (
247
+ (expectation.state === 'accessibleName' ||
248
+ expectation.state === 'role' ||
249
+ expectation.state === 'text' ||
250
+ expectation.state === 'value') &&
251
+ typeof expectation.value !== 'string'
252
+ ) {
253
+ throw new TypeError(`${source}.value must be a string for a ${expectation.state} assertion.`);
254
+ }
255
+ if (
256
+ (expectation.state === 'checked' ||
257
+ expectation.state === 'displayed' ||
258
+ expectation.state === 'enabled' ||
259
+ expectation.state === 'expanded' ||
260
+ expectation.state === 'exists' ||
261
+ expectation.state === 'focused' ||
262
+ expectation.state === 'selected') &&
263
+ expectation.value !== undefined &&
264
+ typeof expectation.value !== 'boolean' &&
265
+ !(expectation.state === 'checked' && expectation.value === 'mixed')
266
+ ) {
267
+ throw new TypeError(`${source}.value must be a boolean for a ${expectation.state} assertion.`);
268
+ }
269
+ }
270
+
271
+ function validateSelector(value: unknown, source: string): void {
272
+ const selector = requireObject(value, source);
273
+ const selectorKeys = ['accessibleName', 'role', 'testId', 'text'].filter((key) => selector[key] !== undefined);
274
+ if (selectorKeys.length !== 1) {
275
+ throw new TypeError(`${source} must define exactly one selector strategy.`);
276
+ }
277
+ const strategy = selectorKeys[0];
278
+ requireNonEmptyString(selector[strategy], `${source}.${strategy}`);
279
+ if (strategy === 'role') {
280
+ requireExactKeys(selector, ['name', 'role'], source);
281
+ if (selector.name !== undefined) {
282
+ requireNonEmptyString(selector.name, `${source}.name`);
283
+ }
284
+ } else {
285
+ requireExactKeys(selector, [strategy], source);
286
+ }
287
+ }
288
+
289
+ function validateEnumArray<T extends string>(value: unknown, allowed: readonly T[], source: string): void {
290
+ if (value === undefined) {
291
+ return;
292
+ }
293
+ if (!Array.isArray(value) || !value.every((item) => typeof item === 'string' && allowed.includes(item as T))) {
294
+ throw new TypeError(`${source} contains an unsupported value.`);
295
+ }
296
+ }
297
+
298
+ function requireExactKeys(value: Record<string, unknown>, allowed: readonly string[], source: string): void {
299
+ const unknown = Object.keys(value).filter((key) => !allowed.includes(key));
300
+ if (unknown.length > 0) {
301
+ throw new TypeError(`${source} contains unknown field "${unknown[0]}".`);
302
+ }
303
+ }
304
+
305
+ function requireNonEmptyString(value: unknown, source: string): asserts value is string {
306
+ if (typeof value !== 'string' || !value) {
307
+ throw new TypeError(`${source} must be a non-empty string.`);
308
+ }
309
+ }
310
+
311
+ function requireFiniteNumber(value: unknown, source: string): asserts value is number {
312
+ if (typeof value !== 'number' || !Number.isFinite(value)) {
313
+ throw new TypeError(`${source} must be a finite number.`);
314
+ }
315
+ }
316
+
317
+ function requireObject(value: unknown, source: string): Record<string, unknown> {
318
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
319
+ throw new TypeError(`${source} must be an object.`);
320
+ }
321
+ return value as Record<string, unknown>;
322
+ }
323
+
324
+ function assertJsonValue(value: unknown, source: string): void {
325
+ if (typeof value === 'number') {
326
+ if (!Number.isFinite(value)) {
327
+ throw new TypeError(`${source} must contain only finite numbers.`);
328
+ }
329
+ return;
330
+ }
331
+ if (value === null || typeof value === 'boolean' || typeof value === 'string') {
332
+ return;
333
+ }
334
+ if (Array.isArray(value)) {
335
+ value.forEach((item, index) => assertJsonValue(item, `${source}[${index}]`));
336
+ return;
337
+ }
338
+ if (typeof value === 'object') {
339
+ for (const [key, item] of Object.entries(value as Record<string, unknown>)) {
340
+ assertJsonValue(item, `${source}.${key}`);
341
+ }
342
+ return;
343
+ }
344
+ throw new TypeError(`${source} must contain only JSON-serializable values.`);
345
+ }