Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions .github/workflows/deno.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
101 changes: 101 additions & 0 deletions packages/shell/integration/charm.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
6 changes: 1 addition & 5 deletions packages/shell/integration/login.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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();
Expand Down
98 changes: 98 additions & 0 deletions packages/shell/integration/utils.ts
Original file line number Diff line number Diff line change
@@ -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<AppState> {
type TransferrableKeyPair = {
privateKey: Array<number>;
publicKey: Array<number>;
};

const serializedId = identity!.serialize() as InsecureCryptoKeyPair;
const transferrableId = {
privateKey: Array.from(serializedId.privateKey),
publicKey: Array.from(serializedId.privateKey),
};

const state = await page!.evaluate<
Promise<AppState>,
[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<string> {
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;
}
12 changes: 10 additions & 2 deletions packages/shell/src/lib/app/controller.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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<ArrayBufferLike>)
: id;
await this.apply({ type: "set-identity", identity });
}

Expand Down
2 changes: 1 addition & 1 deletion recipes/counter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ export default recipe<RecipeState>("Counter", (state) => {
<ct-button onClick={decrement(state)}>
dec to {previous(state.value)}
</ct-button>
Counter is the {nth(state.value)} number
<span>Counter is the {nth(state.value)} number</span>
<ct-button onClick={increment({ value: state.value })}>
inc to {state.value + 1}
</ct-button>
Expand Down