From 5c1992b7e10ab8082d2f38e20bd78c87fe16bb98 Mon Sep 17 00:00:00 2001 From: Kent Bull Date: Sat, 27 Jun 2026 18:15:27 -0600 Subject: [PATCH] ci: parallelize tests and cleanup setup --- .github/workflows/ci.yml | 37 +- .gitignore | 1 + docs/ci.md | 67 ++- docs/smoke-tests.md | 5 +- package.json | 10 +- scripts/ci/start-keri-stack.sh | 2 +- tests/browser-ci-smoke.ts | 539 +++++++++++++++++++++++ tests/scenarios/multisig-ci.test.ts | 152 +++++++ tests/scenarios/multisig.test.ts | 153 ------- tests/unit/multisigServiceGuards.test.ts | 176 ++++++++ vitest.unit.config.ts | 12 + 11 files changed, 975 insertions(+), 179 deletions(-) create mode 100644 tests/browser-ci-smoke.ts create mode 100644 tests/scenarios/multisig-ci.test.ts create mode 100644 tests/unit/multisigServiceGuards.test.ts create mode 100644 vitest.unit.config.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dacdbf97..f72a70f5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,10 +11,37 @@ permissions: contents: read jobs: - smoke: - name: Lint, build, and KERIA smoke tests + static: + name: Static app checks runs-on: ubuntu-latest - timeout-minutes: 45 + timeout-minutes: 20 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up pnpm + uses: pnpm/action-setup@v4 + with: + version: 10.33.0 + run_install: false + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: "22" + cache: pnpm + + - name: Install Node dependencies + run: pnpm install --frozen-lockfile + + - name: Run static app checks + run: pnpm ci:static + + live: + name: Live KERIA smoke + runs-on: ubuntu-latest + timeout-minutes: 35 env: KERIPY_BRANCH: v1.2.13 @@ -78,8 +105,8 @@ jobs: - name: Start KERIA and demo witnesses run: scripts/ci/start-keri-stack.sh - - name: Run CI test suite - run: pnpm test:ci + - name: Run live KERIA smoke + run: pnpm ci:live - name: Upload KERIA stack logs if: always() diff --git a/.gitignore b/.gitignore index 31508b87..f6f03f24 100644 --- a/.gitignore +++ b/.gitignore @@ -10,4 +10,5 @@ dist/ coverage/* .cache/ +.ci/ .smoke-artifacts/ diff --git a/docs/ci.md b/docs/ci.md index 23cd9cef..7b1417dc 100644 --- a/docs/ci.md +++ b/docs/ci.md @@ -1,13 +1,27 @@ # CI -The repository uses GitHub Actions to run the Signify boundary smoke tests -and scenario tests against a real local KERIA stack. +The repository uses GitHub Actions to run fast static app checks separately +from the Signify boundary smoke tests that need a real local KERIA stack. Workflow: `.github/workflows/ci.yml` -## What CI Runs +## Required Jobs -The main CI job: +### Static app checks + +The static app job is Node-only and runs: + +```bash +pnpm ci:static +``` + +That script runs lint, production build, and the unit suite. Unit tests use +`vitest.unit.config.ts` so pure unit files can run in parallel without changing +the serial policy used by live KERIA scenarios. + +### Live KERIA smoke + +The live KERIA job: 1. installs system dependencies needed by KERIpy, currently `libsodium-dev`, 2. sets up Python 3.12.8, @@ -17,27 +31,48 @@ The main CI job: 6. installs pinned KERIpy and KERIA from GitHub commits, 7. starts local KERI demo witnesses, 8. starts local KERIA, -9. runs `pnpm test:ci`, +9. runs `pnpm ci:live`, 10. uploads KERIA/witness logs on success or failure. -`pnpm test:ci` currently runs: +`pnpm ci:live` currently runs: ```bash -pnpm lint -pnpm build pnpm keria:smoke -- --mode connect pnpm keria:smoke -pnpm scenario:test -pnpm browser:smoke +pnpm scenario:ci +pnpm browser:ci-smoke ``` -Future tests that require the same local KERIA stack should be added to -`test:ci` or called from that script. +`pnpm test:ci` remains as a local parity wrapper for developers who want the +same static and live checks in one command: + +```bash +pnpm ci:static && pnpm ci:live +``` + +Future required checks that need the same local KERIA stack should be added to +`ci:live` or a script it calls. Do not append broad/manual suites to required +CI unless they protect an active change. + +`pnpm scenario:ci` is the required live scenario subset. It keeps one +two-member multisig canary for invitation, acceptance, interaction, and +rotation, but leaves the full multisig matrix in `pnpm multisig:test`. + +`pnpm scenario:test` runs the broader top-level scenario files for manual or +pre-merge confidence. Optional schema and external-fixture scenarios live under +`tests/scenarios/optional` and are available through `pnpm scenario:test:all`, +where they skip unless their required config is present. + +The W3C holder presentation smoke is intentionally not part of required PR CI. +It attaches to a pre-seeded live W3C stack with W3C-enabled KERIA and live +verifier services: + +```bash +pnpm w3c:holder-presentation:smoke +``` -`pnpm scenario:test` runs only the top-level scenario test files. Optional -schema and external-fixture scenarios live under `tests/scenarios/optional` and -are available through `pnpm scenario:test:all`, where they skip unless their -required config is present. +If this smoke becomes automated, pin KERIA and any cross-repo stack inputs by +immutable SHAs, not floating branches. ## Pinned Python Stack diff --git a/docs/smoke-tests.md b/docs/smoke-tests.md index fbea7bbf..a6c64866 100644 --- a/docs/smoke-tests.md +++ b/docs/smoke-tests.md @@ -42,9 +42,11 @@ admin and boot APIs work without involving witnesses or browser automation. CI runs all smoke checks through: ```bash -pnpm test:ci +pnpm ci:live ``` +Run `pnpm test:ci` to execute both static and live required checks locally. + See [CI](./ci.md) for the GitHub Actions service setup and pinned KERIpy/KERIA versions. @@ -65,6 +67,7 @@ The smoke-test stack has one shared smoke module and two executable wrappers. | --------------- | ------------------------------------ | ------------------------------------------------------------------------------------------------------------------- | | Shared smoke | `tests/smoke/clientBoundarySmoke.ts` | Boots/connects through the Signify boundary, reads client state, and optionally creates a witnessed identifier. | | CLI wrapper | `scripts/keria-smoke.ts` | Parses process args, calls the shared smoke module, and prints JSON. | +| Required browser wrapper | `tests/browser-ci-smoke.ts` | Runs the compact required browser path: layout, connect, identifier table, and contact/OOBI notification payload. | | Browser wrapper | `tests/browser-smoke.ts` | Starts or reuses Vite, drives the React UI with Puppeteer, and verifies the client summary. | | Contact OOBI smoke | `tests/contact-oobi-smoke.ts` | Resolves harness and witness OOBIs through the React Contacts UI and verifies operation/notification payload links. | | Contact challenge smoke | `tests/contact-challenge-smoke.ts` | Exercises browser challenge generation, harness response, synthetic challenge notifications, detail response, and bell response. | diff --git a/package.json b/package.json index c0d5aa8b..defb673e 100644 --- a/package.json +++ b/package.json @@ -7,24 +7,28 @@ "scripts": { "dev": "vite", "build": "npx tsc && vite build", - "lint": "eslint src tests scripts eslint.config.mjs vite.config.ts vitest.config.ts --max-warnings 0", + "lint": "eslint src tests scripts eslint.config.mjs vite.config.ts vitest.config.ts vitest.unit.config.ts --max-warnings 0", "preview": "vite preview", "keria:smoke": "tsx scripts/keria-smoke.ts", "contact:ui-smoke": "tsx tests/contact-oobi-smoke.ts", "contact:challenge-smoke": "tsx tests/contact-challenge-smoke.ts", "identifier-agent-oobi:smoke": "tsx tests/identifier-agent-oobi-smoke.ts", "browser:smoke": "tsx tests/browser-smoke.ts", + "browser:ci-smoke": "tsx tests/browser-ci-smoke.ts", "w3c:holder-presentation:smoke": "node tests/w3c-holder-presentation-smoke.mjs", "responsive:smoke": "tsx tests/responsive-smoke.ts", - "unit:test": "vitest run tests/unit", + "unit:test": "vitest run --config vitest.unit.config.ts tests/unit", "multisig:test": "vitest run tests/scenarios/multisig.test.ts tests/scenarios/multisig-interaction.test.ts", + "scenario:ci": "vitest run tests/scenarios/challenge.test.ts tests/scenarios/credentials.test.ts tests/scenarios/multisig-ci.test.ts", "scenario:test": "vitest run tests/scenarios/salty.test.ts tests/scenarios/randy.test.ts tests/scenarios/witnessed.test.ts tests/scenarios/challenge.test.ts tests/scenarios/controller-rotation.test.ts tests/scenarios/oobi-contacts.test.ts tests/scenarios/credentials.test.ts tests/scenarios/multisig.test.ts tests/scenarios/multisig-interaction.test.ts", "delegation:test": "vitest run tests/scenarios/optional/delegation.test.ts", "scenario:test:all": "vitest run tests/scenarios", "portable:wallet-api": "vitest run tests/scenarios/challenge.test.ts tests/scenarios/credentials.test.ts tests/scenarios/multisig.test.ts tests/scenarios/multisig-interaction.test.ts tests/scenarios/optional/delegation.test.ts", "portable:wallet-browser": "pnpm browser:smoke && pnpm responsive:smoke && tsx tests/contact-challenge-smoke.ts", "portable:e2e": "pnpm portable:wallet-api && pnpm portable:wallet-browser", - "test:ci": "pnpm lint && pnpm build && pnpm unit:test && pnpm responsive:smoke && pnpm keria:smoke -- --mode connect && pnpm keria:smoke && pnpm scenario:test && pnpm contact:ui-smoke && pnpm browser:smoke" + "ci:static": "pnpm lint && pnpm build && pnpm unit:test", + "ci:live": "pnpm keria:smoke -- --mode connect && pnpm keria:smoke && pnpm scenario:ci && pnpm browser:ci-smoke", + "test:ci": "pnpm ci:static && pnpm ci:live" }, "engines": { "node": ">=20.19.0" diff --git a/scripts/ci/start-keri-stack.sh b/scripts/ci/start-keri-stack.sh index 1565c2d9..e2ae7dd5 100755 --- a/scripts/ci/start-keri-stack.sh +++ b/scripts/ci/start-keri-stack.sh @@ -99,7 +99,7 @@ socket_wait 127.0.0.1 3903 "KERIA boot API" echo "Starting vLEI schema server" ( cd "$ROOT_DIR" - exec vLEI-server \ + exec python -m vlei.server \ --http 7723 \ --schema-dir "$SCHEMA_DIR" \ --cred-dir "$CREDENTIAL_DIR" \ diff --git a/tests/browser-ci-smoke.ts b/tests/browser-ci-smoke.ts new file mode 100644 index 00000000..af908bc2 --- /dev/null +++ b/tests/browser-ci-smoke.ts @@ -0,0 +1,539 @@ +import puppeteer, { type Page } from 'puppeteer'; +import { SignifyClient, Tier, ready, type Operation } from 'signify-ts'; +import { appConfig } from '../src/config'; +import { + chromeArgs, + connectBrowserAgent, + dispatchClick, + logStage, + routeUrl as appRouteUrl, + startViteIfNeeded, + waitForDomState, + waitForElement, + waitForText, +} from './support/browserHarness'; +import { + navigateInApp, + openContactDetail, + resolveOobiInContacts, +} from './support/contactUiHarness'; + +/** + * Required CI browser smoke. + * + * This is the compact required-gate browser proof. The broader standalone + * browser, responsive, and contact smokes stay available for focused debugging. + */ +const appUrl = process.env.BROWSER_CI_SMOKE_URL ?? 'http://127.0.0.1:5173'; +const routeUrl = (path: string): string => appRouteUrl(appUrl, path); +const uiPreferencesStorageKey = 'signify-react-ts:ui-preferences:v1'; + +interface IdentifierFixture { + alias: string; + prefix: string; +} + +interface HeaderExpectation { + expected: string[]; + omitted: string[]; +} + +const textContent = (page: Page, selector: string): Promise => + page.$eval(selector, (element) => element.textContent ?? ''); + +const connectClient = async (passcode: string): Promise => { + await ready(); + const client = new SignifyClient( + appConfig.keria.adminUrl, + passcode, + Tier.low, + appConfig.keria.bootUrl + ); + await client.connect(); + return client; +}; + +const waitForOperation = async ( + client: SignifyClient, + operation: Operation, + label: string +): Promise => { + const controller = new globalThis.AbortController(); + const timeout = globalThis.setTimeout(() => { + controller.abort(new Error(`${label} timed out`)); + }, appConfig.operations.timeoutMs); + + try { + await client.operations().wait(operation, { + signal: controller.signal, + minSleep: appConfig.operations.minSleepMs, + maxSleep: appConfig.operations.maxSleepMs, + }); + } catch (error) { + throw new Error( + `${label} failed: ${error instanceof Error ? error.message : String(error)}`, + { cause: error } + ); + } finally { + globalThis.clearTimeout(timeout); + } +}; + +const createIdentifierFixture = async ( + passcode: string +): Promise => { + const client = await connectClient(passcode); + const alias = `browser-ci-${new Date() + .toISOString() + .replace(/[-:.TZ]/g, '') + .slice(0, 14)}`; + const result = await client.identifiers().create(alias, { + toad: appConfig.witnesses.toad, + wits: appConfig.witnesses.aids, + }); + const operation = await result.op(); + await waitForOperation(client, operation, `creating ${alias}`); + const identifier = await client.identifiers().get(alias); + + return { alias, prefix: identifier.prefix }; +}; + +const assertNoHorizontalOverflow = async ( + page: Page, + label: string +): Promise => { + const metrics = await page.evaluate(() => ({ + innerWidth: globalThis.innerWidth, + htmlScrollWidth: globalThis.document.documentElement.scrollWidth, + bodyScrollWidth: globalThis.document.body.scrollWidth, + })); + const scrollWidth = Math.max( + metrics.htmlScrollWidth, + metrics.bodyScrollWidth + ); + + if (scrollWidth > metrics.innerWidth) { + throw new Error( + `${label} has horizontal overflow: scrollWidth=${scrollWidth}, innerWidth=${metrics.innerWidth}` + ); + } +}; + +const assertContentStartsBelowAppBar = async ( + page: Page, + label: string +): Promise => { + const metrics = await page.evaluate(() => { + const appBar = globalThis.document.querySelector('.MuiAppBar-root'); + const content = globalThis.document.querySelector( + '[data-testid="connection-required"]' + ); + + if (appBar === null || content === null) { + return null; + } + + const appBarRect = appBar.getBoundingClientRect(); + const contentRect = content.getBoundingClientRect(); + + return { + appBarBottom: appBarRect.bottom, + contentTop: contentRect.top, + }; + }); + + if (metrics === null) { + throw new Error( + `${label} did not render the app bar and route content` + ); + } + + if (metrics.contentTop < metrics.appBarBottom) { + throw new Error( + `${label} route content overlaps app bar: contentTop=${metrics.contentTop}, appBarBottom=${metrics.appBarBottom}` + ); + } + + if (metrics.contentTop - metrics.appBarBottom > 96) { + throw new Error( + `${label} route content appears vertically centered: contentTop=${metrics.contentTop}, appBarBottom=${metrics.appBarBottom}` + ); + } +}; + +const assertElementsFitViewport = async ( + page: Page, + selectors: string[], + label: string +): Promise => { + const failures = await page.evaluate((visibleSelectors) => { + const viewportWidth = globalThis.innerWidth; + + return visibleSelectors.flatMap((selector) => { + const element = globalThis.document.querySelector(selector); + + if (element === null) { + return [`${selector} was not found`]; + } + + const rect = element.getBoundingClientRect(); + + if (rect.left < -1 || rect.right > viewportWidth + 1) { + return [ + `${selector} overflows horizontally: left=${rect.left}, right=${rect.right}, viewport=${viewportWidth}`, + ]; + } + + return []; + }); + }, selectors); + + if (failures.length > 0) { + throw new Error(`${label} viewport fit failed: ${failures.join('; ')}`); + } +}; + +const assertVisibleControlFitsViewport = async ( + page: Page, + ariaLabel: string, + label: string +): Promise => { + const failures = await page.evaluate((expectedLabel) => { + const viewportWidth = globalThis.innerWidth; + const controls = [...globalThis.document.querySelectorAll('button')] + .filter( + (button) => button.getAttribute('aria-label') === expectedLabel + ) + .filter((button) => { + const rect = button.getBoundingClientRect(); + const style = globalThis.getComputedStyle(button); + return ( + rect.width > 0 && + rect.height > 0 && + style.display !== 'none' && + style.visibility !== 'hidden' + ); + }); + + if (controls.length === 0) { + return [`No visible control for ${expectedLabel}`]; + } + + return controls.flatMap((control) => { + const rect = control.getBoundingClientRect(); + if (rect.left < -1 || rect.right > viewportWidth + 1) { + return [ + `${expectedLabel} overflows horizontally: left=${rect.left}, right=${rect.right}, viewport=${viewportWidth}`, + ]; + } + + return []; + }); + }, ariaLabel); + + if (failures.length > 0) { + throw new Error(`${label} control fit failed: ${failures.join('; ')}`); + } +}; + +const visibleIdentifierHeaders = async (page: Page): Promise => + page.$$eval('[data-testid="identifier-table"] thead th', (headers) => + headers + .filter((header) => { + const rect = header.getBoundingClientRect(); + const style = globalThis.getComputedStyle(header); + return ( + rect.width > 0 && + rect.height > 0 && + style.display !== 'none' && + style.visibility !== 'hidden' + ); + }) + .map((header) => header.textContent?.trim() ?? '') + ); + +const assertIdentifierHeaders = async ( + page: Page, + { expected, omitted }: HeaderExpectation, + label: string +): Promise => { + const headers = await visibleIdentifierHeaders(page); + const missing = expected.filter((header) => !headers.includes(header)); + const unexpectedlyVisible = omitted.filter((header) => + headers.includes(header) + ); + + if (missing.length > 0 || unexpectedlyVisible.length > 0) { + throw new Error( + `${label} identifier headers mismatch: visible=${headers.join(', ')}, missing=${missing.join(', ')}, unexpectedlyVisible=${unexpectedlyVisible.join(', ')}` + ); + } +}; + +const navigateToIdentifiers = async (page: Page): Promise => { + await dispatchClick(page, '[data-testid="nav-open"]'); + await waitForElement(page, '[data-testid="nav-identifiers"]', 10_000); + await dispatchClick(page, '[data-testid="nav-identifiers"]'); + await waitForElement(page, '[data-testid="identifier-table"]', 10_000); +}; + +const assertProtectedMobileLayout = async (page: Page): Promise => { + for (const viewport of [ + { label: 'iPhone SE', width: 320, height: 568 }, + { label: 'mobile', width: 390, height: 844 }, + ]) { + await page.setViewport({ + width: viewport.width, + height: viewport.height, + isMobile: true, + deviceScaleFactor: 2, + }); + await page.goto(routeUrl('/identifiers'), { + waitUntil: 'networkidle0', + }); + await waitForElement( + page, + '[data-testid="connection-required"]', + 10_000 + ); + await assertNoHorizontalOverflow(page, viewport.label); + await assertContentStartsBelowAppBar(page, viewport.label); + + await dispatchClick(page, '[data-testid="connect-open"]'); + await waitForElement(page, '[data-testid="connect-dialog"]', 10_000); + await assertNoHorizontalOverflow(page, `${viewport.label} dialog`); + await assertElementsFitViewport( + page, + [ + '.MuiDialog-paper', + '[data-testid="connect-submit"]', + '[data-testid="generate-passcode"]', + '[data-testid="connect-close"]', + ], + `${viewport.label} dialog` + ); + await dispatchClick(page, '[data-testid="connect-close"]'); + } +}; + +const assertUiPreferences = async (page: Page): Promise => { + await page.goto(appUrl, { waitUntil: 'networkidle0' }); + await page.evaluate((key) => { + globalThis.localStorage.removeItem(key); + }, uiPreferencesStorageKey); + await page.reload({ waitUntil: 'networkidle0' }); + await waitForElement(page, '[data-testid="ui-sound-toggle"]', 10_000); + await waitForElement(page, '[data-testid="theme-mode-toggle"]', 10_000); + + await dispatchClick(page, '[data-testid="theme-mode-toggle"]'); + await waitForDomState( + page, + 'light theme toggle', + () => + globalThis.document + .querySelector('[data-testid="theme-mode-toggle"]') + ?.getAttribute('aria-pressed') === 'true', + 10_000 + ); + await dispatchClick(page, '[data-testid="ui-sound-toggle"]'); + await waitForDomState( + page, + 'muted sound toggle', + () => + globalThis.document + .querySelector('[data-testid="ui-sound-toggle"]') + ?.getAttribute('aria-pressed') === 'true', + 10_000 + ); + + const persistedPreference = await page.evaluate((key) => { + const text = globalThis.localStorage.getItem(key); + return text === null ? null : JSON.parse(text); + }, uiPreferencesStorageKey); + if (persistedPreference?.hoverSoundMuted !== true) { + throw new Error('Expected muted sound preference to persist'); + } + if (persistedPreference?.themeMode !== 'light') { + throw new Error('Expected light theme preference to persist'); + } +}; + +const assertConnectedIdentifierTable = async ( + page: Page, + fixture: IdentifierFixture +): Promise => { + const viewports = [ + { + label: 'compact table', + width: 640, + height: 800, + headers: { + expected: ['Name', 'AID', 'Actions'], + omitted: ['Type', 'KIDX', 'PIDX', 'OOBI'], + }, + }, + { + label: 'medium table', + width: 960, + height: 800, + headers: { + expected: ['Name', 'AID', 'Type', 'Actions'], + omitted: ['KIDX', 'PIDX', 'OOBI'], + }, + }, + ]; + + await page.setViewport({ + width: viewports[0].width, + height: viewports[0].height, + isMobile: false, + deviceScaleFactor: 1, + }); + await navigateToIdentifiers(page); + + for (const viewport of viewports) { + await page.setViewport({ + width: viewport.width, + height: viewport.height, + isMobile: false, + deviceScaleFactor: 1, + }); + await waitForElement(page, '[data-testid="identifier-table"]', 10_000); + await waitForDomState( + page, + `rotate control for ${fixture.alias}`, + (alias) => + [...globalThis.document.querySelectorAll('button')].some( + (button) => + button.getAttribute('aria-label') === + `Rotate identifier ${alias}` + ), + 10_000, + fixture.alias + ); + await assertNoHorizontalOverflow(page, viewport.label); + await assertIdentifierHeaders(page, viewport.headers, viewport.label); + await assertVisibleControlFitsViewport( + page, + `Rotate identifier ${fixture.alias}`, + viewport.label + ); + await assertVisibleControlFitsViewport( + page, + `Copy agent OOBI for ${fixture.alias}`, + viewport.label + ); + } +}; + +const witnessOobi = (): string => { + const wanAid = appConfig.witnesses.aids[0]; + if (wanAid === undefined) { + throw new Error('No configured witness AID available for UI smoke.'); + } + + return `http://127.0.0.1:5642/oobi/${wanAid}/controller?name=Wan`; +}; + +const assertQuickNotificationAndOperationPayload = async ( + page: Page +): Promise => { + await dispatchClick(page, '[data-testid="notifications-open"]'); + await page.waitForSelector('[data-testid="notification-quick-item"]', { + timeout: 30_000, + }); + await dispatchClick(page, '[data-testid="notification-quick-item"]'); + await page.waitForFunction( + () => globalThis.location.pathname.startsWith('/operations/'), + { timeout: 10_000 } + ); + await page.goBack({ waitUntil: 'networkidle0' }); + await page.waitForSelector('[data-testid="contacts-view"]', { + timeout: 10_000, + }); + logStage('notification.payload.ready'); +}; + +const assertContactOobiFlow = async (page: Page): Promise => { + const contactAlias = 'Wan witness'; + const contactOobi = witnessOobi(); + + await resolveOobiInContacts(page, { + alias: contactAlias, + oobi: contactOobi, + requireResolved: true, + }); + await openContactDetail(page, contactAlias); + await waitForText(page, '[data-testid="contact-detail"]', contactOobi); + await page.goBack({ waitUntil: 'networkidle0' }); + await page.waitForSelector('[data-testid="contacts-view"]', { + timeout: 10_000, + }); + await assertQuickNotificationAndOperationPayload(page); + await navigateInApp( + page, + 'nav-dashboard', + '[data-testid="dashboard-view"]' + ); +}; + +const vite = await startViteIfNeeded(appUrl); +const browser = await puppeteer.launch({ + headless: 'new', + args: chromeArgs, + protocolTimeout: 300_000, +}); + +try { + const page = await browser.newPage(); + page.setDefaultTimeout(60_000); + page.setDefaultNavigationTimeout(60_000); + page.on('pageerror', (error) => { + console.error(`[browser:pageerror] ${error.message}`); + }); + page.on('console', (message) => { + if (message.type() === 'error' || message.type() === 'warning') { + console.error(`[browser:${message.type()}] ${message.text()}`); + } + }); + + await assertUiPreferences(page); + await assertProtectedMobileLayout(page); + + await page.setViewport({ + width: 960, + height: 800, + isMobile: false, + deviceScaleFactor: 1, + }); + const browserPasscode = await connectBrowserAgent(page, { appUrl }); + const fixture = await createIdentifierFixture(browserPasscode); + await assertConnectedIdentifierTable(page, fixture); + await assertContactOobiFlow(page); + await navigateInApp(page, 'nav-client', '[data-testid="client-summary"]'); + + const controller = await textContent( + page, + '[data-testid="controller-aid"]' + ); + const agent = await textContent(page, '[data-testid="agent-aid"]'); + + console.log( + JSON.stringify( + { + status: 'passed', + controller, + agent, + identifierAlias: fixture.alias, + identifierPrefix: fixture.prefix, + }, + null, + 2 + ) + ); +} finally { + await browser.close(); + if (vite !== null) { + vite.kill('SIGTERM'); + } +} diff --git a/tests/scenarios/multisig-ci.test.ts b/tests/scenarios/multisig-ci.test.ts new file mode 100644 index 00000000..d628c23f --- /dev/null +++ b/tests/scenarios/multisig-ci.test.ts @@ -0,0 +1,152 @@ +import { describe, expect, it } from 'vitest'; +import { thresholdSpecForMembers } from '../../src/domain/multisig/multisigThresholds'; +import { + MULTISIG_ICP_ROUTE, + acceptMultisigInceptionService, + startMultisigInceptionService, +} from '../../src/services/multisig.service'; +import { listNotificationsService } from '../../src/services/notifications.service'; +import { runServiceOperation, waitForNotification } from '../support/keria'; +import { + assertMembershipAndThresholds, + authorizeGroupAgents, + createMembers, + exchangeAllAgentOobis, + expectGroupConvergence, + notificationSaid, + requestInput, + rotateMembersAndGroup, + startGroupInteraction, + uniqueAlias, +} from '../support/multisig'; + +describe.sequential('required CI multisig canary', () => { + it( + 'proves two-member invitation, acceptance, interaction, and rotation', + async () => { + const groupAlias = uniqueAlias('multisig-ci'); + const { roles, aliases, aids, memberAids } = await createMembers( + 'multisig-ci', + 2 + ); + const initiator = roles[0]; + const acceptor = roles[1]; + const initiatorAid = aids[0]; + const acceptorAid = aids[1]; + if ( + initiator === undefined || + acceptor === undefined || + initiatorAid === undefined || + acceptorAid === undefined + ) { + throw new Error('Missing multisig CI canary member.'); + } + + await exchangeAllAgentOobis(roles, aliases); + + const threshold = thresholdSpecForMembers(memberAids); + const creating = runServiceOperation(() => + startMultisigInceptionService({ + client: initiator.client, + config: undefined, + draft: { + groupAlias, + localMemberName: aliases[0], + localMemberAid: initiatorAid.prefix, + members: memberAids.map((aid, index) => ({ + aid, + alias: aliases[index] ?? aid, + source: + aid === initiatorAid.prefix + ? 'local' + : 'contact', + })), + signingMemberAids: [...memberAids], + rotationMemberAids: [...memberAids], + signingThreshold: threshold, + rotationThreshold: threshold, + witnessMode: 'none', + }, + }) + ); + const notification = await waitForNotification( + acceptor, + MULTISIG_ICP_ROUTE + ); + const snapshot = await runServiceOperation(() => + listNotificationsService({ + client: acceptor.client, + localAids: [acceptorAid.prefix], + }) + ); + + expect(snapshot.notifications).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: notification.i, + route: MULTISIG_ICP_ROUTE, + status: 'unread', + message: 'Group invitation', + multisigRequest: expect.objectContaining({ + exnSaid: notificationSaid(notification), + groupAlias: null, + status: 'actionable', + progress: expect.objectContaining({ + completed: 1, + total: 2, + respondedMemberAids: [initiatorAid.prefix], + waitingMemberAids: [acceptorAid.prefix], + }), + }), + }), + ]) + ); + + await Promise.all([ + creating, + runServiceOperation(() => + acceptMultisigInceptionService({ + client: acceptor.client, + input: requestInput(notification, groupAlias, aliases[1]), + }) + ), + ]); + await expectGroupConvergence(roles, groupAlias); + await assertMembershipAndThresholds(roles[0], groupAlias, memberAids, [ + '1/2', + '1/2', + ]); + + await authorizeGroupAgents({ roles, aliases, groupAlias }); + + const beforeInteraction = await expectGroupConvergence( + roles, + groupAlias + ); + await startGroupInteraction({ + roles, + aliases, + groupAlias, + data: { + i: beforeInteraction.prefix, + s: beforeInteraction.sequence, + d: beforeInteraction.digest, + }, + }); + const afterInteraction = await expectGroupConvergence( + roles, + groupAlias + ); + expect(Number(afterInteraction.sequence)).toBe( + Number(beforeInteraction.sequence) + 1 + ); + + await rotateMembersAndGroup({ roles, aliases, groupAlias, memberAids }); + const afterRotation = await expectGroupConvergence(roles, groupAlias); + expect(Number(afterRotation.sequence)).toBe( + Number(afterInteraction.sequence) + 1 + ); + }, + 360_000 + ); +}); diff --git a/tests/scenarios/multisig.test.ts b/tests/scenarios/multisig.test.ts index 7018c026..8c0db36f 100644 --- a/tests/scenarios/multisig.test.ts +++ b/tests/scenarios/multisig.test.ts @@ -1,6 +1,4 @@ -import type { SignifyClient } from 'signify-ts'; import { describe, expect, it } from 'vitest'; -import type { MultisigCreateDraft } from '../../src/domain/multisig/multisigTypes'; import { thresholdSpecForMembers, type MultisigThresholdSpec, @@ -8,7 +6,6 @@ import { import { acceptMultisigInceptionService, startMultisigInceptionService, - startMultisigRotationService, } from '../../src/services/multisig.service'; import { listNotificationsService } from '../../src/services/notifications.service'; import { runServiceOperation, waitForNotification } from '../support/keria'; @@ -58,156 +55,6 @@ const nestedWeightedThreshold = ( }); describe.sequential('multisig lifecycle quality gate', () => { - it('rejects invalid multisig drafts before protocol submission', async () => { - const fakeClient = {} as SignifyClient; - const validThreshold = thresholdSpecForMembers(['Ea', 'Eb']); - const baseDraft: MultisigCreateDraft = { - groupAlias: 'invalid-group', - localMemberName: 'member-a', - localMemberAid: 'Ea', - members: [], - signingMemberAids: ['Ea', 'Eb'], - rotationMemberAids: ['Ea', 'Eb'], - signingThreshold: validThreshold, - rotationThreshold: validThreshold, - witnessMode: 'none', - }; - - await expect( - runServiceOperation(() => - startMultisigInceptionService({ - client: fakeClient, - draft: { - ...baseDraft, - signingThreshold: { - mode: 'customFlat', - weights: [], - }, - }, - }) - ) - ).rejects.toThrow('Signing threshold requires at least one member.'); - - await expect( - runServiceOperation(() => - startMultisigInceptionService({ - client: fakeClient, - draft: { - ...baseDraft, - localMemberAid: 'Ec', - }, - }) - ) - ).rejects.toThrow('The local member must be in the signing set.'); - }); - - it('rejects multisig inception when a remote member cannot receive the request', async () => { - const validThreshold = thresholdSpecForMembers(['Ea', 'Eb']); - const fakeClient = { - identifiers: () => ({ - get: async () => ({ - name: 'member-a', - prefix: 'Ea', - }), - list: async () => [{ name: 'member-a', prefix: 'Ea' }], - }), - contacts: () => ({ - list: async () => [ - { - id: 'Eb', - alias: 'member-b', - ends: {}, - }, - ], - }), - } as unknown as SignifyClient; - - await expect( - runServiceOperation(() => - startMultisigInceptionService({ - client: fakeClient, - draft: { - groupAlias: 'delivery-fails', - localMemberName: 'member-a', - localMemberAid: 'Ea', - members: [ - { - aid: 'Ea', - alias: 'member-a', - source: 'local', - }, - { - aid: 'Eb', - alias: 'member-b', - source: 'contact', - }, - ], - signingMemberAids: ['Ea', 'Eb'], - rotationMemberAids: ['Ea', 'Eb'], - signingThreshold: validThreshold, - rotationThreshold: validThreshold, - witnessMode: 'none', - }, - }) - ) - ).rejects.toThrow('Resolve member agent OOBIs before creating the group'); - }); - - it('rejects rotation when a referenced member key state cannot be resolved', async () => { - const fakeClient = { - identifiers: () => ({ - get: async () => ({ name: 'member-a', prefix: 'Ea' }), - }), - keyStates: () => ({ - query: async () => { - throw new Error('missing key state'); - }, - }), - } as unknown as SignifyClient; - - await expect( - runServiceOperation(() => - startMultisigRotationService({ - client: fakeClient, - draft: { - groupAlias: 'group-a', - localMemberName: 'member-a', - signingMemberAids: ['Eunknown'], - rotationMemberAids: ['Eunknown'], - nextThreshold: thresholdSpecForMembers(['Eunknown']), - }, - }) - ) - ).rejects.toThrow('missing key state'); - }); - - it('rejects rotation before Signify when group signing members are unavailable', async () => { - const fakeClient = { - identifiers: () => ({ - get: async () => ({ name: 'member-a', prefix: 'Ea' }), - members: async () => ({ - signing: [], - rotation: [{ prefix: 'Ea' }], - }), - }), - } as unknown as SignifyClient; - - await expect( - runServiceOperation(() => - startMultisigRotationService({ - client: fakeClient, - draft: { - groupAlias: 'group-a', - localMemberName: 'member-a', - signingMemberAids: [], - rotationMemberAids: ['Ea'], - nextThreshold: thresholdSpecForMembers(['Ea']), - }, - }) - ) - ).rejects.toThrow('signing members could not be loaded'); - }); - it( 'surfaces two-member inception invitations through app notifications', async () => { diff --git a/tests/unit/multisigServiceGuards.test.ts b/tests/unit/multisigServiceGuards.test.ts new file mode 100644 index 00000000..cceddf28 --- /dev/null +++ b/tests/unit/multisigServiceGuards.test.ts @@ -0,0 +1,176 @@ +import type { Operation as EffectionOperation } from 'effection'; +import type { SignifyClient } from 'signify-ts'; +import { describe, expect, it } from 'vitest'; +import { createAppRuntime } from '../../src/app/runtime'; +import type { MultisigCreateDraft } from '../../src/domain/multisig/multisigTypes'; +import { thresholdSpecForMembers } from '../../src/domain/multisig/multisigThresholds'; +import { + startMultisigInceptionService, + startMultisigRotationService, +} from '../../src/services/multisig.service'; + +const runServiceOperation = async ( + operation: () => EffectionOperation +): Promise => { + const runtime = createAppRuntime({ storage: null }); + try { + return await runtime.runWorkflow(operation, { + scope: 'app', + track: false, + }); + } finally { + await runtime.destroy(); + } +}; + +describe('multisig service guards', () => { + it('rejects invalid multisig drafts before protocol submission', async () => { + const fakeClient = {} as SignifyClient; + const validThreshold = thresholdSpecForMembers(['Ea', 'Eb']); + const baseDraft: MultisigCreateDraft = { + groupAlias: 'invalid-group', + localMemberName: 'member-a', + localMemberAid: 'Ea', + members: [], + signingMemberAids: ['Ea', 'Eb'], + rotationMemberAids: ['Ea', 'Eb'], + signingThreshold: validThreshold, + rotationThreshold: validThreshold, + witnessMode: 'none', + }; + + await expect( + runServiceOperation(() => + startMultisigInceptionService({ + client: fakeClient, + draft: { + ...baseDraft, + signingThreshold: { + mode: 'customFlat', + weights: [], + }, + }, + }) + ) + ).rejects.toThrow('Signing threshold requires at least one member.'); + + await expect( + runServiceOperation(() => + startMultisigInceptionService({ + client: fakeClient, + draft: { + ...baseDraft, + localMemberAid: 'Ec', + }, + }) + ) + ).rejects.toThrow('The local member must be in the signing set.'); + }); + + it('rejects multisig inception when a remote member cannot receive the request', async () => { + const validThreshold = thresholdSpecForMembers(['Ea', 'Eb']); + const fakeClient = { + identifiers: () => ({ + get: async () => ({ + name: 'member-a', + prefix: 'Ea', + }), + list: async () => [{ name: 'member-a', prefix: 'Ea' }], + }), + contacts: () => ({ + list: async () => [ + { + id: 'Eb', + alias: 'member-b', + ends: {}, + }, + ], + }), + } as unknown as SignifyClient; + + await expect( + runServiceOperation(() => + startMultisigInceptionService({ + client: fakeClient, + draft: { + groupAlias: 'delivery-fails', + localMemberName: 'member-a', + localMemberAid: 'Ea', + members: [ + { + aid: 'Ea', + alias: 'member-a', + source: 'local', + }, + { + aid: 'Eb', + alias: 'member-b', + source: 'contact', + }, + ], + signingMemberAids: ['Ea', 'Eb'], + rotationMemberAids: ['Ea', 'Eb'], + signingThreshold: validThreshold, + rotationThreshold: validThreshold, + witnessMode: 'none', + }, + }) + ) + ).rejects.toThrow('Resolve member agent OOBIs before creating the group'); + }); + + it('rejects rotation when a referenced member key state cannot be resolved', async () => { + const fakeClient = { + identifiers: () => ({ + get: async () => ({ name: 'member-a', prefix: 'Ea' }), + }), + keyStates: () => ({ + query: async () => { + throw new Error('missing key state'); + }, + }), + } as unknown as SignifyClient; + + await expect( + runServiceOperation(() => + startMultisigRotationService({ + client: fakeClient, + draft: { + groupAlias: 'group-a', + localMemberName: 'member-a', + signingMemberAids: ['Eunknown'], + rotationMemberAids: ['Eunknown'], + nextThreshold: thresholdSpecForMembers(['Eunknown']), + }, + }) + ) + ).rejects.toThrow('missing key state'); + }); + + it('rejects rotation before Signify when group signing members are unavailable', async () => { + const fakeClient = { + identifiers: () => ({ + get: async () => ({ name: 'member-a', prefix: 'Ea' }), + members: async () => ({ + signing: [], + rotation: [{ prefix: 'Ea' }], + }), + }), + } as unknown as SignifyClient; + + await expect( + runServiceOperation(() => + startMultisigRotationService({ + client: fakeClient, + draft: { + groupAlias: 'group-a', + localMemberName: 'member-a', + signingMemberAids: [], + rotationMemberAids: ['Ea'], + nextThreshold: thresholdSpecForMembers(['Ea']), + }, + }) + ) + ).rejects.toThrow('signing members could not be loaded'); + }); +}); diff --git a/vitest.unit.config.ts b/vitest.unit.config.ts new file mode 100644 index 00000000..a6c3e92a --- /dev/null +++ b/vitest.unit.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from 'vitest/config'; +import wasm from 'vite-plugin-wasm'; + +export default defineConfig({ + plugins: [wasm()], + test: { + environment: 'node', + fileParallelism: true, + hookTimeout: 180_000, + testTimeout: 180_000, + }, +});