From c3028c6b9d27b7ed8c730ed28860be45a7549460 Mon Sep 17 00:00:00 2001 From: Jordan Santell Date: Tue, 29 Jul 2025 14:37:43 -0700 Subject: [PATCH] chore: Add a test on viewing and interacting with a charm in shell integration --- .github/workflows/deno.yml | 8 +- packages/shell/integration/charm.test.ts | 101 +++++++++++++++++++++++ packages/shell/integration/login.test.ts | 6 +- packages/shell/integration/utils.ts | 98 ++++++++++++++++++++++ packages/shell/src/lib/app/controller.ts | 12 ++- recipes/counter.tsx | 2 +- 6 files changed, 217 insertions(+), 10 deletions(-) create mode 100644 packages/shell/integration/charm.test.ts create mode 100644 packages/shell/integration/utils.ts diff --git a/.github/workflows/deno.yml b/.github/workflows/deno.yml index 977456429c..969999792e 100644 --- a/.github/workflows/deno.yml +++ b/.github/workflows/deno.yml @@ -500,14 +500,18 @@ jobs: run: | HEADLESS=1 \ API_URL=http://localhost:8000/ \ - FRONTEND_URL=http://localhost:8000/shell/ \ deno task integration # Automatic deployment to staging (toolshed) deploy-toolshed: name: "Deploy to Toolshed (Staging)" if: github.ref == 'refs/heads/main' - needs: ["integration-test", "cli-integration-test", "seeder-integration-test", "shell-integration-test"] + needs: [ + "integration-test", + "cli-integration-test", + "seeder-integration-test", + "shell-integration-test", + ] runs-on: ubuntu-latest environment: toolshed steps: diff --git a/packages/shell/integration/charm.test.ts b/packages/shell/integration/charm.test.ts new file mode 100644 index 0000000000..8031352220 --- /dev/null +++ b/packages/shell/integration/charm.test.ts @@ -0,0 +1,101 @@ +import { PageErrorEvent } from "@astral/astral"; +import { + Browser, + dismissDialogs, + Page, + pipeConsole, +} from "@commontools/integration"; +import { afterAll, beforeAll, describe, it } from "@std/testing/bdd"; +import { assert, assertObjectMatch } from "@std/assert"; +import { Identity } from "@commontools/identity"; +import { login, registerCharm } from "./utils.ts"; +import { join } from "@std/path"; +import "../src/globals.ts"; +import { sleep } from "@commontools/utils/sleep"; + +const API_URL = (() => { + const url = Deno.env.get("API_URL") ?? "http://localhost:8000"; + return url.substr(-1) === "/" ? url : `${url}/`; +})(); +const HEADLESS = !!Deno.env.get("HEADLESS"); +const ASTRAL_TIMEOUT = 60_000; + +describe("shell charm tests", () => { + let browser: Browser | undefined; + let page: Page | undefined; + let identity: Identity | undefined; + const exceptions: string[] = []; + + beforeAll(async () => { + browser = await Browser.launch({ + timeout: ASTRAL_TIMEOUT, + headless: HEADLESS, + }); + page = await browser.newPage(); + page.addEventListener("console", pipeConsole); + page.addEventListener("dialog", dismissDialogs); + page.addEventListener("pageerror", (e: PageErrorEvent) => { + console.error("Browser Page Error:", e.detail.message); + exceptions.push(e.detail.message); + }); + identity = await Identity.generate({ implementation: "noble" }); + }); + + afterAll(async () => { + await page?.close(); + await browser?.close(); + }); + + it("can view and interact with a charm", async () => { + const spaceName = globalThis.crypto.randomUUID(); + + const charmId = await registerCharm({ + spaceName: spaceName, + apiUrl: new URL(API_URL), + identity: identity!, + source: await Deno.readTextFile( + join( + import.meta.dirname!, + "..", + "..", + "..", + "recipes", + "counter.tsx", + ), + ), + }); + + // TODO(js): Remove /shell when no longer prefixed + await page!.goto(`${API_URL}shell/${spaceName}/${charmId}`); + await page!.applyConsoleFormatter(); + + const state = await login(page!, identity!); + assertObjectMatch({ + apiUrl: state.apiUrl, + spaceName: state.spaceName, + activeCharmId: state.activeCharmId, + privateKey: state.identity?.serialize().privateKey, + }, { + apiUrl: state.apiUrl, + spaceName, + activeCharmId: charmId, + privateKey: identity!.serialize().privateKey, + }, "Expected app state with identity"); + + await sleep(2000); + let handle = await page!.$( + "pierce/ct-button", + ); + assert(handle); + handle.click(); + await sleep(1000); + handle.click(); + await sleep(1000); + handle = await page!.$( + "pierce/span", + ); + await sleep(2000); + const text = await handle?.innerText(); + assert(text === "Counter is the -2th number"); + }); +}); diff --git a/packages/shell/integration/login.test.ts b/packages/shell/integration/login.test.ts index 510bea3204..a11a5ab309 100644 --- a/packages/shell/integration/login.test.ts +++ b/packages/shell/integration/login.test.ts @@ -14,10 +14,6 @@ const API_URL = (() => { const url = Deno.env.get("API_URL") ?? "http://localhost:8000/"; return url.substr(-1) === "/" ? url : `${url}/`; })(); -const FRONTEND_URL = (() => { - const url = Deno.env.get("FRONTEND_URL") ?? "http://localhost:5173"; - return url.substr(-1) === "/" ? url : `${url}/`; -})(); const HEADLESS = !!Deno.env.get("HEADLESS"); const ASTRAL_TIMEOUT = 60_000; @@ -47,7 +43,7 @@ describe("shell login tests", () => { it("can create a new user via passphrase", async () => { const spaceName = "common-knowledge"; - await page!.goto(`${FRONTEND_URL}`); + await page!.goto(`${API_URL}shell`); await page!.applyConsoleFormatter(); const state = await page!.evaluate(() => { return globalThis.app.state(); diff --git a/packages/shell/integration/utils.ts b/packages/shell/integration/utils.ts new file mode 100644 index 0000000000..e8c0574ab1 --- /dev/null +++ b/packages/shell/integration/utils.ts @@ -0,0 +1,98 @@ +import { Page } from "@commontools/integration"; +import { ANYONE, Identity, InsecureCryptoKeyPair } from "@commontools/identity"; +import { AppState } from "../src/lib/app/mod.ts"; +import { Runtime } from "@commontools/runner"; +import { StorageManager } from "@commontools/runner/storage/cache"; +import { CharmManager } from "@commontools/charm"; +import { CharmsController } from "@commontools/charm/ops"; + +// Pass the key over the boundary. When the state is returned, +// the key is serialized to Uint8Arrays, and then turned into regular arrays, +// which can then by transferred across the astral boundary. +// +// The passed in identity must use the `noble` implementation, which +// contains raw private key material. +export async function login(page: Page, identity: Identity): Promise { + type TransferrableKeyPair = { + privateKey: Array; + publicKey: Array; + }; + + const serializedId = identity!.serialize() as InsecureCryptoKeyPair; + const transferrableId = { + privateKey: Array.from(serializedId.privateKey), + publicKey: Array.from(serializedId.privateKey), + }; + + const state = await page!.evaluate< + Promise, + [TransferrableKeyPair] + >( + async (rawId) => { + // Convert transferrable key to a raw key of Uint8Array + const keyPairRaw = { + privateKey: Uint8Array.from(rawId.privateKey), + publicKey: Uint8Array.from(rawId.publicKey), + }; + await globalThis.app.setIdentity(keyPairRaw); + const state = globalThis.app.state(); + state.identity = rawId as unknown as Identity; + return state; + }, + { + args: [transferrableId], + }, + ); + + const privateKey = Uint8Array.from( + (state.identity as unknown as TransferrableKeyPair)! + .privateKey, + ); + + state.identity = await Identity.fromRaw(privateKey, { + implementation: "noble", + }); + return state; +} + +// Create a new charm using `source` in the provided space. +// Returns the charm id upon success. +export async function registerCharm( + { apiUrl, source, identity, spaceName }: { + apiUrl: URL; + source: string; + identity: Identity; + spaceName: string; + }, +): Promise { + const account = spaceName.startsWith("~") + ? identity + : await Identity.fromPassphrase(ANYONE); + const user = await account.derive(spaceName); + const session = { + private: account.did() === identity.did(), + name: spaceName, + space: user.did(), + as: user, + }; + + const runtime = new Runtime({ + storageManager: StorageManager.open({ + as: session.as, + address: new URL("/api/storage/memory", apiUrl), + }), + blobbyServerUrl: apiUrl.toString(), + }); + + let charmId: string | undefined; + try { + const manager = new CharmManager(session, runtime); + await manager.synced(); + const charms = new CharmsController(manager); + const charm = await charms.create(source); + charmId = charm.id; + } finally { + await runtime.dispose(); + } + return charmId; +} diff --git a/packages/shell/src/lib/app/controller.ts b/packages/shell/src/lib/app/controller.ts index 9101243ce4..ed7b543954 100644 --- a/packages/shell/src/lib/app/controller.ts +++ b/packages/shell/src/lib/app/controller.ts @@ -1,4 +1,9 @@ -import { Identity, KeyStore } from "@commontools/identity"; +import { + Identity, + isKeyPairRaw, + KeyPairRaw, + KeyStore, +} from "@commontools/identity"; import { XRootView } from "../../views/RootView.ts"; import { Command } from "./commands.ts"; import { AppState, AppUpdateEvent } from "./mod.ts"; @@ -35,7 +40,10 @@ export class App extends EventTarget { await this.apply({ type: "set-active-charm-id", charmId }); } - async setIdentity(identity: Identity) { + async setIdentity(id: Identity | KeyPairRaw) { + const identity = isKeyPairRaw(id) + ? await Identity.fromRaw(id.privateKey as Uint8Array) + : id; await this.apply({ type: "set-identity", identity }); } diff --git a/recipes/counter.tsx b/recipes/counter.tsx index 5de22b4017..656251d270 100644 --- a/recipes/counter.tsx +++ b/recipes/counter.tsx @@ -50,7 +50,7 @@ export default recipe("Counter", (state) => { dec to {previous(state.value)} - Counter is the {nth(state.value)} number + Counter is the {nth(state.value)} number inc to {state.value + 1}