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
81 changes: 77 additions & 4 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -35,12 +35,85 @@ jobs:
- name: Build happ
run: nix develop --command bun run build:happ

# Browser-level e2e against real conductors (ui/tests/e2e). Gated behind the
# build job; when a Sweettest CI job lands, gate this behind that instead so
# backend regressions fail fast before the slower browser suite runs.
e2e:
# Primary backend suite (CLAUDE.md): pure predicate unit tests in
# crates/shared, then Sweettest against real conductors. Runs before e2e so a
# backend regression fails fast instead of surfacing as a confusing browser
# failure ten minutes later.
#
# Sharded one job per [[test]] target. Measured on the first CI run of the
# whole suite in a single job (run 31736241424): 61 min total — 16 min of
# compile plus 40 min of tests executed back-to-back. Sharding overlaps the
# test time so wall clock tracks the slowest target rather than their sum:
#
# nondominium 16m · resource 13m · governance 6m · person 4m · misc 1.5m
#
# Two details make this work and are easy to undo by accident:
#
# * NO custom CARGO_TARGET_DIR. `CLAUDE.md` documents
# `CARGO_TARGET_DIR=target/native-tests` for local runs, which keeps the
# native test artifacts away from the wasm build. In CI that path falls
# outside what Swatinem/rust-cache saves, so every run recompiled
# holochain's test_utils from scratch — the 16 min. The default `target/`
# is cached, and cargo already separates wasm by target triple.
# * `shared-key` so all shards restore ONE cache rather than fighting over
# five. The first run on a new lockfile still pays the compile in
# parallel; later runs restore it.
#
# --test-threads 2: each Sweettest spawns conductors, and 6 threads with 11
# conductor tests in flight gets the runner OOM-killed (SIGTERM mid-suite).
sweettest:
runs-on: ubuntu-latest
needs: build
strategy:
# Report every failing target, not just the first — a shared regression
# usually breaks several, and seeing which ones is the diagnosis.
fail-fast: false
matrix:
target: [misc, person, governance, resource, nondominium]
steps:
- uses: actions/checkout@v4
with:
submodules: recursive

- uses: cachix/install-nix-action@v26
with:
github_access_token: ${{ secrets.GITHUB_TOKEN }}

- uses: cachix/cachix-action@v14
with:
name: holochain-ci
skipPush: true

- uses: Swatinem/rust-cache@v2
with:
shared-key: sweettest
workspaces: |
. -> target
vendor/hrea -> vendor/hrea/target

- name: Install dependencies
run: nix develop --command bun install

# Sweettest loads the packaged .happ, so the WASM build is a prerequisite.
- name: Build happ
run: nix develop --command bun run build:happ

# Pure predicate tests — no conductors, seconds to run. Only needs to run
# once, so it rides the cheapest shard.
- name: Run shared-crate unit tests
if: matrix.target == 'misc'
run: nix develop --command cargo test --package nondominium_shared

- name: Run Sweettest target
run: |
nix develop --command cargo test --package nondominium_sweettest \
--test ${{ matrix.target }} -- --test-threads 2

# Browser-level e2e against real conductors (ui/tests/e2e). Gated behind
# sweettest so backend regressions fail before the slower browser suite runs.
e2e:
runs-on: ubuntu-latest
needs: sweettest
steps:
- uses: actions/checkout@v4
with:
Expand Down
33 changes: 26 additions & 7 deletions ui/tests/e2e/specs/core-flows.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
createGroup,
createNdo,
ensureLobbyProfile,
expectEmptyLobby,
expectEventually,
gotoAgent
} from '../utils/e2e-helpers.js';
Expand Down Expand Up @@ -82,13 +83,27 @@ test.describe.serial('nondominium core flows', () => {
modifiers: { network_seed: `e2e-clone-guard-${Date.now()}` }
});
await authorizeWithRetry(seed.admin, clone.cell_id);
const myGroup = await seed.app.callZome({
cell_id: clone.cell_id,
zome_name: 'zome_group',
fn_name: 'get_my_group',
payload: null
});
expect(myGroup).toBeNull();
try {
const myGroup = await seed.app.callZome({
cell_id: clone.cell_id,
zome_name: 'zome_group',
fn_name: 'get_my_group',
payload: null
});
expect(myGroup).toBeNull();
} finally {
// The UI enumerates group clone cells straight off appInfo, so a leftover
// guard clone shows up in the sidebar as a real group and makes the next
// test's "empty lobby" precondition false. Tear it down here — the guard
// owns this cell, nothing downstream should see it.
await seed.app.disableCloneCell({
clone_cell_id: { type: 'dna_hash', value: clone.cell_id[0] }
});
await seed.admin.deleteCloneCell({
app_id: seed.appId,
clone_cell_id: { type: 'dna_hash', value: clone.cell_id[0] }
});
}
});

// ── Phase 1: single-agent core flows ──────────────────────────────────────
Expand All @@ -103,6 +118,10 @@ test.describe.serial('nondominium core flows', () => {
});

test('empty lobby shows the create-or-join onboarding CTA', async () => {
// The CTA only renders when the agent has no groups, so the emptiness is a
// precondition rather than part of what is under test. Assert it first —
// when it breaks, the message should say so.
await expectEmptyLobby(page);
await expect(page.getByText('Create or join a group to see NDOs')).toBeVisible();
});

Expand Down
34 changes: 33 additions & 1 deletion ui/tests/e2e/utils/e2e-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,31 @@ export async function saveGroupProfileIfPrompted(page: Page): Promise<void> {
}
}

/**
* Asserts the agent has no groups yet.
*
* Several lobby behaviours (the create-or-join onboarding CTA above all) are
* only reachable from a genuinely empty lobby, and that emptiness is a property
* of shared conductor state rather than of the test itself. Any earlier test —
* or any spec file that happens to sort earlier, since the suite runs
* `workers: 1`, `fullyParallel: false` — can quietly invalidate it.
*
* Assert it explicitly so the failure names the broken precondition instead of
* surfacing ten seconds later as an inscrutable "element not found" on whatever
* the empty state was supposed to render.
*/
export async function expectEmptyLobby(page: Page): Promise<void> {
const groupLinks = page.locator('nav a[href^="/group/"]');
const count = await groupLinks.count();
const names = count > 0 ? await groupLinks.allInnerTexts() : [];
expect(
count,
`precondition failed: expected an empty lobby, found ${count} group(s) in the sidebar ` +
`(${names.join(', ')}). Some earlier test leaked conductor state — check for a ` +
`clone cell or group that was created and never torn down.`
).toBe(0);
}

/**
* Creates a group through the sidebar and lands on /group/{seed}. Handles the
* first-visit GroupProfileModal.
Expand All @@ -140,7 +165,14 @@ export async function createGroup(page: Page, name: string): Promise<string> {

export interface NdoFormInput {
name: string;
regime?: 'Private' | 'Commons' | 'Nondominium' | 'CommonPool';
regime?:
| 'Private'
| 'Commons'
| 'Collective'
| 'Pool'
| 'CommonPool'
| 'Public'
| 'Nondominium';
nature?: 'Physical' | 'Digital' | 'Service' | 'Hybrid' | 'Information';
stage?: string;
description?: string;
Expand Down
7 changes: 6 additions & 1 deletion ui/tests/setup/harness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,8 @@ export async function authorizeWithRetry(
export interface SeedClient {
app: AppWebsocket;
admin: AdminWebsocket;
/** Installed app id — needed for admin calls scoped to the app (e.g. deleteCloneCell). */
appId: string;
close: () => Promise<void>;
}

Expand Down Expand Up @@ -152,8 +154,10 @@ export async function createSeedClient(agent = 1): Promise<SeedClient> {
await authorizeWithRetry(admin, cellId);
}

const appId = ready.appId || APP_ID;

const { token } = await admin.issueAppAuthenticationToken({
installed_app_id: ready.appId || APP_ID,
installed_app_id: appId,
single_use: false,
expiry_seconds: 3600
});
Expand All @@ -168,6 +172,7 @@ export async function createSeedClient(agent = 1): Promise<SeedClient> {
return {
app,
admin,
appId,
close: async () => {
try {
await app.client.close();
Expand Down
Loading