Skip to main content

Seed Project

Overview

Available since @interopio/iocd-cli 1.0.0

The seed project includes an automated test setup for testing your customized io.Connect Desktop platform and the apps running in it. The setup starts a real platform process in an isolated test environment and enables tests to use both browser automation and the io.Connect APIs.

The generated test setup combines TypeScript, WebdriverIO, Mocha, and Chai with @interopio/wdio-iocd-service. By default, specifications run in the WebdriverIO worker via the platform-wdio runner. The service starts a dedicated io.Connect Desktop instance and connects the test process to it. Tests can:

  • Start apps and use io.Connect APIs.
  • Switch the WebdriverIO browser context to an app window.
  • Interact with the app UI and inspect its DOM.
  • Test communication and workflows involving multiple apps.
  • Import and restore Layouts and Workspaces.
  • Verify platform configuration and behavior.

These tests complement, but don't replace, unit tests. Use unit tests for isolated app logic and end-to-end tests for behavior that depends on a running io.Connect Desktop platform, actual app windows, io.Connect APIs, platform configuration, or communication between apps.

The examples and API descriptions in this page apply to the version of @interopio/wdio-iocd-service installed by the generated test package. The test template requests the service via the moving next tag, so check the installed version and its declarations when adapting these examples.

The paths in this section assume the default testsDirectory value of "tests". If you set a custom testsDirectory in config/iocd.cli.config.json, adjust the paths accordingly. Unless noted otherwise, execute all commands from the seed root after installing the seed dependencies.

Use the dependencies and configuration of the generated /tests package. Examples for installing the service directly outside a seed project may use a different version or setup.

Test Execution

The following diagram demonstrates how the CLI prepares and runs the tests:

npm test
    │
    └── iocd test
            │
            ├── Locates the seed test package and installs its dependencies when necessary.
            ├── Prepares the component with base and development modifications.
            ├── Resolves the executable from the configured iocd component.
            ├── Makes the seed license available to the test platform.
            └── Starts WebdriverIO in the tests package.
                    │
                    └── @interopio/wdio-iocd-service
                            │
                            ├── Selects the ChromeDriver version matching the platform Electron version.
                            ├── Uses @interopio/iocd-launcher to start io.Connect Desktop.
                            ├── Connects WebdriverIO and the test io.Connect API to the platform.
                            ├── Runs the test specifications.
                            └── Stops ChromeDriver and io.Connect Desktop.

With the default platform-wdio runner, specifications run in a separate WebdriverIO worker process. Test hooks, assertions, and the io.Connect API execute in that worker. WebdriverIO controls the app windows via ChromeDriver.

The setup combines public testing tools with libraries provided by interop.io:

Tool Source Description
TypeScript Public Test language and static type checking.
WebdriverIO Public Browser automation, selectors, element interaction, waits, and browser state inspection.
Mocha Public Test suites, test cases, and setup and cleanup hooks.
Chai Public Assertions for API values and app state.
@interopio/iocd-cli interop.io Owns the seed test infrastructure, resolves the seed platform executable and license, and starts the test package.
@interopio/wdio-iocd-service interop.io Integrates io.Connect Desktop with WebdriverIO, manages ChromeDriver, exposes the test io.Connect API, and adds browser commands specific to io.Connect Desktop.
@interopio/iocd-launcher interop.io Resolves, starts, monitors, and stops the io.Connect Desktop process. It is used internally by the WebdriverIO service.
@interopio/desktop interop.io Provides the io.Connect APIs used from tests.
@interopio/workspaces-api interop.io Adds the Workspaces API to the test io.Connect API. Other optional io.Connect libraries can be added in the same way.

The io.Connect CLI and the WebdriverIO service have separate responsibilities. The CLI knows the structure and configuration of the seed project. The service knows how to create a WebdriverIO test session for io.Connect Desktop. Tests should normally be started via the CLI rather than by invoking WebdriverIO directly.

Test Project Structure

The test setup is automatically included in newly generated seed projects:

.
├── .agents/
│   └── skills/
│       └── iocd-wdio-testing/
│           ├── SKILL.md                       # Instructions for compatible coding agents.
│           └── references/                    # Setup, authoring, configuration, and troubleshooting guidance.
├── config/
│   ├── iocd.cli.config.json
│   └── iocd.license.key
├── tests/                                      # Separate Node.js test package.
│   ├── package.json
│   ├── package-lock.json
│   ├── tsconfig.json
│   ├── wdio.config.ts                         # WebdriverIO and io.Connect Desktop service configuration.
│   ├── config/
│   │   └── system.json                        # Platform overrides used only by tests.
│   └── tests/
│       ├── assets/
│       │   └── applications/                  # Static fixture apps used by the specifications.
│       ├── common/
│       │   ├── iocd.ts                        # Polling, event, and cleanup helpers.
│       │   └── open-protocol.ts               # Opens a protocol URL via the operating system.
│       ├── config/
│       │   └── apps.json                      # Definitions for the fixture apps.
│       ├── single-app.spec.ts                 # Single-app UI example.
│       ├── channel-selector.spec.ts           # Built-in Channel Selector UI example.
│       ├── shared-contexts.spec.ts            # Cross-app Shared Contexts example.
│       ├── workspace-interop.spec.ts          # Workspace and multi-app Interop example.
│       ├── interop.spec.ts                    # Test-driven Interop request/response example.
│       ├── protocol-handler.spec.ts           # Interop invocation via a protocol URL.
│       ├── notifications.spec.ts              # Notification Panel example.
│       └── workspace-add-app.spec.ts          # Add an app to a Workspace via the UI popup.
└── package.json

The top-level /tests folder is a separate Node.js package. Paths passed to the underlying WebdriverIO runner are resolved from this package - e.g., the root command npm test -- --spec ./tests/single-app.spec.ts selects <seed>/tests/tests/single-app.spec.ts.

The test system.json registers tests/tests/config/apps.json as a path app store and uses an isolated user data folder. Test runs therefore don't modify the regular user profile of the installed or development platform. It is applied on top of the platform configuration your seed already defines - see Configuration & Isolation.

⚠️ Note that the io.Connect CLI generates the package manifests, TypeScript and WebdriverIO configuration, default fixtures, and examples. The setup command refuses to overwrite an existing test directory. Back up or remove the directory explicitly before creating a fresh test package.

Setting Up Tests

New Seed Project

The test infrastructure is included automatically in a new seed project. Complete the regular seed setup first:

npm install
npm run setup

Run all tests from the seed root:

npm test

On the first run, the CLI installs the dependencies of the nested test package if they aren't already installed. It then resolves the executable from the configured iocd component and supplies its path to the WebdriverIO configuration via the BINARY_PATH environment variable.

The CLI prepares the component before each test run using the same preparation as development mode. You don't need to start npm run dev first. If the component is missing, run npm run setup. Close a running development instance before testing so that the service can manage the test instance.

If you have run npm run apps-dev, the development modifications include the app definitions it copied, so the test platform loads these apps from their development servers. Keep the development servers running while testing.

The CLI also makes the license from config/iocd.license.key available to the isolated test platform. Don't include the license value in specifications, test configuration, logs, or source control.

Missing Test Infrastructure

To add the current test infrastructure to a seed where the test package is missing, execute the following from the seed root:

npx @interopio/iocd-cli@latest test setup

The command creates the test package and Agent Skill, installs the test dependencies, and validates the setup with the TypeScript compiler.

Replacing an Existing Test Package

The CLI doesn't overwrite or migrate an existing test directory. Test packages created before @interopio/iocd-cli 1.0.0 use an older setup that iocd test doesn't support. If you want to replace an older test package with the current setup:

  1. Inspect the existing directory for specifications, assets, app definitions, configuration, scripts, or dependencies that must be retained.
  2. Back up the directory, or remove it if it contains only generated files you don't need.
  3. Run the setup command from the seed root:
npx @interopio/iocd-cli@latest test setup
  1. Transfer any required user-authored files from the backup to the new test package and adapt them to the current configuration.
  2. Type-check the package and run the transferred specifications individually.

If the configured test directory already exists, test setup stops without changing it and instructs you to back it up or remove it first.

Running Tests

Run commands from the seed root so the CLI can prepare the platform executable and license. To run all specifications, execute:

npm test

To run a single specification while developing or debugging it, pass its path with the --spec argument:

npm test -- --spec ./tests/single-app.spec.ts

Arguments after -- are forwarded to WebdriverIO.

ℹ️ For details on runner arguments, see the WebdriverIO CLI documentation.

To type-check the specifications without starting the platform, execute the following from the test package:

cd tests
npm run typecheck

⚠️ Note that invoking npx wdio run ./wdio.config.ts directly from the test package isn't the normal seed workflow. A direct invocation bypasses the CLI preparation and requires you to provide a valid BINARY_PATH and equivalent license setup manually.

Writing Tests

Waiting for App Readiness

The instance.getWindow() method resolves when the app window is available, but this doesn't guarantee that the app has finished initializing. The app may still be initializing IODesktop(), loading optional libraries, registering Interop methods, subscribing to Channels or Shared Contexts, attaching event handlers, or rendering its initial UI.

Before interacting with the app, wait for a signal from the app indicating that the functionality needed by the next action is ready. Suitable signals include:

  • A dedicated DOM status or element.
  • An action control becoming enabled.
  • An Interop method appearing in io.interop.methods().
  • A subscription-ready state exposed by the app.
  • Another observable API state directly related to the behavior under test.

The generated Channel Selector example waits until the fixture app has initialized the Channels API and registered its membership listener:

const window = await instance.getWindow();

await browser.switchById(window.id);
await browser.waitUntil(async () => (await $("#channel-status").getText()) === "Ready", {
    timeoutMsg: "Expected the Channel client to initialize its Channels API"
});

The generated Interop example uses an API-level readiness signal because the next action depends on a method registration:

const methodName = "IOCD.Sample.Add";

await waitFor(() =>
    io.interop.methods().some((method) => method.name === methodName)
);

Choose the narrowest readiness signal that matches the operation the test is about to perform. Don't use a fixed pause to wait for initialization, because a pause makes the test slower without guaranteeing that the app is ready.

Waiting for Results

Use events, WebdriverIO wait commands, browser.waitUntil(), or the generated waitFor() helper. Avoid fixed pauses such as browser.pause(2000), because they make tests slower and less reliable. The following example demonstrates how to wait for completion:

await browser.waitUntil(async () => (await status.getText()) === "Complete", {
    timeout: 5000,
    timeoutMsg: "Expected the operation to complete"
});

When testing an event, subscribe before triggering the action so the test can't miss a fast event.

For event-based behavior, the generated waitForEvent() helper establishes the subscription before the action, applies a predicate, provides a descriptive timeout, and unsubscribes when the event resolves or times out. Register its idempotent unsubscribe function with createCleanupRegistry() so a test failure can't leak the subscription into a later test:

import { createCleanupRegistry, waitForEvent } from "./common/iocd";

let cleanups = createCleanupRegistry();

beforeEach(() => {
    cleanups = createCleanupRegistry();
});

afterEach(async () => {
    await cleanups.run();
});

// In a test, after obtaining `io` and defining `contextName` and `clientName`.
const contextUpdate = await waitForEvent<{ clientName?: string }>(
    (listener) => io.contexts.subscribe(contextName, listener),
    {
        predicate: (data) => data.clientName === clientName,
        timeoutMsg: `Expected ${contextName} to select ${clientName}`
    }
);
cleanups.add(contextUpdate.unsubscribe);

// Subscribe before the user action can publish an update.
await $("#share-client").waitAndClick();

const receivedContext = await contextUpdate.result;
expect(receivedContext.clientName).to.equal(clientName);

Run subscription cleanup before destroying the context or other resource it observes. The cleanup registry runs all registered functions in reverse order, continues if one fails, and rethrows the first cleanup error after attempting the rest.

Saving & Restoring Global Layouts

Global Layout operations have broad defaults. When saving a Global Layout in a test, provide the exact app instance IDs that belong to the scenario. Omitting the instances list may capture other running instances, including the WebdriverIO test client.

Restoring a Global Layout closes visible running instances by default because closeRunningInstances defaults to true. The closeMe option also defaults to true in this case. Set closeMe: false when the test must remain alive while the restore closes and replaces the app instances under test:

const layoutName = `test-orders-${Date.now()}`;
const saveOptions = {
    name: layoutName,
    type: "Global" as const,
    instances: [ordersInstance.id, detailsInstance.id]
};
const restoreOptions = {
    name: layoutName,
    type: "Global" as const,
    closeRunningInstances: true,
    closeMe: false
};

await io.layouts.save(saveOptions);
await io.layouts.restore(restoreOptions);

Use a unique, descriptive Layout name for each test and track it as soon as it is allocated, so afterEach() can remove it even when save or restore fails:

afterEach(async () => {
    const io = await browser.io();
    const layouts = await io.layouts.getAll("Global");

    if (layouts.some((layout) => layout.name === layoutName)) {
        await io.layouts.remove("Global", layoutName);
    }
});

Remove only the Layouts created by the test. Don't clear all user Layouts: the generated test platform uses isolated user data, but tests in the same run still share that test environment and may execute cleanup after a partial failure.

Isolating Tests

Create mutable platform state in beforeEach() or in the test itself. Clean all resources in afterEach():

  • App instances.
  • Windows and Workspaces.
  • Imported Layouts.
  • Interop registrations and event subscriptions.
  • Shared Contexts or other persistent test state.
  • Modified browser time.

Await cleanup operations. Catch cleanup errors only when a resource may legitimately have already been removed.

Using Stable Test Data & Selectors

Prefer dedicated IDs, data-* attributes, roles, or other stable selectors over selectors that depend on presentation or DOM nesting. Give fixture apps and Layouts unique, descriptive names to avoid collisions.

Deterministic input generally produces the easiest failures to reproduce. If a test selects random data, capture the selected value and include it in assertion messages and logs.

Combining APIs & UI Actions

Use io.Connect APIs to create controlled initial state, WebdriverIO to perform the user workflow, and an io.Connect API to verify the resulting platform state. Add a DOM assertion when the visible result is also part of the behavior.

This pattern prevents a test from passing merely because the built-in UI changed visually while the underlying platform operation failed. The generated workspace-add-app.spec.ts demonstrates the complete flow - see Adding an App to a Workspace via the UI.

Use the API only to arrange state and observe results when the purpose is to test a user workflow. Don't bypass the behavior under test by invoking the app action or business logic directly from the test.

Keeping Tests Focused

Test one behavior per test case and run the specification alone while developing it. Extract a shared helper only when behavior is repeated and the helper makes the test intent clearer. Keep generic runtime helpers under /tests/tests/common and workflow-specific helpers close to their specifications.

Examples

The following examples use the fixture apps and helpers generated in /tests/tests. Keep app instances, Workspaces, subscriptions, and imported Layouts in test scope so that cleanup hooks can release them.

Testing a Single App

The generated single-app.spec.ts demonstrates the basic test workflow. Its fixture app contains a button that increments a counter. The test starts the app in io.Connect Desktop, switches WebdriverIO to the app window, clicks the button, and verifies the resulting app state:

import { expect } from "chai";
import type { IOConnectDesktop } from "@interopio/desktop";
import { $, browser } from "@wdio/globals";

describe("Single-app UI", () => {
    let instance: IOConnectDesktop.AppManager.Instance | undefined;

    afterEach(async () => {
        // Stop the app even if the test failed, so the next test starts clean.
        await instance?.stop().catch(() => undefined);
        instance = undefined;
    });

    it("increments the counter when the button is pressed", async () => {
        const io = await browser.io();

        instance = await io.appManager.application("web")!.start();
        const window = await instance.getWindow();

        // Move the WebdriverIO context into the app window before touching its DOM.
        await browser.switchById(window.id);

        await $("#increment-counter").waitAndClick();

        // Wait for the observable result instead of asserting immediately.
        const counter = await $("#counter");
        await browser.waitUntil(async () => (await counter.getText()) === "1", {
            timeoutMsg: "Expected the counter to increment to 1"
        });

        expect(await counter.getText()).to.equal("1");
    });
});

The example contains the main building blocks for an app UI test:

  1. Obtain the io.Connect API with const io = await browser.io() inside a test or hook.
  2. Start the registered app via an io.Connect API.
  3. Retrieve the app window and pass its ID to browser.switchById().
  4. Use standard WebdriverIO selectors and element commands to drive the UI.
  5. Wait for the expected state before checking it with Chai.
  6. Stop the app in afterEach() even when a test fails.

To test one of your own apps, register or expose the app to the test platform, use stable selectors in its UI, and add a focused *.spec.ts file under /tests/tests.

Testing Multiple Apps

The generated workspace-interop.spec.ts demonstrates a more complete integration workflow involving two apps:

  1. The test imports a Workspace Layout containing the people-list and person-details fixture apps.
  2. It restores the Workspace and force-loads both app windows.
  3. It switches WebdriverIO to the list app and selects a random name.
  4. The list app invokes the IOCD.Sample.DisplayPerson Interop method.
  5. The details app handles the invocation and displays the selected name.
  6. The test switches to the details app and verifies its DOM.
  7. The cleanup hook closes the Workspace and removes the imported Layout.

The following excerpt shows the essential test flow:

const io = await browser.io();

workspace = await io.workspaces!.restoreWorkspace(layoutName);
const workspaceWindows = workspace.getAllWindows();
const peopleListWindow = workspaceWindows.find((window) => window.appName === "people-list");
const personDetailsWindow = workspaceWindows.find((window) => window.appName === "person-details");

expect(peopleListWindow, "people-list workspace window").to.exist;
expect(personDetailsWindow, "person-details workspace window").to.exist;

// Force-load both windows so their web content and Interop are running.
await Promise.all([peopleListWindow!.forceLoad(), personDetailsWindow!.forceLoad()]);

await browser.switchById(peopleListWindow!.getGdWindow().id);

const people = $$("[data-person-name]");
const peopleCount = await people.length;
const selectedPerson = await people[Math.floor(Math.random() * peopleCount)];
const expectedName = await selectedPerson.getText();

await selectedPerson.waitForEnabled();
await selectedPerson.click();

// The list app invokes an Interop method that the details app handles.
await browser.switchById(personDetailsWindow!.getGdWindow().id);

const displayedPerson = await $("#selected-person");
await browser.waitUntil(async () => (await displayedPerson.getText()) === expectedName, {
    timeoutMsg: `Expected the details app to display ${expectedName}`
});

expect(await displayedPerson.getText()).to.equal(expectedName);

Fixture app definitions are located in tests/tests/config/apps.json, and their static assets are located under /tests/tests/assets/applications. Add test-only apps there when a workflow requires controlled app behavior. Keep production app definitions and test fixtures separate unless the purpose of the test is to exercise the actual production app package.

The Workspaces API is an optional library. The generated configuration installs @interopio/workspaces-api and lists it in iocd:options.ioInitOptions.libraries. To use another optional io.Connect library:

  1. Add the package to tests/package.json.
  2. Add its module specifier to iocd:options.ioInitOptions.libraries in tests/wdio.config.ts.
  3. Access the resulting API via the runtime io object.

Invoking Interop Methods from a Test

The generated interop.spec.ts shows the request/response Interop pattern driven from the test process itself, rather than only between two apps. The test starts the interop-responder fixture app, which registers the IOCD.Sample.Add Interop method, waits for the method to be published, invokes it, and asserts the returned value:

const io = await browser.io();
const methodName = "IOCD.Sample.Add";

instance = await io.appManager.application("interop-responder")!.start();

// The app registers the method asynchronously, so wait for it before invoking it.
await waitFor(() =>
    io.interop.methods().some((method) => method.name === methodName)
);

const result = await io.interop.invoke(methodName, { a: 2, b: 3 });

expect((result.returned as { sum: number }).sum).to.equal(5);

The waitFor() helper is generated in tests/tests/common/iocd.ts. Use the same wait-for-registration pattern whenever a test invokes a method that an app registers asynchronously.

Invoking Interop Methods via a Protocol URL

Available since @interopio/iocd-cli 2.1.0

The generated protocol-handler.spec.ts verifies that the platform handles an ioconnect://invoke URL opened by the operating system. The test registers an Interop method, opens a protocol URL with arguments for it, and verifies the arguments received by the method:

const io = await browser.io();
const methodName = `IOCD.Seed.Protocol.${randomUUID().replace(/-/g, "")}`;
let receivedArgs: unknown;

// Register the method before opening the URL so that the invocation can't race the registration.
await io.interop.register(methodName, (args) => {
    receivedArgs = args;
    return { received: true };
});
unregister = () => io.interop.unregister(methodName);

const args = new URLSearchParams({
    "args.symbol": "MSFT",
    "args.quantity": "42",
    "args.enabled": "true",
    "args.client.name": "Jane Doe & Co"
});
const url = `ioconnect://invoke/${methodName}?${args}`;

// The protocol handler must invoke the method, so the test doesn't call `io.interop.invoke()`.
await openProtocol(url);
await browser.waitUntil(() => receivedArgs !== undefined, {
    timeout: 15000,
    timeoutMsg: `No Interop invocation received from ${url}.`
});

assert.deepEqual(receivedArgs, {
    symbol: "MSFT",
    quantity: 42,
    enabled: true,
    client: { name: "Jane Doe & Co" }
});

The openProtocol() helper is generated in tests/tests/common/open-protocol.ts. On Windows, it opens the URL via PowerShell. On macOS, it opens the URL with the app bundle of the tested platform. The operating system must associate the protocol with the tested platform, and no other io.Connect Desktop instance may be running. If your seed changes the protocol prefix, update the URL in the specification.

Testing the Channel Selector

The generated channel-selector.spec.ts drives the built-in Channel Selector from the caption of the channel-client fixture app. The test reads the configured Channels with io.channels.all() and selects the first one, so it works with whatever Channel set your seed defines. The fixture app enables the Channels API with IODesktop({ channels: true }) and displays its current Channel whenever onChannelsChanged() fires.

The Channel Selector is part of the Web Group UI, rather than the app DOM. The test therefore uses the Group model exported by @interopio/wdio-iocd-service/models to reach the caption control:

const io = await browser.io();
const [channelName] = await io.channels.all();

instance = await io.appManager.application("channel-client")!.start({}, { mode: "flat" });
const window = await instance.getWindow();

await browser.switchById(window.id);
await browser.waitUntil(async () => (await $("#channel-status").getText()) === "Ready", {
    timeoutMsg: "Expected the Channel client to initialize its Channels API"
});

const group = new Group(window.groupId!);
const [frame] = await group.frames.waitForCount(1);
const channelSelector = frame.captionBar.channelSelector;

await group.switchTo();
await channelSelector.waitForDisplayed();
await channelSelector.click();
await switchToChannelSelectorPopup();

await $(`[data-testid="channel-selector-channel-${channelName}"]`).waitAndClick();

The selector popup is a separate web window, so the generated switchToChannelSelectorPopup() helper scans the available WebDriver handles and switches to the one containing the selector panel. After selecting a Channel, the test switches back to the app with browser.switchById() and waits until the app UI displays the selected Channel. This verifies the user interaction and its effect on the client app, rather than only asserting built-in UI markup.

The example assumes single Channel mode. Channel mode is a platform-wide setting defined by your seed configuration, so testing single and multi Channel behavior requires separate platform launches with the corresponding configuration. See Testing Multiple Platform Configurations.

Testing Shared Contexts

The generated shared-contexts.spec.ts demonstrates data sharing between two instances of the shared-context-client fixture app. Each instance subscribes to the same Shared Context. The test clicks a button in the publisher instance, which calls io.contexts.set(), then switches to the subscriber instance and verifies that it rendered the update:

const io = await browser.io();
const app = io.appManager.application("shared-context-client")!;

const publisher = await app.start({}, { title: "Context Publisher" });
cleanups.add(() => publisher.stop());
const subscriber = await app.start({}, { title: "Context Subscriber" });
cleanups.add(() => subscriber.stop());

const publisherWindow = await publisher.getWindow();
const subscriberWindow = await subscriber.getWindow();

// Wait until the receiving app has subscribed before publishing the update.
await browser.switchById(subscriberWindow.id);
await browser.waitUntil(async () => (await $("#context-status").getText()) === "Ready", {
    timeoutMsg: "Expected the subscriber app to initialize its Shared Context subscription"
});

await browser.switchById(publisherWindow.id);
await $("#share-client").waitAndClick();

await browser.switchById(subscriberWindow.id);
const selectedClient = await $("#selected-client");
await browser.waitUntil(async () => (await selectedClient.getText()) === "Ada Lovelace", {
    timeoutMsg: "Expected the subscriber app to receive Ada Lovelace"
});

Subscribe before triggering an update so the receiving app can't miss it. Shared Contexts are persistent platform state, so the example destroys its test context before and after each test and stops both app instances during cleanup.

Testing Notifications

The generated notifications.spec.ts raises a notification and verifies it in the Notification Panel. It uses the service commands browser.switchToNotificationPanel() and element.waitForChildrenCount():

const io = await browser.io();
const title = "Trade executed";
const body = "AAPL 100 @ 190.25";

await io.notifications.configure({ enable: true });
await io.notifications.raise({ title, body });

// Open the panel before switching the WebdriverIO context to it.
await io.notifications.panel.show();
await browser.switchToNotificationPanel();

const list = await $(".io-notification-list");
await list.waitForChildrenCount(".io-notification", 1);

const notificationTitle = await $(".io-notification .io-notification-body-content .io-title");
expect(await notificationTitle.getText()).to.equal(title);

Notifications are disabled on a fresh platform, so the test enables them with io.notifications.configure() before raising anything and clears them in afterEach().

Adding an App to a Workspace via the UI

The generated workspace-add-app.spec.ts adds an app to a Workspace via the add button of the Workspace frame and the add-application popup, instead of calling the workspace.addWindow() API. It arranges the initial state via the Workspaces API, performs the action via the Workspaces App UI, and verifies the result via the Workspaces API and the DOM of the added app. The popup opens in a separate window, so the switchToAddApplicationPopup() helper defined in the specification scans the available WebDriver handles and switches to the one containing the popup:

const io = await browser.io();

// Start from an empty Workspace so the frame shows the empty-state add button.
workspace = await io.workspaces!.createWorkspace({ children: [] });
await browser.switchById(workspace.frameId);

// Open the add-application popup from the add button of the empty container.
await $(".wsp-empty-container button.add-button").waitAndClick();
await switchToAddApplicationPopup();

// Filter the app list in the popup by the title of the target app.
const search = await $(".add-application #appSearch");
await search.waitForDisplayed();
await search.setValue("Workspace Widget");

// Row markup differs between Workspaces App versions, so match either shape.
await $(
    ".add-application .applicationsList .list-group-item, .add-application li.io-list-item"
).waitAndClick();

// The click adds the app via the UI. Confirm the result via the Workspaces API.
await browser.switchById(workspace.frameId);
await browser.waitUntil(
    async () => {
        const current = (await io.workspaces!.getAllWorkspaces()).find(
            (candidate) => candidate.id === workspace!.id
        );
        return current?.getAllWindows().length === 1;
    },
    { timeoutMsg: "Expected the added app to appear in the workspace" }
);

const current = (await io.workspaces!.getAllWorkspaces()).find(
    (candidate) => candidate.id === workspace!.id
)!;
const [addedWindow] = current.getAllWindows();
expect(addedWindow.appName).to.equal("workspace-widget");

// Switch to the added window and verify that its UI has loaded.
await addedWindow.forceLoad();
await browser.switchById(addedWindow.getGdWindow().id);
await $("#widget-ready").waitForDisplayed();

The workspace-widget fixture app is marked with customProperties.includeInWorkspaces, so it appears in the add-application popup. The popup DOM belongs to the Workspaces App, so its selectors can change between platform versions. Prefer the workspace.addWindow() API when you only need to arrange Workspace state rather than exercise the popup UI itself.

Available Test APIs

WebdriverIO

Use standard WebdriverIO APIs for browser automation:

  • $() and $$() for locating elements.
  • Element commands such as click(), setValue(), getText(), waitForDisplayed(), and waitForEnabled().
  • Browser commands such as waitUntil() and getWindowHandles().
  • WebdriverIO configuration hooks, reporters, and CLI filters.

ℹ️ For details on browser and element commands, see the WebdriverIO documentation.

Test Framework & Assertions

The generated setup uses Mocha and Chai:

  • describe() groups related behavior.
  • it() defines a test case.
  • beforeEach() creates isolated state for each test.
  • afterEach() cleans resources even after a failed test.
  • expect() from Chai verifies API values and app state.

Import the Chai assertion explicitly:

import { expect } from "chai";

io.Connect APIs

The WebdriverIO service creates the test io.Connect API before runtime hooks and test cases execute. Obtain it inside a test or hook with browser.io():

const io = await browser.io();

Call browser.io() only inside it(), beforeEach(), afterEach(), or other runtime hooks. The io.Connect API isn't available while Mocha is evaluating a describe() callback.

Use the io.Connect APIs for behavior that belongs to the platform rather than the browser, such as:

  • Starting and stopping apps.
  • Invoking or registering Interop methods.
  • Reading and updating Shared Contexts or Channels.
  • Opening and managing windows.
  • Importing, restoring, and removing Layouts.
  • Creating and manipulating Workspaces.

The fully resolved platform configuration is available synchronously as globalThis.ioCDPlatformConfig when configuration-dependent test suites must be created at describe() time.

WebdriverIO Commands for io.Connect Desktop

IOCDService() adds commands to the WebdriverIO browser and element objects before the specifications execute. These commands complement the standard WebdriverIO API with operations for accessing the test io.Connect API, selecting io.Connect Windows, controlling the clock in the active app window, and waiting for common UI conditions.

The browser commands available in the generated platform-wdio setup are:

Command Description
browser.io() Returns the test io.Connect API in the platform-wdio runner. Call it inside a test or hook, then use the returned io object.
browser.switchById(targetId) Switches the active WebDriver context to an app window, Web Group, or Workspace frame with the specified io.Connect Window ID.
browser.switchToLaunchpad() Switches the active WebDriver context to the running Launchpad window.
browser.switchToNotificationPanel() Switches the active WebDriver context to the open Notification Panel window.
browser.travelTime(offsetMs) Shifts Date and Date.now() by the specified number of milliseconds in the active browser context.
browser.restoreTime() Restores the original Date implementation in the active browser context after travelTime().

Switching Between io.Connect Windows

Standard WebdriverIO commands operate on one active WebDriver window handle. The io.Connect APIs, however, identify app windows and platform UI windows with io.Connect Window IDs. The browser.switchById() command maps an io.Connect Window ID to its WebDriver handle and makes that window the active context.

Use it after starting an app and obtaining its io.Connect Window:

const io = await browser.io();
const instance = await io.appManager.application("web")!.start();
const window = await instance.getWindow();

await browser.switchById(window.id);
await $("#increment-counter").waitAndClick();

The target may also be an underlying window from a Workspace:

const workspaceWindow = workspace.getAllWindows()[0];

await workspaceWindow.forceLoad();
await browser.switchById(workspaceWindow.getGdWindow().id);

The command scans the available WebDriver handles until it finds the io.Connect Window ID and waits up to five seconds by default. It throws if the target isn't found. It doesn't start an app, restore a Workspace, or open a platform UI window. The target must already exist. The target must also be a web-based io.Connect Window exposed to ChromeDriver. This command doesn't provide UI automation for native app windows.

The browser.switchToLaunchpad() and browser.switchToNotificationPanel() commands are shortcuts for platform UI windows whose app names are known to the service:

await browser.switchToLaunchpad();
// Interact with elements in the Launchpad.

// Open the Notification Panel via the app workflow first.
await browser.switchToNotificationPanel();
// Interact with elements in the Notification Panel.

These commands only switch the active context: switchToLaunchpad() requires the Launchpad to be running, and switchToNotificationPanel() requires the Notification Panel to be open. Both commands throw if the respective window isn't available.

Controlling Time in an App Window

The browser.travelTime() command patches the JavaScript Date constructor and Date.now() in the active browser context. The offset is in milliseconds and may be positive or negative. Use it for UI behavior based on the current date or time:

await browser.switchById(window.id);

try {
    await browser.travelTime(5 * 60 * 1000);
    // Verify UI behavior five minutes in the future.
} finally {
    await browser.restoreTime();
}

Time travel affects only the active browser context. It doesn't change the platform clock, other app windows, or the scheduling of timers such as setTimeout(). Restore time before switching away from the window, or switch back to the affected window before calling browser.restoreTime().

Waiting for Elements

The service also adds the following element commands:

Command Description
element.waitAndClick(options?) Waits for the element to exist, be displayed, and be clickable, and then clicks it. The options.timeout value defaults to 5000 milliseconds.
element.waitForChildrenCount(selector, count, options?) Waits until the element has exactly count child elements matching selector. The options.timeout value defaults to 5000 milliseconds.

The following example demonstrates how to wait for a button and a list of items:

const options = { timeout: 10000 };

await $("#save").waitAndClick(options);
const orders = await $("#orders");
await orders.waitForChildrenCount("li", 5, options);

Both commands use WebdriverIO waits and throw a timeout error when the expected condition isn't reached.

Built-In io.Connect Desktop UI Models

The @interopio/wdio-iocd-service/models entry point provides page objects for built-in web UI owned by io.Connect Desktop, such as Web Groups, window headers, tabs, Channel Selectors, and window controls. Use ordinary WebdriverIO selectors for UI owned by your client apps.

The models provide operations for element lookup, window switching, and waiting, such as group.switchTo(), group.frames.waitForCount(), tab.close(), and waitForDisplayed(). Prefer an available model over copying internal platform selectors into a specification.

The following table lists the models and helpers exported by the service:

Area Models and Helpers
Channel Selector popup ChannelSelectorPopup, ChannelRow, ChannelRows
Dialogs and overlays PlatformDialog, ModalsDialog, DialogWindows, FooterButtons, Alert, ModalOverlay, ModalOverlays
Group and frames Group, Frame, WebGroupElement, HtmlNCArea
Captions and tabs CaptionBar, Caption, Editor, MoveArea, TabBar, TabHeader, Tab, Tabs, PinnedTabs
Caption controls ChannelSelector, CloseButton, DownloadsButton, HtmlButtons, ButtonsContainer, StandardButton, StandardButtons, CustomButton, CustomButtons
Download Manager DownloadManagerPanel, DownloadManagerPopup, DownloadManagerWindow, DownloadItemRow, DownloadItemRows, DownloadSettingsPanel
Sizing and snapping SizingBorderGroup, SizingBorderOrientation, SizingBorder, SizingBorderType, ApproachingMarker
Base classes and helpers BrowserPage, WebElement, WebCollection, KeyedCollection, SelectorElement, ButtonElement, InputElement, ToggleElement, ActionButtons, MouseDrag, waitForValue

The entry point also exports related enums and types, such as MouseButton, MoveAreaEdge, and NeighborFrames.

Import the models from the package subpath:

import { Group } from "@interopio/wdio-iocd-service/models";

const group = new Group(window.groupId!);
const [frame] = await group.frames.waitForCount(1);

await group.switchTo();
await frame.captionBar.channelSelector.waitForDisplayed();
await frame.captionBar.channelSelector.click();

Model exports vary by service version. Check tests/node_modules/@interopio/wdio-iocd-service/dist/models/index.d.ts if a model isn't available in your installed package.

Models reduce direct coupling to built-in markup, but remain coupled to the matching io.Connect Desktop and WebdriverIO service versions. If no suitable model exists, prefer stable data-testid, role, or other semantic selectors and isolate any version-sensitive built-in selector in a local page object.

Configuration & Isolation

The generated tests/wdio.config.ts composes IOCDBaseConfiguration with IOCDService() and defines one platform-wdio capability. The capability receives the executable from process.env.BINARY_PATH, which is set by the CLI.

The generated configuration also defines:

Name Description
BINARY_PATH Absolute path to the executable from the iocd component configured in the seed project. Supplied by iocd test.
WDIO_ROOT_DIR Absolute path to the test package. Used by test configuration placeholders.
WDIO_TESTS_ROOT Absolute path to /tests/tests. Used by fixture app definitions and assets.
iocd:options.configOverrides Additional platform configuration applied only to the test process.
iocd:options.ioInitOptions APIs and optional libraries used to construct the test io.Connect API.

Test preparation starts from the installed component, applies the selected modification files, and then adds test overrides:

Layer Owner Location
Default system configuration io.Connect Desktop Shipped inside the installed iocd component.
Seed modifications Your seed project /modifications/base followed by /modifications/dev. JSON merge files preserve unspecified properties, while ordinary files replace their targets (see System Configuration).
Test overrides Your test package tests/config/system.json, listed in iocd:options.configOverrides and applied only to the test process.

Tests use base and development modifications. Production builds use base and build modifications, so the configurations can differ. Put shared behavior in base modifications and explicitly validate differences introduced for production. These tests don't replace checks of the packaged distribution.

Keep test overrides focused on fixture app stores, isolated user data and cache folders, logs, crash dumps, and settings such as disabling auto updates for a test run. Keep the platform behavior you intend to validate in the seed modifications. To test another supported configuration, define a separate platform launch as described in Testing Multiple Platform Configurations. For interactive authentication, automate the configured login as described in Automating the Platform SSO Login.

The generated test platform uses a dedicated /tests/user-data folder. Treat it as disposable test output and don't depend on state left by a previous run. The generated tests/.gitignore file excludes it from source control. Don't point tests at the normal user data folder or mutate developer machine state that can't be restored during cleanup.

The default and recommended runner is platform-wdio. The service also supports platform-window, platform-node, and standalone for specialized scenarios. These modes have different execution and bundling constraints and aren't required for normal seed app testing.

ℹ️ For details on other runner modes, see the package documentation installed with @interopio/wdio-iocd-service.

Testing Multiple Platform Configurations

Platform-wide settings - the Channel mode, the window group type, the default dialogs, the io.Connect Gateway - are fixed for the lifetime of a platform process. A test can't switch them at runtime, so a test that requires a configuration different from the one your seed ships needs its own platform launch.

Define one WebdriverIO capability per platform configuration in the generated tests/wdio.config.ts. Each capability starts its own io.Connect Desktop instance with its own configuration overrides and runs only the specifications written for it:

  1. Add a configuration file next to the base one (e.g., tests/config/system.multi-channel.json) containing only the keys that differ:
{
    "windows": {
        "channelSelector": {
            "type": "multi"
        }
    }
}
  1. Place the specifications that require it in a dedicated folder (e.g., /tests/tests/multi-channel).

  2. Add a capability for that configuration. It uses the same executable, runner, and ioInitOptions as the default one, and differs only in configOverrides and the specifications it runs:

// Extend the generated tests/wdio.config.ts configuration.
const ioInitOptions = {
    appManager: "full" as const,
    layouts: "full" as const,
    channels: true,
    libraries: ["@interopio/workspaces-api"]
};
const baseConfig = path.resolve(rootDir, "config/system.json");
const variantConfig = path.resolve(rootDir, "config/system.multi-channel.json");
const variantSpecs = ["./tests/multi-channel/**/*.spec.ts"];

const capability = (specs: string[], configOverrides: string[], exclude: string[] = []) => ({
    browserName: "iocd",
    "iocd:runner": "platform-wdio" as const,
    "iocd:options": { binary, ioInitOptions, configOverrides },
    // Group the specifications in one worker for this configuration.
    "wdio:specs": [specs],
    "wdio:exclude": exclude
});

export const config: WebdriverIO.Config = {
    // Retain the other generated configuration properties.
    capabilities: [
        capability(["./tests/**/*.spec.ts"], [baseConfig], variantSpecs),
        capability(variantSpecs, [baseConfig, variantConfig])
    ]
};

Observe the following rules when defining additional capabilities:

  • Exclude the variant specifications from the default capability with wdio:exclude. Otherwise, they also run under the default configuration, which they weren't written for.
  • Keep the default capability aligned with the base and development configuration you intend to test. Additional capabilities are for the other configurations your product supports, not a way to make a failing test pass. Settings that all your users receive belong in the seed modifications.
  • Capabilities run sequentially, because the service sets maxInstances to 1. Each one starts and stops a platform instance, so group the specifications by configuration instead of adding a capability per specification file.
  • The --spec argument replaces the spec patterns of all capabilities, but each capability still applies its wdio:exclude patterns. In the example above, npm test -- --spec ./tests/single-app.spec.ts runs that specification under both capabilities, while npm test -- --spec ./tests/multi-channel/channels.spec.ts runs it only under the variant capability, because the default capability excludes it. Write configuration-dependent specifications so that they detect the configuration they run under.

To detect the active configuration, read globalThis.ioCDPlatformConfig. It holds the resolved system configuration and, unlike the io.Connect API, is available synchronously at describe() time, so it can also drive skipping or test generation:

type PlatformConfigView = {
    windows?: { channelSelector?: { type?: "single" | "multi" } };
};

const selectorType = (globalThis as { ioCDPlatformConfig?: PlatformConfigView })
    .ioCDPlatformConfig?.windows?.channelSelector?.type;

describe("Multi Channel selection", () => {
    before(function () {
        if (selectorType !== "multi") {
            this.skip();
        }
    });

    // Register tests that require the multi Channel configuration.
});

Don't treat a missing value as a disabled setting. A test asserting that something doesn't happen would then pass under every configuration, including the one where the behavior is expected. Skip explicitly, or fail with a message identifying the configuration that was found.

Automating the Platform SSO Login

Available since io.Connect Desktop 10.4 & @interopio/wdio-iocd-service 1.0.0-next.3

If your platform is configured with an interactive login (the ssoAuth system configuration), io.Connect Desktop displays a login page during startup and reports readiness only after a user signs in. The test platform starts with the same configuration, so without automating the sign-in, the test run waits on the login window until the startup timeout expires and no test executes.

The login option of IOCDService() automates the sign-in. While the platform is starting, the service finds the login window, drives it with the handler you provide, and continues the run once the platform becomes ready. The following example demonstrates how to sign in with a dedicated test account:

import {
    IOCDBaseConfiguration,
    IOCDService,
    type LoginBrowser
} from "@interopio/wdio-iocd-service";

const loginHandler = async (loginBrowser: LoginBrowser) => {
    const username = loginBrowser.$("#username");
    const password = loginBrowser.$("#password");

    await loginBrowser.waitUntil(
        async () => (await username.isExisting()) && (await password.isExisting()),
        { timeout: 30000 }
    );

    await username.setValue(process.env.IOCD_TEST_USER!);
    await password.setValue(process.env.IOCD_TEST_PASSWORD!);
    await loginBrowser.$("button[type=submit]").click();
};

const serviceOptions = {
    login: {
        window: { url: /login\.mycompany\.com/ },
        handler: loginHandler
    }
};

export const config: WebdriverIO.Config = {
    ...IOCDBaseConfiguration,
    services: IOCDService(serviceOptions)
    // Retain the capabilities and other generated configuration.
};

The login option has the following properties:

Property Type Description
handler (browser) => Promise<void> Drives the login page via the restricted LoginBrowser API. Full WebdriverIO browser and element APIs aren't available during login.
onNoLoginWindow "skip" | "fail" What to do when the platform becomes ready without showing a login window - e.g., when the test user data still holds a valid session. Defaults to "skip".
readyTimeoutMs number Budget in milliseconds for the platform to become ready after the handler has completed. Guards against a handler that finishes without actually signing in. Defaults to 120000.
timeoutMs number Budget in milliseconds for finding the login window and running the handler. Defaults to 120000.
window object How to recognize the login window among the startup windows. The url and title properties accept a substring or a regular expression, and at least one of them is required when window is set. Matching by url is the recommended approach for identity provider pages. If window is omitted, the platform SSO app window is used.

Consider the following when automating the login:

  • The handler runs while the platform is still starting: the io.Connect APIs and browser.io() aren't available yet - only the login API is available.
  • The identity provider session belongs to the platform process and is available to apps started by the tests.
  • Provide the credentials of a dedicated test account via environment variables or CI secrets. Never store credentials in specifications or configuration files.
  • Any login failure - a login window that never appears, a handler that throws or exceeds timeoutMs, or a platform that doesn't become ready within readyTimeoutMs after the handler - stops the platform and reports the specifications as failed with the reason. The login is never retried.
  • Multi-factor authentication prompts and native OS dialogs, such as integrated Windows authentication or client certificate pickers, can't be automated with WebDriver. Use a test account exempt from such factors, or a non-interactive authentication configuration for the test platform.
  • The login option also accepts a function receiving the WebdriverIO capability and returning the login options, so capabilities with different configurations can sign in differently or not at all.

The login browser supports $(), getUrl(), getTitle(), waitUntil(), and execute(). Its elements support setValue(), click(), getText(), getValue(), isExisting(), and waitUntil(). Use waitUntil() with isExisting() to wait for a form. The waitForExist() element method and $$() collections aren't available during login. The regular WebdriverIO APIs become available after startup.

Troubleshooting

Missing Platform Executable

If the CLI reports that the platform executable is missing, ensure that the configured iocd component is installed. Run npm run setup to install or repair the component. The CLI prepares it automatically before running tests. Close the development platform before running npm test so the test service can start its isolated platform instance.

License Prompt or License Failure

Run tests from the seed root via npm test so the CLI can use config/iocd.license.key. In CI, provide the license via the secure mechanism used by your seed setup. Never hard-code a license in a specification or commit it to an additional test fixture.

Platform Startup Timeout

Temporarily set iocd:options.enableLogging to true in tests/wdio.config.ts and inspect the platform output. Check for invalid configuration paths, license failures, gateway port conflicts, and stale test user data before increasing startupTimeoutMs.

If a login window appears and stays open during startup, your platform is configured with an interactive SSO login and startup is blocked until a user signs in. Automate the sign-in with the login option - see Automating the Platform SSO Login.

Missing io.Connect API

If browser.io() reports that the API isn't available, check that it is being called inside a runtime hook or test case rather than at module or describe() scope.

If an optional API such as io.workspaces is missing, verify that its package is installed in tests/package.json and its module specifier is listed in ioInitOptions.libraries.

Flaky UI Test

Run only the failing specification, replace fixed delays with observable waits, subscribe before actions, and verify that cleanup is awaited. Use a useful timeoutMsg so CI output identifies the state that wasn't reached.

Optional video recording can be enabled via IOCDService({ videoRecording: { ... } }). Videos are retained for failed specifications and can help diagnose UI failures that aren't reproducible locally.

CI/CD

To run the tests in CI, the runner must have:

  • A supported Node.js and NPM version.
  • The seed dependencies installed.
  • The configured iocd component available.
  • A valid license supplied via CI secrets or the license configuration of the seed project.
  • Permission to start desktop processes and ChromeDriver.
  • No development app definitions copied by npm run apps-dev in /modifications/dev, unless the workflow also starts the development servers. The generated workflow doesn't start them.

Run focused specifications during development, but run the full suite as the merge or release gate. Preserve useful failure artifacts such as reporter output, platform logs, screenshots, and optional videos. Never publish the license, unredacted user data, or other secrets as CI artifacts.

Available since @interopio/iocd-cli 2.1.0

The generated build workflow runs npm test before building on Windows and macOS. Its run_tests input defaults to true. For older or custom workflows, add a test step after installing dependencies and preparing the platform components.

Using Coding Agents

Generated seed projects include an Agent Skill at .agents/skills/iocd-wdio-testing/SKILL.md. Compatible coding agents can discover this skill when asked to set up, write, run, or debug io.Connect Desktop tests.

The skill contains project-specific instructions and references for:

  • Setting up missing test infrastructure via the CLI.
  • Replacing an existing test package only after explicit user approval.
  • Registering test apps and adding test assets.
  • Using WebdriverIO and the browser.io() API correctly.
  • Adding optional io.Connect libraries.
  • Applying synchronization and cleanup patterns.
  • Running focused type checks and specifications.
  • Troubleshooting common platform and test failures.

Example requests to a coding agent include:

Set up the io.Connect Desktop test infrastructure in this seed.

Write a test that starts MyApp, submits its login form, and verifies the signed-in state.

Add a Workspace test for communication between the Orders and Details apps.

Debug the failing io.Connect Desktop WebdriverIO specification and run only that specification while iterating.

The skill instructs agents to inspect an existing test package and request explicit approval before backing it up, removing it, or replacing it. Review generated test behavior, selectors, cleanup, and configuration like any other code change, and don't allow an agent to expose license values or other secrets.

Updating Agent Skills

Available since @interopio/iocd-cli 1.1.0

Agent Skills are copied into the seed project when it is created, so they don't update automatically when a newer version becomes available. To replace all Agent Skills managed by the io.Connect CLI with the latest published versions, execute the following command from the seed root:

npx @interopio/iocd-cli@latest skills update

The command completely replaces each skill shipped by the CLI, including its SKILL.md file and references. This removes stale files from CLI-managed skill directories and preserves skills that aren't shipped by the CLI.

Each replacement is copied to a temporary location before the existing skill is changed. If publishing the replacement fails, the CLI restores the previous skill or retains its backup and reports its location. The CLI also prevents concurrent skills operations in the same seed project and recovers abandoned operation locks automatically.

The command reports its progress in the terminal and writes detailed information about discovered skills, copied paths, lock handling, rollback, and failures to the standard io.Connect CLI log. The CLI displays the log file location when it starts.

⚠️ Note that any changes made directly in a CLI-managed skill directory will be overwritten. Commit or back up changes you want to retain before updating the skills.