mirror of
https://github.com/nexu-io/open-design.git
synced 2026-10-27 09:51:12 +01:00
test(e2e): cover shared wallet with two real client sends
This commit is contained in:
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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' });
|
||||
});
|
||||
Reference in New Issue
Block a user