diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6528c84..d8ad8a4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -5,7 +5,7 @@ on: branches: - main tags: - - 'v*' + - "v*" pull_request: branches: - main @@ -20,8 +20,8 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: 'lts/*' - cache: 'npm' + node-version: "lts/*" + cache: "npm" - name: Install dependencies run: npm ci @@ -38,8 +38,8 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: '24.15.0' - cache: 'npm' + node-version: "24.15.0" + cache: "npm" - name: Install dependencies run: npm ci @@ -49,16 +49,33 @@ jobs: timeout-minutes: 5 - name: Test - run: npm run test + run: | + npx tsc -p tsconfig.test.json + npm run build:test-react-bundle + npx wtr + + edge-integration: + runs-on: windows-latest + steps: + - name: Checkout repo + uses: actions/checkout@v4 - - name: Upload coverage artifact - if: always() - uses: actions/upload-artifact@v4 + - name: Setup Node.js + uses: actions/setup-node@v4 with: - name: coverage-report - path: coverage - if-no-files-found: ignore - retention-days: 1 + node-version: "24.15.0" + cache: "npm" + + - name: Install dependencies + run: npm ci + + - name: Run Edge integration test + env: + EMBEDDED_WEB_TEST_BROWSERS: chromium + EMBEDDED_WEB_CHROMIUM_CHANNEL: msedge + run: | + npx tsc -p tsconfig.test.json + npx wtr .tmp/test-dist/test/corti-embedded.integration.test.js build: runs-on: ubuntu-latest @@ -69,8 +86,8 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: 'lts/*' - cache: 'npm' + node-version: "lts/*" + cache: "npm" - name: Install dependencies run: npm ci @@ -79,7 +96,7 @@ jobs: run: npm run prerelease publish: - needs: [typecheck, test, build] + needs: [typecheck, test, edge-integration, build] if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') runs-on: ubuntu-latest permissions: @@ -92,9 +109,9 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: 'lts/*' - cache: 'npm' - registry-url: 'https://registry.npmjs.org' + node-version: "lts/*" + cache: "npm" + registry-url: "https://registry.npmjs.org" - name: Install dependencies run: npm ci diff --git a/README.md b/README.md index 6902576..4f8c9ec 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,48 @@ A web component and React component library that provides an embedded interface npm install @corti/embedded-web ``` +## Testing + +Install dependencies before running tests: + +```bash +npm install +``` + +Run the full test suite: + +```bash +npm test +``` + +Run tests in watch mode while developing: + +```bash +npm run test:watch +``` + +Run only the embedded component communication integration test: + +```bash +npx tsc -p tsconfig.test.json && npx wtr .tmp/test-dist/test/corti-embedded.integration.test.js +``` + +Run the integration test across Chromium, Firefox, and WebKit: + +```bash +npx tsc -p tsconfig.test.json && EMBEDDED_WEB_TEST_BROWSERS=chromium,firefox,webkit npx wtr .tmp/test-dist/test/corti-embedded.integration.test.js +``` + +Run the integration test against an installed Microsoft Edge channel: + +```bash +npx tsc -p tsconfig.test.json && EMBEDDED_WEB_TEST_BROWSERS=chromium EMBEDDED_WEB_CHROMIUM_CHANNEL=msedge npx wtr .tmp/test-dist/test/corti-embedded.integration.test.js +``` + +The integration test runs in Playwright Chromium by default and can be expanded to Firefox, WebKit, or an installed Edge channel. It validates the public web component API against a real iframe `postMessage` boundary and uses the local test runner setup, so it does not require access to the live embedded Assistant service. + +Pull requests also run the integration test against Microsoft Edge on a Windows GitHub Actions runner. + ## Usage ### Web Component diff --git a/test/corti-embedded.integration.test.ts b/test/corti-embedded.integration.test.ts new file mode 100644 index 0000000..3241709 --- /dev/null +++ b/test/corti-embedded.integration.test.ts @@ -0,0 +1,340 @@ +import { expect, fixture } from "@open-wc/testing"; +import { html } from "lit"; +import type { CortiEmbedded } from "../src/CortiEmbedded.js"; +import "../src/corti-embedded.js"; +import type { + ConfigurePayload, + ConfigureApplicationPayload, + CreateInteractionPayload, + Fact, + KeycloakTokenResponse, + NavigatePayload, + SessionConfig, + SetCredentialsPayload, + SetInteractionOptionsPayload, +} from "../src/web-index.js"; + +interface EmbeddedEventDetail { + name: string; + payload: unknown; +} + +interface RequestReceivedPayload { + action: string; + payload?: unknown; + hasRequestId: boolean; + version: string; +} + +const integrationBaseURL = "https://assistant.integration.corti.app"; + +const authPayload: KeycloakTokenResponse = { + access_token: "integration-token", + token_type: "Bearer", +}; + +const createInteractionPayload: CreateInteractionPayload = { + assignedUserId: "integration-user", + encounter: { + identifier: "integration-encounter", + status: "planned", + type: "first_consultation", + period: { + startedAt: "2026-07-09T00:00:00.000Z", + }, + title: "Integration Encounter", + }, + patient: { + identifier: "integration-patient", + name: "Integration Patient", + }, +}; + +const configureAppPayload: ConfigureApplicationPayload = { + debug: true, + ui: { navigation: true }, + appearance: { primaryColor: "#0055ff" }, +}; + +const configurePayload: ConfigurePayload = { + debug: false, + features: { aiChat: false }, + appearance: { primaryColor: null }, +}; + +const sessionConfig: SessionConfig = { + defaultLanguage: "en", + defaultMode: "virtual", +}; + +const factsPayload: Fact[] = [ + { + text: "Patient reports chest pain", + group: "subjective", + }, +]; + +const interactionOptionsPayload: SetInteractionOptionsPayload = { + mode: { + fallback: "virtual", + options: ["virtual"], + }, + spokenLanguage: { + fallback: "en", + }, +}; + +const credentialsPayload: SetCredentialsPayload = { + password: "integration-password", +}; + +const navigatePayload: NavigatePayload = { + path: "/summary", +}; + +function waitForEmbeddedEvent( + el: CortiEmbedded, + eventName: string, + timeoutMs = 2000, +): Promise> { + return new Promise((resolve, reject) => { + let timeoutId: ReturnType; + + const listener = (event: Event) => { + const customEvent = event as CustomEvent; + if (customEvent.detail?.name !== eventName) { + return; + } + + clearTimeout(timeoutId); + el.removeEventListener("event", listener); + resolve(customEvent); + }; + + timeoutId = setTimeout(() => { + el.removeEventListener("event", listener); + reject(new Error(`Timed out waiting for ${eventName}`)); + }, timeoutMs); + + el.addEventListener("event", listener); + }); +} + +async function mountEmbeddedIntegration(): Promise { + const el = await fixture( + html``, + ); + + await waitForEmbeddedEvent(el, "embedded.ready"); + return el; +} + +async function captureRequest( + el: CortiEmbedded, + requestEvents: RequestReceivedPayload[], + callback: () => Promise, +): Promise { + const requestEvent = waitForEmbeddedEvent(el, "test.request-received"); + const [event, result] = await Promise.all([requestEvent, callback()]); + requestEvents.push(event.detail.payload as RequestReceivedPayload); + return result; +} + +async function withoutConsoleWarn(callback: () => Promise): Promise { + const originalWarn = console.warn; + console.warn = () => {}; + + try { + return await callback(); + } finally { + console.warn = originalWarn; + } +} + +describe("CortiEmbedded browser integration", () => { + it("round-trips public API calls through a real iframe", async () => { + const el = await mountEmbeddedIntegration(); + const requestEvents: RequestReceivedPayload[] = []; + + const user = await captureRequest(el, requestEvents, () => + el.auth(authPayload), + ); + expect(user).to.deep.equal({ + id: "integration-user", + email: "integration@example.test", + }); + + const interaction = await captureRequest(el, requestEvents, () => + el.createInteraction(createInteractionPayload), + ); + expect(interaction).to.deep.equal({ + id: "integration-interaction", + createdAt: "2026-07-09T00:00:00.000Z", + }); + + await withoutConsoleWarn(() => + captureRequest(el, requestEvents, () => + el.configureSession(sessionConfig), + ), + ); + + await captureRequest(el, requestEvents, () => el.addFacts(factsPayload)); + + await captureRequest(el, requestEvents, () => el.navigate(navigatePayload)); + + await captureRequest(el, requestEvents, () => el.startRecording()); + + await captureRequest(el, requestEvents, () => el.stopRecording()); + + const configureAppResponse = await captureRequest(el, requestEvents, () => + el.configureApp(configureAppPayload), + ); + expect(configureAppResponse.ui.navigation).to.equal(true); + + const configureResponse = await withoutConsoleWarn(() => + captureRequest(el, requestEvents, () => el.configure(configurePayload)), + ); + expect(configureResponse.features.aiChat).to.equal(false); + + await captureRequest(el, requestEvents, () => + el.setInteractionOptions(interactionOptionsPayload), + ); + + await captureRequest(el, requestEvents, () => + el.setCredentials(credentialsPayload), + ); + + const status = await captureRequest(el, requestEvents, () => + el.getStatus(), + ); + expect(status.auth.isAuthenticated).to.equal(true); + expect(status.currentUrl).to.equal("/summary"); + + const templates = await captureRequest(el, requestEvents, () => + el.getTemplates(), + ); + expect(templates.templates[0].id).to.equal("integration-template"); + + expect( + requestEvents.map(({ action, hasRequestId, version }) => ({ + action, + hasRequestId, + version, + })), + ).to.deep.equal([ + { + action: "auth", + hasRequestId: true, + version: "v1", + }, + { + action: "createInteraction", + hasRequestId: true, + version: "v1", + }, + { + action: "configureSession", + hasRequestId: true, + version: "v1", + }, + { + action: "addFacts", + hasRequestId: true, + version: "v1", + }, + { + action: "navigate", + hasRequestId: true, + version: "v1", + }, + { + action: "startRecording", + hasRequestId: true, + version: "v1", + }, + { + action: "stopRecording", + hasRequestId: true, + version: "v1", + }, + { + action: "configureApp", + hasRequestId: true, + version: "v1", + }, + { + action: "configure", + hasRequestId: true, + version: "v1", + }, + { + action: "setInteractionOptions", + hasRequestId: true, + version: "v1", + }, + { + action: "setCredentials", + hasRequestId: true, + version: "v1", + }, + { + action: "getStatus", + hasRequestId: true, + version: "v1", + }, + { + action: "getTemplates", + hasRequestId: true, + version: "v1", + }, + ]); + expect(requestEvents[0].payload).to.deep.include(authPayload); + expect(requestEvents[1].payload).to.deep.equal(createInteractionPayload); + expect(requestEvents[2].payload).to.deep.include(sessionConfig); + expect(requestEvents[3].payload).to.deep.equal({ facts: factsPayload }); + expect(requestEvents[4].payload).to.deep.equal(navigatePayload); + expect(requestEvents[5].payload).to.deep.equal({}); + expect(requestEvents[6].payload).to.deep.equal({}); + expect(requestEvents[7].payload).to.deep.equal(configureAppPayload); + expect(requestEvents[8].payload).to.deep.equal(configurePayload); + expect(requestEvents[9].payload).to.deep.equal(interactionOptionsPayload); + expect(requestEvents[10].payload).to.deep.equal(credentialsPayload); + expect(requestEvents[11].payload).to.deep.equal({}); + }); + + it("surfaces events emitted by the embedded iframe", async () => { + const el = await mountEmbeddedIntegration(); + const requestReceived = waitForEmbeddedEvent(el, "test.request-received"); + const navigated = waitForEmbeddedEvent(el, "embedded.navigated"); + + await el.navigate({ path: "/summary" }); + + const requestEvent = await requestReceived; + expect(requestEvent.detail.payload).to.deep.equal({ + action: "navigate", + payload: { path: "/summary" }, + hasRequestId: true, + version: "v1", + }); + + const navigatedEvent = await navigated; + expect(navigatedEvent.detail).to.deep.equal({ + name: "embedded.navigated", + payload: { path: "/summary" }, + }); + }); + + it("toggles visibility through local public methods", async () => { + const el = await mountEmbeddedIntegration(); + const iframe = el.shadowRoot!.querySelector("iframe") as HTMLIFrameElement; + + expect(iframe.getAttribute("style")).to.contain("display: none"); + el.show(); + await el.updateComplete; + expect(iframe.getAttribute("style")).to.contain("display: block"); + + el.hide(); + await el.updateComplete; + expect(iframe.getAttribute("style")).to.contain("display: none"); + }); +}); diff --git a/web-test-runner.config.js b/web-test-runner.config.js index 0ff45d6..aacf06c 100644 --- a/web-test-runner.config.js +++ b/web-test-runner.config.js @@ -1,22 +1,184 @@ -import { playwrightLauncher } from '@web/test-runner-playwright'; +import { playwrightLauncher } from "@web/test-runner-playwright"; -const filteredLogs = ['Running in dev mode', 'Lit is in dev mode']; +const filteredLogs = ["Running in dev mode", "Lit is in dev mode"]; + +const embeddedIntegrationBaseURL = "https://assistant.integration.corti.app"; + +const browserProducts = (process.env.EMBEDDED_WEB_TEST_BROWSERS || "chromium") + .split(",") + .map(browser => browser.trim()) + .filter(Boolean); + +const chromiumChannel = process.env.EMBEDDED_WEB_CHROMIUM_CHANNEL; + +const embeddedIntegrationFrameHtml = ` + + + + Corti Embedded Integration Frame + + + + +`; + +function createBrowserLauncher(product) { + return playwrightLauncher({ + product, + launchOptions: + product === "chromium" && chromiumChannel + ? { channel: chromiumChannel } + : {}, + createBrowserContext: async ({ browser }) => { + const context = await browser.newContext(); + + await context.route(`${embeddedIntegrationBaseURL}/embedded`, route => + route.fulfill({ + status: 200, + contentType: "text/html", + body: embeddedIntegrationFrameHtml, + }), + ); + + return context; + }, + }); +} export default /** @type {import("@web/test-runner").TestRunnerConfig} */ ({ /** Test files to run */ - files: '.tmp/test-dist/test/**/*.test.js', + files: ".tmp/test-dist/test/**/*.test.js", /** Resolve bare module imports */ nodeResolve: { - exportConditions: ['browser', 'development'], + exportConditions: ["browser", "development"], }, /** Use Playwright Chromium (bundled) so no CHROME_PATH is required */ - browsers: [playwrightLauncher({ product: 'chromium' })], + browsers: browserProducts.map(createBrowserLauncher), coverageConfig: { - include: ['src/**/*.ts'], - exclude: ['.tmp/test-dist/test/vendor/**'], + include: ["src/**/*.ts"], + exclude: [".tmp/test-dist/test/vendor/**"], }, testRunnerHtml: testFrameworkImport => ` @@ -36,7 +198,7 @@ export default /** @type {import("@web/test-runner").TestRunnerConfig} */ ({ /** Filter out lit dev mode logs */ filterBrowserLogs(log) { for (const arg of log.args) { - if (typeof arg === 'string' && filteredLogs.some(l => arg.includes(l))) { + if (typeof arg === "string" && filteredLogs.some(l => arg.includes(l))) { return false; } }