@interopio/iocd-cli 1.1.2 → 1.1.3
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/changelog.md +7 -0
- package/dist/templates/tests/.agents/skills/iocd-wdio-testing/SKILL.md +0 -1
- package/dist/templates/tests/.agents/skills/iocd-wdio-testing/references/authoring.md +25 -8
- package/dist/templates/tests/.agents/skills/iocd-wdio-testing/references/configuration.md +35 -12
- package/dist/templates/tests/.agents/skills/iocd-wdio-testing/references/models.md +24 -31
- package/dist/templates/tests/.agents/skills/iocd-wdio-testing/references/setup.md +16 -3
- package/dist/templates/tests/tests/.gitignore.template +1 -0
- package/dist/templates/tests/tests/README.md +4 -2
- package/dist/templates/tests/tests/tests/common/iocd.ts +60 -40
- package/dist/templates/tests/tests/tests/shared-contexts.spec.ts +22 -17
- package/package.json +1 -1
package/changelog.md
CHANGED
|
@@ -5,6 +5,13 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [1.1.3](https://github.com/InteropIO/iocd-cli/compare/iocd-cli-v1.1.2...iocd-cli-v1.1.3) (2026-10-06)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Bug Fixes
|
|
12
|
+
|
|
13
|
+
* **test:** harden seed testing skill and examples ([#124](https://github.com/InteropIO/iocd-cli/issues/124)) ([0ea68f4](https://github.com/InteropIO/iocd-cli/commit/0ea68f41079284feef624569b4da57cb9663c538))
|
|
14
|
+
|
|
8
15
|
## [1.1.2](https://github.com/InteropIO/iocd-cli/compare/iocd-cli-v1.1.1...iocd-cli-v1.1.2) (2026-09-23)
|
|
9
16
|
|
|
10
17
|
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: iocd-wdio-testing
|
|
3
3
|
description: 'Set up, write, run, and debug end-to-end WebdriverIO tests for an io.Connect Desktop (IOCD) seed project with @interopio/wdio-iocd-service. Use when adding or running IOCD/seed E2E tests, or testing apps, windows, Web Groups, Workspaces, Interop, Channels, Shared Contexts, Layouts, or Notifications in a running io.Connect Desktop platform.'
|
|
4
|
-
argument-hint: 'Describe the IOCD behavior to test, or ask to set up tests'
|
|
5
4
|
---
|
|
6
5
|
|
|
7
6
|
# io.Connect Desktop WebdriverIO Testing
|
|
@@ -175,14 +175,20 @@ only when the target app permits it.
|
|
|
175
175
|
Some renderer calls return a promise that settles only after the user answers a dialog or completes
|
|
176
176
|
another UI workflow. Awaiting that promise while issuing the request blocks the test before it can
|
|
177
177
|
perform the action that resolves the promise. Split the workflow into **start**, **interact**, and
|
|
178
|
-
**collect** phases
|
|
178
|
+
**collect** phases. This example uses `ModalsDialog`; verify that the installed service exports it
|
|
179
|
+
(see [models](./models.md)). Otherwise, implement the interaction in a local page object using the
|
|
180
|
+
actual dialog DOM. `config`, `requesterWindow`, and `expectedResponse` are test-owned inputs.
|
|
179
181
|
|
|
180
182
|
```ts
|
|
183
|
+
import { ModalsDialog } from "@interopio/wdio-iocd-service/models";
|
|
184
|
+
|
|
185
|
+
type ModalOutcome = { settled: boolean; response?: unknown; error?: string };
|
|
186
|
+
|
|
181
187
|
// In the app-window helper: start the request and return immediately.
|
|
182
188
|
await browser.execute((requestConfig) => {
|
|
183
189
|
const appWindow = window as typeof window & {
|
|
184
190
|
io: { modals: { dialogs: { request(config: unknown): Promise<unknown> } } };
|
|
185
|
-
__modalOutcome?:
|
|
191
|
+
__modalOutcome?: ModalOutcome;
|
|
186
192
|
};
|
|
187
193
|
|
|
188
194
|
appWindow.__modalOutcome = { settled: false };
|
|
@@ -195,16 +201,24 @@ await browser.execute((requestConfig) => {
|
|
|
195
201
|
});
|
|
196
202
|
}, config);
|
|
197
203
|
|
|
198
|
-
//
|
|
204
|
+
// Address the dialog raised by this requester and answer it.
|
|
205
|
+
const dialog = ModalsDialog.forTarget(requesterWindow.id);
|
|
199
206
|
await dialog.waitUntilOpen();
|
|
200
|
-
await dialog.
|
|
207
|
+
await dialog.action("confirm").click();
|
|
201
208
|
|
|
202
209
|
// Switch back to the requester, wait for settlement, then collect/assert the outcome.
|
|
203
210
|
await browser.switchById(requesterWindow.id);
|
|
204
211
|
await browser.waitUntil(
|
|
205
|
-
() => browser.execute(() =>
|
|
212
|
+
() => browser.execute(() =>
|
|
213
|
+
Boolean((window as typeof window & { __modalOutcome?: ModalOutcome }).__modalOutcome?.settled)
|
|
214
|
+
),
|
|
206
215
|
{ timeoutMsg: "Expected the modal request to settle" }
|
|
207
216
|
);
|
|
217
|
+
const outcome = await browser.execute(() =>
|
|
218
|
+
(window as typeof window & { __modalOutcome?: ModalOutcome }).__modalOutcome
|
|
219
|
+
);
|
|
220
|
+
expect(outcome?.error).to.equal(undefined);
|
|
221
|
+
expect(outcome?.response).to.deep.equal(expectedResponse);
|
|
208
222
|
```
|
|
209
223
|
|
|
210
224
|
Store both resolved and rejected outcomes, clean any app-owned request state after the test, and use
|
|
@@ -233,10 +247,13 @@ command only switches focus, it doesn't open the panel. See `notifications.spec.
|
|
|
233
247
|
- **Wait for state, not time** — events, `browser.waitUntil`, element waits, or `waitFor`. Subscribe
|
|
234
248
|
before triggering the action. For event APIs, use `waitForEvent` from `tests/tests/common/iocd.ts`:
|
|
235
249
|
it filters events, times out with a useful message, and unsubscribes on success or timeout. Track
|
|
236
|
-
its idempotent `unsubscribe` in a cleanup registry so test failure
|
|
250
|
+
its idempotent `unsubscribe` in a cleanup registry so test failure cancels the pending wait and
|
|
251
|
+
removes its timer and subscription. Always await `result` on the success path to assert delivery.
|
|
237
252
|
Don't replace an unknown readiness condition with a fixed pause.
|
|
238
|
-
- **Isolate** —
|
|
239
|
-
cleanup
|
|
253
|
+
- **Isolate** — register cleanup immediately after each resource is created, before the next
|
|
254
|
+
fallible operation. Put all cleanup in one registry so a failed unsubscribe cannot prevent apps
|
|
255
|
+
from stopping or contexts from being destroyed. Catch cleanup errors only when the resource may
|
|
256
|
+
already be gone.
|
|
240
257
|
- **Don't bypass the behavior under test** — drive the first app as a user would and observe the
|
|
241
258
|
outcome in the receiver, rather than invoking application business logic directly from the test.
|
|
242
259
|
- **One behavior per test**; run the new spec alone while iterating.
|
|
@@ -29,7 +29,11 @@ platform the seed ships — you'd be testing something else. Behavior changes be
|
|
|
29
29
|
`modifications/`; another *supported* configuration belongs in its own capability (below).
|
|
30
30
|
|
|
31
31
|
The generated `config/system.json` registers `tests/tests/config/apps.json` as a path app store and
|
|
32
|
-
isolates `folders.userData` to `tests/user-data
|
|
32
|
+
isolates `folders.userData` to `tests/user-data`. The test package's `.gitignore` excludes this
|
|
33
|
+
generated profile; check older packages as described in [setup](./setup.md). It persists across
|
|
34
|
+
launches, including cached SSO sessions, so tests must arrange their required state. Use separate
|
|
35
|
+
profile directories for capabilities that require different authenticated users or independent
|
|
36
|
+
persistent state. Never commit the profile or include it in CI artifacts.
|
|
33
37
|
|
|
34
38
|
## Tests that need extra config
|
|
35
39
|
|
|
@@ -81,9 +85,10 @@ Rules:
|
|
|
81
85
|
- **One launch per capability, run sequentially** (`IOCDBaseConfiguration` sets `maxInstances: 1`).
|
|
82
86
|
Budget a full platform startup + shutdown for each — group specs by configuration; never add a
|
|
83
87
|
capability per spec.
|
|
84
|
-
- **`npm test -- --spec <file>` overrides capability spec patterns
|
|
85
|
-
|
|
86
|
-
|
|
88
|
+
- **`npm test -- --spec <file>` overrides capability spec patterns, but preserves exclusions.**
|
|
89
|
+
A selected spec runs under each capability that does not exclude it. In the example above, a
|
|
90
|
+
multi-Channel spec runs only under its variant, while a default spec also runs under the variant.
|
|
91
|
+
Config-dependent specs must read the live config and skip when it doesn't apply (below).
|
|
87
92
|
|
|
88
93
|
### Read the live config in a spec
|
|
89
94
|
|
|
@@ -114,8 +119,10 @@ the configuration that was actually found.
|
|
|
114
119
|
A seed configured with an interactive login (`ssoAuth` in the system config) reports readiness only
|
|
115
120
|
**after** someone signs in — without automation, `npm test` sits on the login window until the
|
|
116
121
|
startup timeout and no spec runs. The service's `login` option drives that window while the
|
|
117
|
-
platform boots.
|
|
118
|
-
|
|
122
|
+
platform boots. The option was introduced in `@interopio/wdio-iocd-service` 1.0.0-next.3; the handler
|
|
123
|
+
API and supported platform versions vary by service release. Check the installed service's README
|
|
124
|
+
and `dist/services/loginOptions.d.ts` before adapting a login flow. The example below uses the
|
|
125
|
+
`LoginBrowser` API in 1.0.0-next.7, whose login discovery also supports platforms before 10.4.
|
|
119
126
|
|
|
120
127
|
```ts
|
|
121
128
|
// tests/wdio.config.ts
|
|
@@ -125,8 +132,14 @@ services: IOCDService({
|
|
|
125
132
|
handler: async (browser) => {
|
|
126
133
|
const username = browser.$("#username");
|
|
127
134
|
const password = browser.$("#password");
|
|
128
|
-
await
|
|
129
|
-
|
|
135
|
+
await browser.waitUntil(() => username.isExisting(), {
|
|
136
|
+
timeout: 30000,
|
|
137
|
+
timeoutMsg: "The login username field did not appear",
|
|
138
|
+
});
|
|
139
|
+
await browser.waitUntil(() => password.isExisting(), {
|
|
140
|
+
timeout: 30000,
|
|
141
|
+
timeoutMsg: "The login password field did not appear",
|
|
142
|
+
});
|
|
130
143
|
await username.setValue(process.env.IOCD_TEST_USER!);
|
|
131
144
|
await password.setValue(process.env.IOCD_TEST_PASSWORD!);
|
|
132
145
|
await browser.$("button[type=submit]").click();
|
|
@@ -137,9 +150,12 @@ services: IOCDService({
|
|
|
137
150
|
|
|
138
151
|
Rules:
|
|
139
152
|
|
|
140
|
-
- **The handler runs before the platform is ready.** No `browser.io()
|
|
141
|
-
|
|
142
|
-
|
|
153
|
+
- **The handler runs before the platform is ready.** No `browser.io()` or models. In next.7 it
|
|
154
|
+
receives `LoginBrowser`, a subset of WebdriverIO driven over CDP. Use `$`, `getUrl`, `getTitle`,
|
|
155
|
+
`waitUntil`, and `execute`; its elements support `setValue`, `click`, `getText`, `getValue`,
|
|
156
|
+
`isExisting`, and `waitUntil`. Check the installed `dist/services/loginBrowser.d.ts` for the
|
|
157
|
+
supported surface. WDIO globals resolve during the handler, but `$$` throws and element methods
|
|
158
|
+
such as `waitForExist` are unavailable. Page objects must use only the supported subset.
|
|
143
159
|
- **The signed-in session belongs to the platform process**, so the apps under test are
|
|
144
160
|
authenticated exactly as in production.
|
|
145
161
|
- **Credentials come from env vars / CI secrets** — never from specs or config files.
|
|
@@ -155,10 +171,17 @@ Rules:
|
|
|
155
171
|
|
|
156
172
|
## Add an optional IO library
|
|
157
173
|
|
|
158
|
-
|
|
174
|
+
For libraries that support the worker's Node context:
|
|
175
|
+
|
|
176
|
+
1. Add the package to `tests/package.json` and install it in the test package.
|
|
159
177
|
2. Add its module specifier to `iocd:options.ioInitOptions.libraries` in `tests/wdio.config.ts`.
|
|
160
178
|
3. Use it via `browser.io()` (optional sub-APIs need `!`).
|
|
161
179
|
|
|
180
|
+
Renderer-scoped libraries, such as a Modals client that requires an app window, must be installed
|
|
181
|
+
and initialized in a real fixture or application window. Adding them to the worker's `libraries`
|
|
182
|
+
does not supply that window context. See [authoring](./authoring.md#worker-api-vs-app-window-apis)
|
|
183
|
+
for the readiness and interaction pattern.
|
|
184
|
+
|
|
162
185
|
## Runners
|
|
163
186
|
|
|
164
187
|
`platform-wdio` is the default (specs run Node-side and drive the app remotely). `platform-window`,
|
|
@@ -1,27 +1,29 @@
|
|
|
1
1
|
# Built-in IOCD UI Models
|
|
2
2
|
|
|
3
3
|
`@interopio/wdio-iocd-service/models` exports page objects for built-in web UI owned by IOCD. Use
|
|
4
|
-
|
|
5
|
-
WebdriverIO selectors for UI owned by the client app.
|
|
4
|
+
available models for Web Groups, captions, tabs, Channel Selectors, dialogs, and other platform
|
|
5
|
+
surfaces. Use normal WebdriverIO selectors for UI owned by the client app.
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
`group.switchTo()`, `group.frames.waitForCount()`, `tab.close()`, and `waitForDisplayed()`. They also
|
|
9
|
-
handle the WebDriver context and waits expected by the built-in surface. Prefer an available model
|
|
10
|
-
over copying internal IOCD selectors into a spec.
|
|
7
|
+
## Inspect the installed version first
|
|
11
8
|
|
|
12
|
-
|
|
9
|
+
The generated test package requests the service's `next` tag, so its available models can change
|
|
10
|
+
between installations. The installed package and lockfile are the source of truth. Before choosing
|
|
11
|
+
or importing a model, read these files from the test package:
|
|
13
12
|
|
|
14
|
-
|
|
15
|
-
|
|
13
|
+
```text
|
|
14
|
+
tests/node_modules/@interopio/wdio-iocd-service/package.json
|
|
15
|
+
tests/node_modules/@interopio/wdio-iocd-service/dist/models/index.d.ts
|
|
16
|
+
```
|
|
16
17
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
| Caption controls | `ChannelSelector`, `CloseButton`, `HtmlButtons`, `ButtonsContainer`, `StandardButton`, `StandardButtons`, `CustomButton`, `CustomButtons` |
|
|
22
|
-
| Sizing and snapping | `SizingBorderGroup`, `SizingBorderOrientation`, `SizingBorder`, `SizingBorderType`, `ApproachingMarker`, and the `NeighborFrames` type |
|
|
18
|
+
Then follow the export to its declaration file for constructor arguments, methods, waits, and
|
|
19
|
+
window-context behavior. Check for a suitable popup or dialog model before writing handle-scanning
|
|
20
|
+
helpers or copying internal selectors. Do not assume that a model shown in another repository or
|
|
21
|
+
service version exists locally, or that a missing model in an older version is still unavailable.
|
|
23
22
|
|
|
24
|
-
|
|
23
|
+
## Web Group example
|
|
24
|
+
|
|
25
|
+
The generated Channel Selector spec demonstrates the `Group` model. After verifying its installed
|
|
26
|
+
signature:
|
|
25
27
|
|
|
26
28
|
```ts
|
|
27
29
|
import { Group } from "@interopio/wdio-iocd-service/models";
|
|
@@ -34,19 +36,10 @@ await frame.captionBar.channelSelector.waitForDisplayed();
|
|
|
34
36
|
await frame.captionBar.channelSelector.click();
|
|
35
37
|
```
|
|
36
38
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
```text
|
|
42
|
-
tests/node_modules/@interopio/wdio-iocd-service/dist/models/index.d.ts
|
|
43
|
-
```
|
|
44
|
-
|
|
45
|
-
Do not copy a model import from another repository or a newer service release without checking that
|
|
46
|
-
it exists locally. Newer versions may add models such as `ChannelSelectorPopup`, `PlatformDialog`,
|
|
47
|
-
`ModalsDialog`, or `Alert`; those names aren't exported by the version currently generated in the
|
|
48
|
-
seed.
|
|
39
|
+
Models hide generated IDs and DOM structure behind semantic operations and provide the waits and
|
|
40
|
+
context switching supported by that version. Read their declarations before mixing model calls
|
|
41
|
+
with raw `browser.switchById()` or `browser.switchToWindow()` calls.
|
|
49
42
|
|
|
50
|
-
Models
|
|
51
|
-
|
|
52
|
-
|
|
43
|
+
Models remain coupled to the matching Desktop and service versions. If no suitable model is
|
|
44
|
+
exported, prefer a stable `data-testid`, role, or other semantic selector and keep any
|
|
45
|
+
version-sensitive selector in one local page object.
|
|
@@ -13,9 +13,9 @@ npm test
|
|
|
13
13
|
```
|
|
14
14
|
|
|
15
15
|
On the first run the CLI installs the nested test package's dependencies, resolves the executable
|
|
16
|
-
from the configured `iocd` component, and passes its path to WebdriverIO via `BINARY_PATH`.
|
|
17
|
-
|
|
18
|
-
|
|
16
|
+
from the configured `iocd` component, and passes its path to WebdriverIO via `BINARY_PATH`. The CLI
|
|
17
|
+
applies the seed's dev modifications before the service launches its own platform instance. If the
|
|
18
|
+
component or executable is missing, run `npm run setup` to install it. The CLI also makes
|
|
19
19
|
`config/iocd.license.key` available to the test platform — keep the license out of specs, config,
|
|
20
20
|
logs, and source control.
|
|
21
21
|
|
|
@@ -32,6 +32,18 @@ version. Fall back to `npx @interopio/iocd-cli@latest test setup` only when the
|
|
|
32
32
|
on the CLI. Setup creates the test package and the agent skill, installs the test dependencies, and validates the
|
|
33
33
|
setup with the TypeScript compiler. On failure it removes the files it created.
|
|
34
34
|
|
|
35
|
+
## Existing test package
|
|
36
|
+
|
|
37
|
+
Check that the test package's `.gitignore` contains `user-data/`. Add that entry if it is missing;
|
|
38
|
+
older scaffolds did not include it. From the seed root, verify it with
|
|
39
|
+
`git check-ignore tests/user-data/Default/Cookies` (substitute the configured `testsDirectory`).
|
|
40
|
+
This path can be checked without creating or reading a cookie file. If the profile is already
|
|
41
|
+
tracked, an ignore rule does not untrack it: remove only that generated directory from the Git index
|
|
42
|
+
with `git rm -r --cached -- tests/user-data`, substituting the configured `testsDirectory` here too
|
|
43
|
+
and keeping its local files. For example, `testsDirectory: "quality/e2e"` requires
|
|
44
|
+
`git rm -r --cached -- quality/e2e/user-data`. Never include profile data in commits or CI artifacts;
|
|
45
|
+
it can contain authenticated sessions.
|
|
46
|
+
|
|
35
47
|
## Replacing an existing test package
|
|
36
48
|
|
|
37
49
|
The CLI never overwrites or migrates an existing test directory. To replace one:
|
|
@@ -46,6 +58,7 @@ The CLI never overwrites or migrates an existing test directory. To replace one:
|
|
|
46
58
|
|
|
47
59
|
```text
|
|
48
60
|
tests/ # test package (Node.js)
|
|
61
|
+
├── .gitignore # excludes the generated user-data profile
|
|
49
62
|
├── package.json
|
|
50
63
|
├── tsconfig.json
|
|
51
64
|
├── wdio.config.ts # WebdriverIO + IOCDService config (platform-wdio runner)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
user-data/
|
|
@@ -30,6 +30,7 @@ npm run typecheck
|
|
|
30
30
|
|
|
31
31
|
- `wdio.config.ts` — WebdriverIO + `IOCDService` configuration (the `platform-wdio` runner).
|
|
32
32
|
- `config/system.json` — test-only overrides applied to the test process.
|
|
33
|
+
- `.gitignore` — excludes the disposable `user-data/` browser profile from version control.
|
|
33
34
|
- `tests/common/iocd.ts` — polling, event-waiting, and cleanup-registry helpers.
|
|
34
35
|
- `tests/config/apps.json` — fixture app definitions.
|
|
35
36
|
- `tests/assets/applications/` — static fixture app HTML.
|
|
@@ -75,7 +76,8 @@ platform, so group specifications by configuration rather than adding a capabili
|
|
|
75
76
|
the variant specifications from the default capability so they don't run twice, keep the default
|
|
76
77
|
capability on the configuration the seed ships, and let a configuration-dependent specification read
|
|
77
78
|
`globalThis.ioCDPlatformConfig` at `describe` time to skip when it isn't running under its own
|
|
78
|
-
capability (`npm test -- --spec` overrides
|
|
79
|
+
capability (`npm test -- --spec` overrides capability spec patterns, but capability exclusions
|
|
80
|
+
still apply).
|
|
79
81
|
|
|
80
82
|
## Sample specifications
|
|
81
83
|
|
|
@@ -117,5 +119,5 @@ package. Use ordinary WebdriverIO selectors for UI owned by your app.
|
|
|
117
119
|
|
|
118
120
|
## More
|
|
119
121
|
|
|
120
|
-
- Full guide:
|
|
122
|
+
- Full guide: [Testing in the io.Connect Desktop seed-project documentation](https://docs.interop.io/desktop/developers/seed-project/index.html#testing).
|
|
121
123
|
- Coding-agent guidance: `.agents/skills/iocd-wdio-testing/` at the seed root.
|
|
@@ -25,16 +25,20 @@ export function createCleanupRegistry() {
|
|
|
25
25
|
async run(): Promise<void> {
|
|
26
26
|
const pending = cleanups.splice(0).reverse();
|
|
27
27
|
let firstError: unknown;
|
|
28
|
+
let failed = false;
|
|
28
29
|
|
|
29
30
|
for (const cleanup of pending) {
|
|
30
31
|
try {
|
|
31
32
|
await cleanup();
|
|
32
33
|
} catch (error) {
|
|
33
|
-
|
|
34
|
+
if (!failed) {
|
|
35
|
+
firstError = error;
|
|
36
|
+
failed = true;
|
|
37
|
+
}
|
|
34
38
|
}
|
|
35
39
|
}
|
|
36
40
|
|
|
37
|
-
if (
|
|
41
|
+
if (failed) {
|
|
38
42
|
throw firstError;
|
|
39
43
|
}
|
|
40
44
|
},
|
|
@@ -59,67 +63,83 @@ export async function waitForEvent<T>(
|
|
|
59
63
|
|
|
60
64
|
let resolveResult!: (value: T) => void;
|
|
61
65
|
let rejectResult!: (error: Error) => void;
|
|
62
|
-
let
|
|
63
|
-
let cleanupRequested = false;
|
|
66
|
+
let cleanupPromise: Promise<void> | undefined;
|
|
64
67
|
let settled = false;
|
|
65
68
|
|
|
66
69
|
const result = new Promise<T>((resolve, reject) => {
|
|
67
70
|
resolveResult = resolve;
|
|
68
71
|
rejectResult = reject;
|
|
69
72
|
});
|
|
73
|
+
// The trigger can fail or outlast the timeout before the caller awaits result.
|
|
74
|
+
// Observe rejection immediately, without changing what awaiting result throws.
|
|
75
|
+
void result.catch(() => undefined);
|
|
70
76
|
|
|
71
|
-
const
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
return;
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
const cleanup = subscriptionCleanup;
|
|
78
|
-
subscriptionCleanup = undefined;
|
|
79
|
-
await cleanup();
|
|
77
|
+
const cleanupSubscription = (): Promise<void> => {
|
|
78
|
+
cleanupPromise ??= subscription.then(cleanup => cleanup());
|
|
79
|
+
return cleanupPromise;
|
|
80
80
|
};
|
|
81
81
|
|
|
82
|
-
const
|
|
82
|
+
const finish = (outcome: { value: T } | { error: Error }): void => {
|
|
83
83
|
if (settled) {
|
|
84
84
|
return;
|
|
85
85
|
}
|
|
86
86
|
|
|
87
87
|
settled = true;
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
88
|
+
clearTimeout(timeout);
|
|
89
|
+
void cleanupSubscription().then(
|
|
90
|
+
() => {
|
|
91
|
+
if ("error" in outcome) {
|
|
92
|
+
rejectResult(outcome.error);
|
|
93
|
+
} else {
|
|
94
|
+
resolveResult(outcome.value);
|
|
95
|
+
}
|
|
96
|
+
},
|
|
97
|
+
(error: unknown) => {
|
|
98
|
+
rejectResult("error" in outcome ? outcome.error : toError(error));
|
|
93
99
|
}
|
|
94
|
-
|
|
95
|
-
}
|
|
100
|
+
);
|
|
101
|
+
};
|
|
96
102
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
103
|
+
const timeout = setTimeout(
|
|
104
|
+
() => finish({ error: new Error(timeoutMsg) }),
|
|
105
|
+
timeoutMs
|
|
106
|
+
);
|
|
107
|
+
// Start in a microtask so even a synchronous callback can wait for the returned
|
|
108
|
+
// unsubscribe function. Success must not hide an error from delayed cleanup.
|
|
109
|
+
const subscription = Promise.resolve().then(() =>
|
|
110
|
+
subscribe(value => {
|
|
111
|
+
if (settled) {
|
|
100
112
|
return;
|
|
101
113
|
}
|
|
102
114
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
try {
|
|
107
|
-
await unsubscribe();
|
|
108
|
-
resolveResult(value);
|
|
109
|
-
} catch (error) {
|
|
110
|
-
rejectResult(error instanceof Error ? error : new Error(String(error)));
|
|
115
|
+
try {
|
|
116
|
+
if (predicate(value)) {
|
|
117
|
+
finish({ value });
|
|
111
118
|
}
|
|
112
|
-
}
|
|
113
|
-
|
|
119
|
+
} catch (error) {
|
|
120
|
+
finish({ error: toError(error) });
|
|
121
|
+
}
|
|
122
|
+
})
|
|
123
|
+
);
|
|
114
124
|
|
|
115
|
-
|
|
116
|
-
|
|
125
|
+
const unsubscribe: Cleanup = async () => {
|
|
126
|
+
if (!settled) {
|
|
127
|
+
settled = true;
|
|
128
|
+
clearTimeout(timeout);
|
|
129
|
+
rejectResult(new Error("Event wait was cancelled."));
|
|
117
130
|
}
|
|
131
|
+
await cleanupSubscription();
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
try {
|
|
135
|
+
await subscription;
|
|
118
136
|
} catch (error) {
|
|
119
|
-
|
|
120
|
-
clearTimeout(timeout);
|
|
121
|
-
rejectResult(error instanceof Error ? error : new Error(String(error)));
|
|
137
|
+
finish({ error: toError(error) });
|
|
122
138
|
}
|
|
123
139
|
|
|
124
140
|
return { result, unsubscribe };
|
|
125
|
-
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function toError(error: unknown): Error {
|
|
144
|
+
return error instanceof Error ? error : new Error(String(error));
|
|
145
|
+
}
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { expect } from "chai";
|
|
2
|
-
import type { IOConnectDesktop } from "@interopio/desktop";
|
|
3
2
|
import { $, browser } from "@wdio/globals";
|
|
4
3
|
import { createCleanupRegistry, waitForEvent } from "./common/iocd";
|
|
5
4
|
|
|
@@ -8,21 +7,19 @@ import { createCleanupRegistry, waitForEvent } from "./common/iocd";
|
|
|
8
7
|
describe("Shared Contexts", () => {
|
|
9
8
|
const contextName = "IOCD.Sample.SelectedClient";
|
|
10
9
|
const clientName = "Ada Lovelace";
|
|
11
|
-
let instances: IOConnectDesktop.AppManager.Instance[] = [];
|
|
12
10
|
let cleanups = createCleanupRegistry();
|
|
13
11
|
|
|
14
12
|
beforeEach(async () => {
|
|
15
13
|
const io = await browser.io();
|
|
16
14
|
cleanups = createCleanupRegistry();
|
|
15
|
+
// Register first so the context is removed after subscriptions and apps.
|
|
16
|
+
cleanups.add(() => io.contexts.destroy(contextName).catch(() => undefined));
|
|
17
17
|
await io.contexts.destroy(contextName).catch(() => undefined);
|
|
18
18
|
});
|
|
19
19
|
|
|
20
20
|
afterEach(async () => {
|
|
21
|
-
|
|
21
|
+
// The registry attempts every cleanup, even when an earlier one fails.
|
|
22
22
|
await cleanups.run();
|
|
23
|
-
await Promise.all(instances.map((instance) => instance.stop().catch(() => undefined)));
|
|
24
|
-
instances = [];
|
|
25
|
-
await io.contexts.destroy(contextName).catch(() => undefined);
|
|
26
23
|
});
|
|
27
24
|
|
|
28
25
|
it("delivers an update from one app instance to another", async () => {
|
|
@@ -30,25 +27,30 @@ describe("Shared Contexts", () => {
|
|
|
30
27
|
const app = io.appManager.application("shared-context-client")!;
|
|
31
28
|
|
|
32
29
|
const publisher = await app.start({}, { title: "Context Publisher" });
|
|
30
|
+
cleanups.add(() => publisher.stop());
|
|
33
31
|
const subscriber = await app.start({}, { title: "Context Subscriber" });
|
|
34
|
-
|
|
32
|
+
cleanups.add(() => subscriber.stop());
|
|
35
33
|
|
|
36
34
|
const publisherWindow = await publisher.getWindow();
|
|
37
35
|
const subscriberWindow = await subscriber.getWindow();
|
|
38
36
|
|
|
39
37
|
// Wait until the receiving app has subscribed before publishing the update.
|
|
40
38
|
await browser.switchById(subscriberWindow.id);
|
|
41
|
-
await browser.waitUntil(
|
|
42
|
-
|
|
43
|
-
|
|
39
|
+
await browser.waitUntil(
|
|
40
|
+
async () => (await $("#context-status").getText()) === "Ready",
|
|
41
|
+
{
|
|
42
|
+
timeoutMsg:
|
|
43
|
+
"Expected the subscriber app to initialize its Shared Context subscription",
|
|
44
|
+
}
|
|
45
|
+
);
|
|
44
46
|
|
|
45
47
|
const contextUpdate = await waitForEvent<{ clientName?: string }>(
|
|
46
|
-
|
|
47
|
-
io.contexts.subscribe(contextName,
|
|
48
|
+
listener =>
|
|
49
|
+
io.contexts.subscribe(contextName, data => {
|
|
48
50
|
listener(data as { clientName?: string });
|
|
49
51
|
}),
|
|
50
52
|
{
|
|
51
|
-
predicate:
|
|
53
|
+
predicate: data => data.clientName === clientName,
|
|
52
54
|
timeoutMsg: `Expected the ${contextName} context to select ${clientName}`,
|
|
53
55
|
}
|
|
54
56
|
);
|
|
@@ -62,10 +64,13 @@ describe("Shared Contexts", () => {
|
|
|
62
64
|
|
|
63
65
|
await browser.switchById(subscriberWindow.id);
|
|
64
66
|
const selectedClient = await $("#selected-client");
|
|
65
|
-
await browser.waitUntil(
|
|
66
|
-
|
|
67
|
-
|
|
67
|
+
await browser.waitUntil(
|
|
68
|
+
async () => (await selectedClient.getText()) === clientName,
|
|
69
|
+
{
|
|
70
|
+
timeoutMsg: `Expected the subscriber app to receive ${clientName}`,
|
|
71
|
+
}
|
|
72
|
+
);
|
|
68
73
|
|
|
69
74
|
expect(await selectedClient.getText()).to.equal(clientName);
|
|
70
75
|
});
|
|
71
|
-
});
|
|
76
|
+
});
|