test(e2e): cover shared wallet with two real client sends

This commit is contained in:
lefarcen
2026-10-25 12:03:10 +08:00
parent a4f92869d3
commit 246dac10a1
6 changed files with 819 additions and 1 deletions
@@ -0,0 +1,359 @@
import { randomUUID, createHash } from 'node:crypto';
import { mkdir, rm, writeFile } from 'node:fs/promises';
import { dirname, join } from 'node:path';
import type { Page, Request, Response, TestInfo } from '@playwright/test';
import type { WorkspaceBillingPreflight, WorkspaceBillingResponse } from '@open-design/contracts';
import { requireScopedFundingPreflight } from './workspace-funding-preflight.ts';
import { expect } from './suite.ts';
import { warmPlaywrightDaemonRuntime, warmPlaywrightWebRuntime } from './runtime-lifecycle.ts';
import { fundingHeaders, readFundingFixture, requireDpModelCoverage, type FundingSpace, type FundingFixture } from './workspace-funding-fixture.ts';
import { verifyFundingLease } from './workspace-funding-lease.ts';
import { createToolsDevSuite, e2eWorkspaceRoot } from '../tools-dev/runtime.ts';
import { T } from '../timeouts.ts';
export type Preflight = WorkspaceBillingPreflight;
export async function startFundingClient(page: Page, info: TestInfo, options: {
preserveSelectedLocale?: boolean; initialSpace?: keyof FundingFixture['spaces'];
freeWallet?: 'retained' | 'giftOnly'; freeWalletPhase?: 'before' | 'after';
scopeSwitch?: boolean;
} = {}) {
if (process.env.OD_FUNDING_OWNER_RUNNER !== 'active') {
throw new Error('Use the mandatory funding-runner entrypoint; direct Playwright invocation is not owned');
}
const loaded = await readFundingFixture();
const wallet = options.freeWallet ? loaded.freeWallet?.[options.freeWallet] : undefined;
if (options.freeWallet && !wallet) throw new Error('Explicit real personal Free wallet required');
const switchGiven = options.scopeSwitch ? loaded.scopeSwitch : undefined;
if (options.scopeSwitch && !switchGiven?.deviceRuntimeKey) throw new Error('Explicit WS-005 account DeviceAuthorization credential required');
const fixture = wallet ? { ...loaded, actor: wallet.actor } : loaded;
const client = await startScopedFundingClient(page, info, {
fixture,
initialSpace: wallet?.space ?? switchGiven?.spaces.a ?? fixture.spaces[options.initialSpace ?? 'dp'],
checkedSpaces: wallet ? { wallet: wallet.space } : switchGiven?.spaces ?? fixture.spaces,
runtimeKey: wallet?.space.runtimeKey ?? switchGiven?.deviceRuntimeKey ?? fixture.spaces.dp.runtimeKey,
...(options.freeWallet ? { identitySuffix: options.freeWallet } : {}),
attachmentPrefix: options.freeWallet ? `VIEW021-${options.freeWallet}-` : '',
requireNamePrefix: !wallet,
...(options.preserveSelectedLocale !== undefined ? { preserveSelectedLocale: options.preserveSelectedLocale } : {}),
checkPreflight(alias, space, preflight) {
// Free has no subscription-backed Design Plan descriptor.
if (!switchGiven) expect(preflight.codingPlan.tier).toBe(wallet ? null : 'go');
if (switchGiven) expect(Number(preflight.balanceUsd)).toBe(alias === 'a' ? 10 : 20);
if (wallet) {
expect(preflight.codingPlan.eligible).toBe(false);
expect(preflight.codingPlan.windows).toEqual([]);
expect(space.workspaceKind).toBe('personal');
expect(space.actorRole).toBe('owner');
expect(Number(preflight.balanceUsd)).toBe(Number(options.freeWalletPhase === 'after' ? wallet.afterBalanceUsd : wallet.beforeBalanceUsd));
}
// `unknown` is a legitimate advisory result for funded/DP-eligible
// inputs. Only B needs a reliable insufficient verdict; Link remains
// authoritative for actual model admission.
if (alias === 'funded') expect(Number(preflight.balanceUsd)).toBe(100);
if (alias === 'empty') {
expect(Number(preflight.balanceUsd)).toBe(0);
expect(preflight.fundingCheck).toBe('insufficient');
expect(preflight.codingPlan.windows.length).toBe(2);
expect(preflight.codingPlan.windows.every(w => Number(w.remainingCredits) === 0)).toBe(true);
}
if (alias === 'dp') {
expect(Number(preflight.balanceUsd)).toBe(0);
requireDpModelCoverage(preflight.modelCovered === true);
expect(preflight.codingPlan.eligible).toBe(true);
expect(preflight.codingPlan.windows.length).toBe(2);
expect(preflight.codingPlan.windows.every(w => Number(w.remainingCredits) > 0)).toBe(true);
expect(preflight.fundingCheck).not.toBe('insufficient');
}
},
});
return { ...client, fixture };
}
/** Shared owned runtime, real directory and close path for scoped billing witnesses. */
export async function startScopedFundingClient(page: Page, info: TestInfo, options: {
fixture: Pick<FundingFixture, 'isolation' | 'owner' | 'upstream' | 'runtime' | 'actor'>;
initialSpace: FundingSpace;
checkedSpaces: Record<string, FundingSpace>;
runtimeKey: string;
identitySuffix?: string;
attachmentPrefix?: string;
requireNamePrefix: boolean;
preserveSelectedLocale?: boolean;
checkPreflight: (alias: string, space: FundingSpace, preflight: Preflight) => void | Promise<void>;
}) {
if (process.env.OD_FUNDING_OWNER_RUNNER !== 'active') {
throw new Error('Use the mandatory funding-runner entrypoint; direct Playwright invocation is not owned');
}
const { fixture, initialSpace, checkedSpaces } = options;
const ownerPre = await verifyFundingLease(fixture, T.xlong * 7);
const namespace = `funding-${process.pid}-${randomUUID().slice(0, 8)}`;
const root = join(e2eWorkspaceRoot(), '.tmp', 'e2e', namespace);
const scratch = join(root, 'scratch');
const runtime = createToolsDevSuite({
root, namespace, ownerPid: process.pid,
dataDir: join(scratch, 'data'),
toolsDevRoot: join(scratch, 'tools-dev'),
codexHomeDir: join(scratch, 'codex-home'),
});
const amrHome = join(scratch, 'amr-home');
await mkdir(amrHome, { recursive: true, mode: 0o700 });
await mkdir(runtime.dataDir, { recursive: true });
await writeFile(join(amrHome, 'config.json'), JSON.stringify({ profiles: { local: {
controlKey: fixture.actor.controlKey,
runtimeKey: options.runtimeKey,
apiUrl: fixture.upstream.apiUrl,
linkUrl: fixture.upstream.linkUrl,
user: { id: fixture.actor.userId, email: fixture.actor.email, name: 'Disposable funding QA' },
} } }), { mode: 0o600 });
const config = {
privacyDecisionAt: 1, telemetry: { metrics: false, content: false, artifactManifest: false },
mode: 'daemon', agentId: 'amr', model: fixture.runtime.modelId, onboardingCompleted: true,
agentModels: { amr: { model: fixture.runtime.modelId } },
agentCliEnv: { amr: { OPEN_DESIGN_AMR_PROFILE: 'local', VELA_BIN: fixture.runtime.velaBin } },
};
await writeFile(join(runtime.dataDir, 'app-config.json'), JSON.stringify(config));
const env = {
AMR_HOME: amrHome, VELA_BIN: fixture.runtime.velaBin, VELA_PROFILE: 'local',
OPEN_DESIGN_AMR_PROFILE: 'local', VELA_API_URL: fixture.upstream.apiUrl,
VELA_LINK_URL: fixture.upstream.linkUrl, VELA_CONTROL_KEY: fixture.actor.controlKey,
VELA_OPENCODE_BIN: fixture.runtime.openCodeBin,
OD_WORKSPACE_CONTEXT_SOURCE: 'vela', OD_VELA_WEB_URL: fixture.upstream.webUrl,
OD_COLLAB_TRANSPORT: 'off', OD_RESOURCE_TRANSPORT: 'off', OD_TEAM_PROJECTS_TRANSPORT: 'off',
};
const posts: Array<{ path: string; workspaceId: string | undefined }> = [];
page.on('request', request => {
const path = new URL(request.url()).pathname;
if (request.method() === 'POST' && ['/api/projects', '/api/runs'].includes(path)) {
posts.push({ path, workspaceId: request.headers()['x-od-workspace-id'] });
}
});
await page.route('**/*', route => {
const url = new URL(route.request().url());
return !url.protocol.startsWith('http') || ['127.0.0.1', 'localhost', '[::1]'].includes(url.hostname)
? route.continue() : route.abort();
});
const identityPath = info.outputPath(`owned-runtime-identity${options.identitySuffix ? `-${options.identitySuffix}` : ''}.safe.json`);
await mkdir(dirname(identityPath), { recursive: true });
await writeFile(identityPath, JSON.stringify({ namespace, root, dataDir: runtime.dataDir,
toolsDevRoot: runtime.toolsDevRoot, testPid: process.pid }, null, 2), { mode: 0o600 });
try {
await runtime.startWeb(env);
await warmPlaywrightWebRuntime(runtime.url.web('/'));
await warmPlaywrightDaemonRuntime(runtime.url.daemon('/api/health'));
} catch (error) {
await page.close().catch(() => undefined);
await runtime.stopWeb(env);
throw error;
}
const attach = async (name: string, value: unknown) => info.attach(`${options.attachmentPrefix ?? ''}${name}`, {
body: JSON.stringify(value, null, 2), contentType: 'application/json',
});
try {
const directoryResponse = await fetch(runtime.url.web('/api/workspace/directory'));
expect(directoryResponse.ok).toBe(true);
const directory = await directoryResponse.json() as {
items?: Array<{ workspaceId: string; workspaceName: string; workspaceMemberId: string; role: string }>;
workspaces?: Array<{ workspaceId: string; workspaceName: string; workspaceMemberId: string; role: string }>;
};
const items = directory.items ?? directory.workspaces ?? [];
for (const space of Object.values(checkedSpaces)) {
const item = items.find(item => item.workspaceId === space.workspaceId);
expect(item, 'Every sandbox workspace must be authorized for the explicit actor').toBeTruthy();
if (options.requireNamePrefix) expect(item!.workspaceName.startsWith(fixture.isolation.workspaceNamePrefix), 'Sandbox workspace isolation prefix').toBe(true);
expect(item!.workspaceMemberId).toBe(space.actorMemberId ?? space.ownerMemberId);
expect(item!.role).toBe(space.actorRole ?? 'owner');
}
const preconditions: Record<string, Preflight> = {};
for (const [alias, space] of Object.entries(checkedSpaces)) {
const url = new URL('/api/workspace/billing', runtime.url.web());
url.search = new URLSearchParams({ scope: 'workspace', workspaceId: space.workspaceId,
includePreflight: '1', modelId: fixture.runtime.modelId }).toString();
const response = await fetch(url, { headers: fundingHeaders(space) });
const rawBody = await response.text();
// Keep the exact failed prerequisite too; a failed assertion must not
// leave only an undefined-field error with no original response.
await attach(`real-sandbox-preflight-${alias}`, {
workspaceId: space.workspaceId, modelId: fixture.runtime.modelId,
status: response.status, body: rawBody,
});
expect(response.ok).toBe(true);
const preflight = requireScopedFundingPreflight(JSON.parse(rawBody) as WorkspaceBillingResponse, {
workspaceId: space.workspaceId,
workspaceMemberId: space.actorMemberId ?? space.ownerMemberId,
modelId: fixture.runtime.modelId,
});
await options.checkPreflight(alias, space, preflight);
preconditions[alias] = preflight;
}
await attach('real-sandbox-preconditions', { runId: fixture.isolation.runId,
amrSha: fixture.upstream.amrSha, velaSha256: fixture.runtime.velaSha256,
openCodeSha256: fixture.runtime.openCodeSha256, preconditions });
await attach('canonical-owner-live-pre', ownerPre.safe);
await page.addInitScript(({ config, space, preserveSelectedLocale }) => {
localStorage.setItem('open-design:config', JSON.stringify(config));
if (!preserveSelectedLocale || !localStorage.getItem('open-design:locale')) localStorage.setItem('open-design:locale', 'en');
localStorage.setItem('open-design:locale-source', 'manual');
sessionStorage.setItem('od.workspaceSelection.v1', JSON.stringify({
workspaceId: space.workspaceId, workspaceMemberId: space.actorMemberId ?? space.ownerMemberId,
}));
}, { config, space: initialSpace, preserveSelectedLocale: options.preserveSelectedLocale ?? false });
const select = async (space: FundingSpace) => {
const toggle = page.getByTestId('entry-rail-collapse');
if (await toggle.getAttribute('aria-expanded') !== 'true') await toggle.click({ timeout: T.medium });
await expect(toggle).toHaveAttribute('aria-expanded', 'true');
const item = items.find(item => item.workspaceId === space.workspaceId)!;
const switcher = page.getByTestId('workspace-switcher');
const selected = await page.evaluate(() => JSON.parse(sessionStorage.getItem('od.workspaceSelection.v1') ?? 'null')?.workspaceId);
await switcher.click({ timeout: T.medium });
const row = page.getByTestId('workspace-switcher-list').getByRole('menuitem')
.filter({ hasText: item.workspaceName });
if (selected === space.workspaceId) {
await expect(row).toHaveAttribute('aria-current', 'true');
// This backdrop is scoped to the rail: its center may be covered by a long menu.
await page.locator('.entry-nav-rail__menu-backdrop').click({ position: { x: 2, y: 2 }, timeout: T.medium });
await expect(switcher).toHaveAttribute('aria-expanded', 'false');
return; // Selecting the already-current workspace correctly sends no PUT.
}
const changed = page.waitForResponse(r => r.request().method() === 'PUT'
&& new URL(r.url()).pathname === '/api/workspace/active', { timeout: T.medium });
await row.click({ timeout: T.medium });
const response = await changed;
expect(response.ok()).toBe(true);
expect((await response.json()).context.workspaceId).toBe(space.workspaceId);
await expect.poll(() => page.evaluate(() => JSON.parse(sessionStorage.getItem('od.workspaceSelection.v1') ?? 'null')?.workspaceId)).toBe(space.workspaceId);
};
return {
fixture, runtime, posts, attach, select, authorizedSpaces: items,
async createProject(space: FundingSpace) {
const id = `funding-${randomUUID()}`;
const response = await fetch(runtime.url.web('/api/projects'), {
method: 'POST', headers: { ...fundingHeaders(space), 'content-type': 'application/json' },
body: JSON.stringify({ id, name: 'Disposable workspace funding regression', metadata: { kind: 'prototype' } }),
});
expect(response.ok).toBe(true);
return id;
},
async close(closeOptions: { preserveScratch?: boolean } = {}) {
await info.attach(`${options.attachmentPrefix ?? ''}final-observed-page`, { body: await page.screenshot(), contentType: 'image/png' }).catch(() => undefined);
await attach('actual-POST-scopes', posts);
let pageClosed = false;
try {
try {
await page.close();
pageClosed = true;
}
finally {
try { await runtime.stopWeb(env); }
finally {
const stopped = await runtime.status(env);
await attach('owned-runtime-stopped', stopped);
expect(Object.values(stopped.apps ?? {}).every(app => !app?.pid), 'Every owned process must be terminal').toBe(true);
}
}
} finally {
await attach('canonical-owner-live-post', (await verifyFundingLease(fixture, 0)).safe);
}
if (pageClosed && info.status === info.expectedStatus && !closeOptions.preserveScratch) {
await rm(root, { recursive: true, force: true });
}
},
};
} catch (error) {
await page.close().catch(() => undefined);
await runtime.stopWeb(env);
throw error;
}
}
export function holdRealBilling(page: Page, workspaceIds: string[]) {
type Held = { workspaceId: string; path: string; sha256: string;
upstreamFetchedAt: number; browserCompletedAt: number | null; browserStatus: number | null; requestFailure: string | null;
release: () => Promise<void>; cleanup: () => Promise<void> };
const held: Held[] = [];
let enabled = true;
const install = page.route('**/api/workspace/billing**', async route => {
const request = route.request();
const url = new URL(request.url());
const workspaceId = url.searchParams.get('workspaceId') ?? request.headers()['x-od-workspace-id'] ?? '';
if (!enabled || !workspaceIds.includes(workspaceId)) return route.continue();
// Only delivery timing is controlled. No balances, funding verdicts, or
// upstream bytes are supplied by this test.
const response = await route.fetch({ timeout: T.xlong });
const body = await response.body();
if (!enabled) return route.fulfill({ response, body });
let unblock!: () => void;
const blocked = new Promise<void>(resolve => { unblock = resolve; });
let fulfilled!: () => void;
const done = new Promise<void>(resolve => { fulfilled = resolve; });
let released: Promise<void> | undefined;
const row: Held = { workspaceId, path: url.pathname,
sha256: createHash('sha256').update(body).digest('hex'),
upstreamFetchedAt: Date.now(), browserCompletedAt: null, browserStatus: null, requestFailure: null,
release: () => released ??= (async () => {
const arrived = new Promise<Response | null>((resolve, reject) => {
let settled = false;
const finish = (value: Response | null, error?: Error) => {
if (settled) return;
settled = true;
clearTimeout(timeout);
page.off('response', onResponse);
page.off('requestfailed', onFailure);
if (error) reject(error); else resolve(value);
};
const onFailure = (failed: Request) => {
if (failed !== request) return;
row.requestFailure = failed.failure()?.errorText ?? 'unknown request failure';
if (row.requestFailure === 'net::ERR_ABORTED' || row.requestFailure === 'ERR_ABORTED') finish(null);
else finish(null, new Error(`Unexpected billing request failure: ${row.requestFailure}`));
};
const onResponse = (observed: Response) => {
if (observed.request() === request) finish(observed);
};
const timeout = setTimeout(() => finish(null, new Error('Billing delivery completion signal timed out')), T.long);
page.on('response', onResponse);
page.on('requestfailed', onFailure);
// A workspace switch may already have canceled this exact request.
// Register first so an in-flight abort cannot fall between checks.
if (request.failure()) onFailure(request);
});
unblock();
const browserResponse = await arrived;
if (browserResponse) {
row.browserStatus = browserResponse.status();
const error = await browserResponse.finished();
if (error) {
row.requestFailure = request.failure()?.errorText ?? String(error);
expect(['net::ERR_ABORTED', 'ERR_ABORTED']).toContain(row.requestFailure);
} else row.browserCompletedAt = Date.now();
}
await done;
})(),
cleanup: async () => { unblock(); await done; },
};
held.push(row);
await blocked;
try { await route.fulfill({ response, body }); }
catch (error) {
const failure = request.failure()?.errorText;
if (failure !== 'net::ERR_ABORTED' && failure !== 'ERR_ABORTED') throw error;
}
finally { fulfilled(); }
});
return { held, install,
stopHolding() { enabled = false; },
async release(workspaceId: string) {
const matching = held.filter(row => row.workspaceId === workspaceId);
expect(matching.length).toBeGreaterThan(0);
await Promise.all(matching.map(row => row.release()));
},
async dispose() {
enabled = false;
// Cleanup only: canceled navigation may prevent a browser receipt.
await Promise.allSettled(held.map(row => row.cleanup()));
await page.unroute('**/api/workspace/billing**');
},
};
}
@@ -0,0 +1,195 @@
import { createHash } from 'node:crypto';
import { access, readFile, stat } from 'node:fs/promises';
import { constants } from 'node:fs';
import { isAbsolute } from 'node:path';
export type FundingSpace = {
workspaceId: string;
ownerMemberId: string;
runtimeKey: string;
workspaceKind?: 'team' | 'personal';
/** Version2 keeps the actual owner distinct from this logged-in actor. */
actorMemberId?: string;
actorRole?: 'owner' | 'admin' | 'member';
};
export type FundingFixture = {
version: 1 | 2;
isolation: {
runId: string;
expiresAt: string;
workspaceNamePrefix: string;
exclusive: true;
};
upstream: { apiUrl: string; linkUrl: string; webUrl: string; amrSha: string };
owner: {
sourceRoot: string;
sandboxName: string;
ownerEnvPath: string;
runtimeReceiptPath: string;
helperSha256: string;
canonicalToolsOverlay?: { sourceSha: string; files: Array<{ path: string; sha256: string }> };
};
runtime: {
velaBin: string;
velaSha256: string;
openCodeBin: string;
openCodeSha256: string;
modelId: string;
};
actor: { controlKey: string; userId: string; email: string };
spaces: { funded: FundingSpace; empty: FundingSpace; dp: FundingSpace };
/** WS-005 keeps both real tasks bound to their original workspace. */
scopeSwitch?: {
attemptId: string;
/** One real account DeviceAuthorization credential for the whole A/B flow. */
deviceRuntimeKey: string;
spaces: { a: FundingSpace & { workspaceName: string }; b: FundingSpace & { workspaceName: string } };
proofPath: string;
proofSha256: string;
provider: { baseUrl: string; statePath: string; sourcePath: string; sourceSha256: string;
sourceCommit: string; sourceRepoPath: string; pid: number };
usage: Record<'a' | 'b', Array<{ kind: 'main' | 'aux'; modelId: string;
tokens: Record<string, number>; expectedCredits: string }>>;
};
/** VIEW-021 uses actual default personal spaces and their separate Owners. */
freeWallet?: {
retained: FreeWalletGiven;
giftOnly: FreeWalletGiven;
proofPath: string;
proofSha256: string;
consoleUi: { phase1Enabled: boolean; proofPath: string; proofSha256: string };
expiry: { mode?: 'controlled_age' | 'natural_wait'; helperPath: string; helperSha256: string; helperSourceSha: string; helperRepoPath: string };
};
/** VIEW-020's actual long Owner profile, verified by the environment owner. */
noticeLayout?: { ownerUserId: string; ownerDisplayName: string;
ownerProfileProofPath: string; ownerProfileProofSha256: string };
/** Explicit server-owned capacity coordinator for the manual Link lane. */
concurrency?: {
space: FundingSpace & { actorRole: 'owner'; actorMemberId: string };
member: { memberId: string; runtimeKey: string };
holdModelId: string;
coordinator: { helperPath: string; helperSha256: string; helperSourceSha: string; helperRepoPath: string };
serverControl: {
providerBaseUrl: string; providerStatePath: string; providerSourcePath: string;
providerSourceSha256: string; controlTokenPath: string;
redisContainer: string; redisKeyPrefix: string;
};
};
};
export type FundingRuntimeFixture = Pick<FundingFixture, 'version' | 'isolation' | 'upstream' | 'owner' | 'runtime' | 'actor'>;
export type FreeWalletGiven = {
actor: FundingFixture['actor'];
space: FundingSpace & { workspaceName: string; workspaceKind: 'personal'; actorRole: 'owner'; actorMemberId: string };
beforeBalanceUsd: '40.30' | '0.30';
afterBalanceUsd: '40.00' | '0.00';
consoleCookiesPath: string;
consoleCookiesSha256: string;
};
function requireValue(value: unknown, label: string): asserts value is string {
if (typeof value !== 'string' || value.trim().length === 0) {
throw new Error(`Sandbox fixture requires ${label}`);
}
}
export function validateFundingRuntimeFixture(value: unknown, expectedRunId: string): FundingRuntimeFixture {
const fixture = value as FundingRuntimeFixture | undefined;
if (![1, 2].includes(fixture?.version ?? 0) || fixture?.isolation?.exclusive !== true) {
throw new Error('Requires version 1 or 2 exclusively leased disposable sandbox fixture');
}
requireValue(expectedRunId, 'OD_FUNDING_RUN_ID');
if (fixture.isolation.runId !== expectedRunId) throw new Error('Sandbox run ID mismatch');
if (!Number.isFinite(Date.parse(fixture.isolation.expiresAt))
|| Date.parse(fixture.isolation.expiresAt) <= Date.now()) {
throw new Error('Sandbox lease has expired');
}
requireValue(fixture.isolation.workspaceNamePrefix, 'isolation.workspaceNamePrefix');
if (fixture.isolation.workspaceNamePrefix.length < 8) throw new Error('Sandbox workspace prefix is too broad');
for (const field of ['apiUrl', 'linkUrl', 'webUrl'] as const) {
requireValue(fixture.upstream?.[field], `upstream.${field}`);
const url = new URL(fixture.upstream[field]);
if (url.protocol !== 'http:' || !['127.0.0.1', 'localhost', '[::1]'].includes(url.hostname)
|| url.username || url.password || url.pathname !== '/' || url.search || url.hash) {
throw new Error(`upstream.${field} must be a credential-free loopback HTTP origin`);
}
}
if (!/^[a-f0-9]{40}$/.test(fixture.upstream.amrSha)) throw new Error('Requires complete AMR source SHA');
for (const key of ['sourceRoot', 'ownerEnvPath', 'runtimeReceiptPath'] as const) {
requireValue(fixture.owner?.[key], `owner.${key}`);
if (!isAbsolute(fixture.owner[key])) throw new Error(`owner.${key} must be absolute`);
}
requireValue(fixture.owner?.sandboxName, 'owner.sandboxName');
if (!/^[a-f0-9]{64}$/.test(fixture.owner.helperSha256)) throw new Error('Requires canonical owner helper SHA256');
for (const key of ['controlKey', 'userId', 'email'] as const) requireValue(fixture.actor?.[key], `actor.${key}`);
requireValue(fixture.runtime?.modelId, 'runtime.modelId');
for (const key of ['vela', 'openCode'] as const) {
requireValue(fixture.runtime[`${key}Bin`], `runtime.${key}Bin`);
if (!isAbsolute(fixture.runtime[`${key}Bin`]) || !/^[a-f0-9]{64}$/.test(fixture.runtime[`${key}Sha256`])) {
throw new Error(`Requires explicit absolute ${key} binary and SHA256`);
}
}
return fixture;
}
export function validateFundingFixture(value: unknown, expectedRunId: string): FundingFixture {
validateFundingRuntimeFixture(value, expectedRunId);
const fixture = value as FundingFixture;
for (const alias of ['funded', 'empty', 'dp'] as const) {
for (const key of ['workspaceId', 'ownerMemberId', 'runtimeKey'] as const) {
requireValue(fixture.spaces?.[alias]?.[key], `spaces.${alias}.${key}`);
}
const space = fixture.spaces[alias];
if (space.workspaceKind !== undefined && !['team', 'personal'].includes(space.workspaceKind)) throw new Error('Explicit workspace kind must be team or personal');
if (fixture.version === 2) {
requireValue(space.actorMemberId, `spaces.${alias}.actorMemberId`);
if (!['owner', 'admin', 'member'].includes(space.actorRole ?? '')) throw new Error('Version2 requires an explicit actor role');
if ((space.actorRole === 'owner') !== (space.actorMemberId === space.ownerMemberId)) {
throw new Error('Actual owner and actor membership/role must agree');
}
} else if (space.actorMemberId !== undefined || space.actorRole !== undefined) {
throw new Error('Role-aware fixtures require version2; legacy version1 is Owner-only');
}
}
if (new Set(Object.values(fixture.spaces).map(s => s.workspaceId)).size !== 3
|| new Set(Object.values(fixture.spaces).map(s => s.runtimeKey)).size !== 3) {
throw new Error('funded/empty/dp require three distinct workspaces and scoped runtime keys');
}
return fixture;
}
export async function readFundingRuntimeFixture(): Promise<FundingRuntimeFixture> {
const path = process.env.OD_FUNDING_FIXTURE;
if (!path || !isAbsolute(path)) throw new Error('OD_FUNDING_FIXTURE must be an explicit absolute private fixture path');
const info = await stat(path);
if (process.platform !== 'win32' && (info.mode & 0o077) !== 0) throw new Error('Fixture must be private (0600)');
const fixture = validateFundingRuntimeFixture(JSON.parse(await readFile(path, 'utf8')), process.env.OD_FUNDING_RUN_ID ?? '');
for (const key of ['vela', 'openCode'] as const) {
const binary = fixture.runtime[`${key}Bin`];
await access(binary, constants.X_OK);
const hash = createHash('sha256').update(await readFile(binary)).digest('hex');
if (hash !== fixture.runtime[`${key}Sha256`]) throw new Error(`${key} executable hash mismatch`);
}
return fixture;
}
export async function readFundingFixture(): Promise<FundingFixture> {
return validateFundingFixture(await readFundingRuntimeFixture(), process.env.OD_FUNDING_RUN_ID ?? '');
}
export function fundingHeaders(space: FundingSpace): Record<string, string> {
return {
'x-od-workspace-id': space.workspaceId,
'x-od-workspace-member-id': space.actorMemberId ?? space.ownerMemberId,
'x-od-workspace-role': space.actorRole ?? 'owner',
'x-od-workspace-type': space.workspaceKind ?? 'team',
'x-od-workspace-lifecycle-state': 'active',
'x-od-workspace-member-status': 'active',
};
}
export function requireDpModelCoverage(modelCovered: unknown): void {
if (modelCovered !== true) throw new Error('Chosen model must actually be covered by the available Design Plan');
}
@@ -0,0 +1,149 @@
import { execFile } from 'node:child_process';
import { createHash } from 'node:crypto';
import { readFile, stat } from 'node:fs/promises';
import { join } from 'node:path';
import { promisify } from 'node:util';
import type { FundingFixture } from './workspace-funding-fixture.ts';
import { T } from '../timeouts.ts';
const exec = promisify(execFile);
const digest = (bytes: Buffer | string) => createHash('sha256').update(bytes).digest('hex');
const canonicalToolsSha = '3dfcdc6fb4f940f2955cfa4835315d4ee9f83c82';
const canonicalToolPaths = ['sandbox/cli.py', 'sandbox/app/docker-compose.yml'] as const;
/** Only the explicitly reviewed canonical seats-UI opt-in tools may differ
* from the fixed product HEAD. No general allowDirty or product exception. */
export function assertCanonicalToolsOverlay(
overlay: NonNullable<FundingFixture['owner']['canonicalToolsOverlay']>, changedPaths: string[],
observed: Array<{ path: string; rawSha256: string; gitBlobSha256: string }>,
) {
const paths = [...canonicalToolPaths].sort();
if (overlay.sourceSha !== canonicalToolsSha || overlay.files.length !== 2
|| JSON.stringify(overlay.files.map(f => f.path).sort()) !== JSON.stringify(paths)
|| JSON.stringify([...changedPaths].sort()) !== JSON.stringify(paths)
|| observed.length !== 2 || JSON.stringify(observed.map(f => f.path).sort()) !== JSON.stringify(paths)) {
throw new Error('Only the reviewed exact two-file canonical tools overlay is permitted');
}
for (const expected of overlay.files) {
const actual = observed.find(file => file.path === expected.path)!;
if (!/^[a-f0-9]{64}$/.test(expected.sha256) || expected.sha256 !== actual.rawSha256
|| expected.sha256 !== actual.gitBlobSha256) throw new Error('Canonical tool overlay differs from committed/raw expected bytes');
}
}
const ownerKeys = ['E2E_DATABASE_URL', 'VELA_E2E_OWNER_TOKEN', 'VELA_E2E_OWNER_LABEL',
'VELA_E2E_OWNER_KEY', 'VELA_SANDBOX_NAME'] as const;
type FundingLeaseTarget = Pick<FundingFixture, 'owner' | 'upstream'>;
export type FundingRuntimeReceipt = {
name: string; database: string; targetKey: string; sourceRoot: string; amrSha: string;
urls: { api: string; link: string; web: string };
redisDb: number; guardianPid: number; deadlineUtc: string;
helperPath: string; helperSha256: string;
};
export type CanonicalFundingStatus = {
name: string; database: string; sourceRoot: string;
urls: { api: string; link: string; web: string };
redisDb: number; state: string;
};
export function assertFundingLeaseIdentity(
fixture: FundingLeaseTarget, receipt: FundingRuntimeReceipt, status: CanonicalFundingStatus,
ownerEnv: Record<string, string>, actualHead: string, reserveMs: number,
) {
const equal = (actual: unknown, expected: unknown, field: string) => {
if (actual !== expected) throw new Error(`Canonical funding sandbox identity mismatch: ${field}`);
};
for (const key of ownerKeys) if (typeof ownerEnv[key] !== 'string' || !ownerEnv[key]!.trim()) {
throw new Error(`Missing inherited owner capability field: ${key}`);
}
for (const key of Object.keys(ownerEnv)) if (!(ownerKeys as readonly string[]).includes(key)) {
throw new Error('Owner capability file contains unexpected environment fields');
}
equal(status.state, 'running', 'live state');
equal(status.name, fixture.owner.sandboxName, 'canonical named identity');
equal(receipt.name, status.name, 'receipt name');
equal(ownerEnv.VELA_SANDBOX_NAME, status.name, 'inherited name');
equal(status.sourceRoot, fixture.owner.sourceRoot, 'canonical source root');
equal(receipt.sourceRoot, status.sourceRoot, 'receipt source root');
equal(actualHead.trim(), fixture.upstream.amrSha, 'live source HEAD');
equal(receipt.amrSha, actualHead.trim(), 'receipt source SHA');
equal(receipt.helperPath, join(fixture.owner.sourceRoot, 'e2e/helper/e2e_ownership.py'), 'receipt helper path');
equal(receipt.helperSha256, fixture.owner.helperSha256, 'receipt helper hash');
equal(receipt.database, status.database, 'canonical database');
equal(decodeURIComponent(new URL(ownerEnv.E2E_DATABASE_URL!).pathname.slice(1)), status.database, 'inherited database target');
equal(receipt.targetKey, ownerEnv.VELA_E2E_OWNER_KEY, 'target digest');
if (!/^[a-f0-9]{64}$/.test(receipt.targetKey)) throw new Error('Canonical target digest must be SHA256');
equal(receipt.redisDb, status.redisDb, 'Redis database');
for (const [field, expected] of Object.entries({ api: fixture.upstream.apiUrl, link: fixture.upstream.linkUrl, web: fixture.upstream.webUrl })) {
equal(status.urls[field as keyof typeof status.urls], expected, `canonical ${field} endpoint`);
equal(receipt.urls[field as keyof typeof receipt.urls], expected, `receipt ${field} endpoint`);
}
if (!Number.isInteger(receipt.guardianPid) || receipt.guardianPid <= 0) throw new Error('Missing guardian identity');
if (!Number.isFinite(Date.parse(receipt.deadlineUtc)) || Date.parse(receipt.deadlineUtc) - Date.now() < reserveMs) {
throw new Error('Insufficient canonical guardian budget for this command and cleanup');
}
}
async function privateJson<T>(path: string): Promise<T> {
const mode = (await stat(path)).mode;
if (process.platform !== 'win32' && (mode & 0o077) !== 0) throw new Error('Owner capability/receipt must be private (0600)');
return JSON.parse(await readFile(path, 'utf8')) as T;
}
/** Read-only borrowing of the server owner's live lock. Never acquire, start,
* stop, or repair AMR here. Success requires the real canonical flock verifier. */
export async function verifyFundingLease(fixture: FundingLeaseTarget, reserveMs: number) {
const root = fixture.owner.sourceRoot;
const env = await privateJson<Record<string, string>>(fixture.owner.ownerEnvPath);
const receipt = await privateJson<FundingRuntimeReceipt>(fixture.owner.runtimeReceiptPath);
const helper = join(root, 'e2e/helper/e2e_ownership.py');
if (digest(await readFile(helper)) !== fixture.owner.helperSha256) throw new Error('Canonical owner helper hash mismatch');
const options = { cwd: root, env: { ...process.env, ...env }, timeout: T.long, maxBuffer: 4 * 1024 * 1024 };
const overlay = fixture.owner.canonicalToolsOverlay;
if (overlay) {
const changed = await exec('git', ['diff', '--name-only', 'HEAD', '--'], options);
const observed = [];
// Fixed repository paths/source, never a fixture-controlled filesystem read.
for (const relative of canonicalToolPaths) {
const original = await exec('git', ['show', `${canonicalToolsSha}:${relative}`], options);
observed.push({ path: relative, rawSha256: digest(await readFile(join(root, relative))), gitBlobSha256: digest(original.stdout) });
}
assertCanonicalToolsOverlay(overlay, changed.stdout.trim().split('\n').filter(Boolean), observed);
await exec('git', ['diff', '--quiet', 'HEAD', '--', '.', ':(exclude)sandbox/cli.py', ':(exclude)sandbox/app/docker-compose.yml'], options);
} else await exec('git', ['diff', '--quiet', 'HEAD', '--'], options);
// Do not trust only the declared helper hash or named manifest source root.
// Verify both control-plane files against the exact live checkout's Git blob.
for (const relative of ['e2e/helper/e2e_ownership.py', 'sandbox/cli.py']) {
const source = overlay && relative === 'sandbox/cli.py' ? canonicalToolsSha : 'HEAD';
const original = await exec('git', ['show', `${source}:${relative}`], options);
if (digest(original.stdout) !== digest(await readFile(join(root, relative)))) throw new Error('Canonical control-plane source has uncommitted changes');
}
const head = await exec('git', ['rev-parse', 'HEAD'], options);
const inspected = await exec('python3', [join(root, 'sandbox/cli.py'), 'status', fixture.owner.sandboxName, '--json'], options);
const status = JSON.parse(inspected.stdout) as CanonicalFundingStatus;
assertFundingLeaseIdentity(fixture, receipt, status, env, head.stdout, reserveMs);
try { await exec('python3', [helper, 'verify-inherited'], options); }
catch { throw new Error('Canonical live inherited ownership verification failed; do not count this run as passed'); }
const verifiedAt = new Date().toISOString();
return {
env,
safe: { verifiedAt, exitCode: 0, name: receipt.name, database: receipt.database,
targetKey: receipt.targetKey, sourceRoot: root, amrSha: head.stdout.trim(),
urls: receipt.urls, redisDb: receipt.redisDb, guardianPid: receipt.guardianPid,
deadlineUtc: receipt.deadlineUtc, helperSha256: fixture.owner.helperSha256,
...(overlay ? { canonicalToolsOverlay: overlay } : {}) },
};
}
/** Testable pre/post ordering: a failed precheck cannot spawn the child; a
* failed postcheck rejects even when the browser command returned exit 0. */
export async function withFundingLease<T>(
verify: (phase: 'pre' | 'post') => Promise<void>, command: () => Promise<T>,
): Promise<T> {
await verify('pre');
try { return await command(); }
finally { await verify('post'); }
}
@@ -0,0 +1,24 @@
import type { WorkspaceBillingPreflight, WorkspaceBillingResponse } from '@open-design/contracts';
/** Validate the real scoped funding precondition before any UI business step. */
export function requireScopedFundingPreflight(
body: WorkspaceBillingResponse,
expected: { workspaceId: string; workspaceMemberId: string; modelId: string },
): WorkspaceBillingPreflight {
const preflight = body.preflight;
if (!preflight || preflight.schemaVersion !== 1 || preflight.workspaceId !== expected.workspaceId
|| preflight.workspaceMemberId !== expected.workspaceMemberId
|| preflight.modelId !== expected.modelId) {
throw new Error('Funding preflight does not match the required workspace/member/model');
}
// Shared wallet authority belongs to the versioned billing response, not
// an optional upstream extension absent from WorkspaceBillingPreflight.
const wallet = body.workspaceBalance;
if (!wallet || wallet.billingScopeVersion !== 2
|| wallet.workspaceId !== expected.workspaceId
|| wallet.workspaceMemberId !== expected.workspaceMemberId
|| preflight.codingPlan?.workspaceId !== expected.workspaceId) {
throw new Error('Funding precondition requires the exact workspace/member v2 wallet and plan');
}
return preflight;
}
+1 -1
View File
@@ -22,7 +22,7 @@ export default defineConfig({
// generic full-pool shard run would execute them without their visual
// config. Excluding them at the config level keeps every functional
// consumer (full pool, `test:ui*`, ui_p0 groups) aligned.
testIgnore: 'visual-*.test.ts',
testIgnore: ['visual-*.test.ts', 'qa-bill001.private.test.ts'],
outputDir: './ui/reports/test-results',
timeout: Number(process.env.OD_PLAYWRIGHT_TIMEOUT) || 45_000,
retries: process.env.CI ? 1 : 0,
+91
View File
@@ -0,0 +1,91 @@
import { randomUUID } from 'node:crypto';
import { clusterTest as test, expect } from '@/playwright/suite';
import { startScopedFundingClient } from '@/playwright/workspace-funding-client';
import { fundingHeaders, readFundingFixture, type FundingFixture, type FundingSpace } from '@/playwright/workspace-funding-fixture';
import { T } from '@/timeouts';
type Bill001Given = {
workspaceId: string; ownerMemberId: string; memberMemberId: string; modelId: string;
expectedUnitPrice: { input: number; output: number; tokenUnit: number; tokensEachSide: number; usdPerCall: number };
owner: { actor: FundingFixture['actor']; runtimeKey: string };
member: { actor: FundingFixture['actor']; runtimeKey: string };
};
test('[P0] BILL-001 two real client sends debit one workspace wallet by actor', async ({ page }, info) => {
test.setTimeout(T.xlong * 5);
const base = await readFundingFixture() as FundingFixture & { bill001?: Bill001Given };
const given = base.bill001;
if (!given) throw new Error('Explicit disposable BILL-001 two-actor given required');
expect(given.expectedUnitPrice.usdPerCall).toBe(3);
expect((given.expectedUnitPrice.input + given.expectedUnitPrice.output)
* given.expectedUnitPrice.tokensEachSide / given.expectedUnitPrice.tokenUnit).toBe(3);
const result: Record<string, unknown>[] = [];
for (const [index, role] of (['owner', 'member'] as const).entries()) {
const actor = given[role];
const actorPage = index === 0 ? page : await page.context().newPage();
const space: FundingSpace = { workspaceId: given.workspaceId, ownerMemberId: given.ownerMemberId,
actorMemberId: role === 'owner' ? given.ownerMemberId : given.memberMemberId,
actorRole: role, runtimeKey: actor.runtimeKey };
const expectedBefore = index === 0 ? 10 : 7;
const expectedAfter = index === 0 ? 7 : 4;
const fixture = { ...base, actor: actor.actor, runtime: { ...base.runtime, modelId: given.modelId } };
const client = await startScopedFundingClient(actorPage, info, { fixture,
initialSpace: space, checkedSpaces: { bill001: space }, runtimeKey: actor.runtimeKey,
identitySuffix: `bill001-${role}`, attachmentPrefix: `BILL001-${role}-`, requireNamePrefix: false,
checkPreflight(_alias, _space, preflight) {
expect(Number(preflight.balanceUsd)).toBe(expectedBefore);
expect(preflight.modelCovered).toBe(false);
},
});
try {
await actorPage.goto(client.runtime.url.web('/'), { waitUntil: 'domcontentloaded', timeout: T.xlong });
const credits = actorPage.getByTestId('entry-top-right-credits');
await expect(credits).toBeVisible({ timeout: T.xlong });
await credits.click();
const walletPanel = actorPage.getByTestId('entry-top-right-credits-panel');
await expect(walletPanel).toContainText(`$${expectedBefore.toFixed(2)}`, { timeout: T.long });
const input = actorPage.getByTestId('home-hero-input');
const send = actorPage.getByTestId('home-hero-submit');
await expect(input).toBeVisible({ timeout: T.xlong });
const requestId = `bill001-${randomUUID()}-${role}`;
const prompt = `QA:${JSON.stringify({ id: requestId, tokens: given.expectedUnitPrice.tokensEachSide })}`;
await input.fill(prompt);
await expect(send).toBeEnabled({ timeout: T.medium });
await info.attach(`BILL001-${role}-before-send`, { body: await actorPage.screenshot(), contentType: 'image/png' });
const runPost = actorPage.waitForResponse(response => response.request().method() === 'POST'
&& new URL(response.url()).pathname === '/api/runs', { timeout: T.long });
await send.click();
const created = await runPost;
expect(created.status()).toBe(202);
const body = await created.json() as { runId?: string };
expect(body.runId).toBeTruthy();
let terminal: Record<string, unknown> | undefined;
await expect.poll(async () => {
const response = await fetch(client.runtime.url.daemon(`/api/runs/${body.runId}`), { headers: fundingHeaders(space) });
terminal = await response.json() as Record<string, unknown>;
return terminal.status;
}, { timeout: T.xlong, intervals: [250, 500, 1000] }).toBe('succeeded');
const billingUrl = new URL('/api/workspace/billing', client.runtime.url.web());
billingUrl.search = new URLSearchParams({ scope: 'workspace', workspaceId: given.workspaceId,
includePreflight: '1', modelId: given.modelId }).toString();
let observedBalance = NaN;
await expect.poll(async () => {
const response = await fetch(billingUrl, { headers: fundingHeaders(space) });
expect(response.ok).toBe(true);
const billing = await response.json() as { preflight?: { balanceUsd?: string } };
observedBalance = Number(billing.preflight?.balanceUsd);
return observedBalance;
}, { timeout: T.xlong, intervals: [250, 500, 1000] }).toBe(expectedAfter);
await actorPage.reload({ waitUntil: 'domcontentloaded' });
await expect(credits).toBeVisible({ timeout: T.xlong });
await credits.click();
await expect(walletPanel).toContainText(`$${expectedAfter.toFixed(2)}`, { timeout: T.long });
await info.attach(`BILL001-${role}-after-send`, { body: await actorPage.screenshot(), contentType: 'image/png' });
result.push({ role, actorUserId: actor.actor.userId, workspaceId: given.workspaceId,
requestId, runId: body.runId, runStatus: terminal?.status, createdStatus: created.status(),
expectedBefore, observedBalance, expectedAfter,
appRunPosts: client.posts.filter(post => post.path === '/api/runs') });
} finally { await client.close(); }
}
await info.attach('BILL001-two-client-observation', { body: JSON.stringify(result, null, 2), contentType: 'application/json' });
});