From 1de0350ef076acf7f838a18d3f9e0025a1bc5a6b Mon Sep 17 00:00:00 2001 From: Rod Vagg Date: Mon, 24 Aug 2026 20:30:40 +1000 Subject: [PATCH] feat(synapse): run local client using synapse as library Instead of cloning example-storage-e2e.js and having to npm install the whole of synapse, we instead implement all of our client code in here and pull in Synapse as a dependency as it's supposed to be used. Frontier runs will still clone Synapse but will do a minimal prod npm install and avoid build. We use the Typescript directly, without compile, and rewrite the source paths from dist/ to src/ dynamically. This change also splits up the storage e2e testing in a way that's more explicit and verbose for our needs and also easier to extend to cover new variations of concerns. Closes: #179 Closes: #180 --- .github/workflows/ci_run.yml | 2 +- README.md | 10 +- ci/README.md | 26 +- ci/dependency-profiles.json | 8 +- renovate.json | 11 +- scenarios/create-dataset-smoke.ts | 202 ----------- scenarios/helpers.py | 15 +- scenarios/run.py | 2 +- scenarios/synapse-e2e/account.ts | 131 ++++++++ scenarios/synapse-e2e/create-dataset.ts | 41 +++ scenarios/synapse-e2e/environment.ts | 62 ++++ scenarios/synapse-e2e/onchain.ts | 121 +++++++ scenarios/synapse-e2e/source-runtime.mjs | 71 ++++ scenarios/synapse-e2e/storage.ts | 137 ++++++++ scenarios/synapse-e2e/system-e2e.ts | 49 +++ scenarios/synapse-e2e/upload-probe.ts | 40 +++ scenarios/synapse.py | 165 --------- scenarios/synapse_runtime.py | 315 ++++++++++++++++++ scenarios/test_basic_balances.py | 2 +- scenarios/test_caching_subsystem.py | 28 +- scenarios/test_create_dataset_smoke.py | 19 +- ...est_storage_e2e.py => test_synapse_e2e.py} | 26 +- scripts/resolve-ci-dependencies.py | 62 +++- scripts/setup-scenarios-prerequisites.sh | 9 +- scripts/tests/test_resolve_ci_dependencies.py | 72 ++++ scripts/tests/test_scenario_dependencies.py | 260 ++++++++++++--- 26 files changed, 1394 insertions(+), 492 deletions(-) delete mode 100644 scenarios/create-dataset-smoke.ts create mode 100644 scenarios/synapse-e2e/account.ts create mode 100644 scenarios/synapse-e2e/create-dataset.ts create mode 100644 scenarios/synapse-e2e/environment.ts create mode 100644 scenarios/synapse-e2e/onchain.ts create mode 100644 scenarios/synapse-e2e/source-runtime.mjs create mode 100644 scenarios/synapse-e2e/storage.ts create mode 100644 scenarios/synapse-e2e/system-e2e.ts create mode 100644 scenarios/synapse-e2e/upload-probe.ts delete mode 100644 scenarios/synapse.py create mode 100755 scenarios/synapse_runtime.py rename scenarios/{test_storage_e2e.py => test_synapse_e2e.py} (53%) diff --git a/.github/workflows/ci_run.yml b/.github/workflows/ci_run.yml index c1cf06d4..e1d8a83d 100644 --- a/.github/workflows/ci_run.yml +++ b/.github/workflows/ci_run.yml @@ -126,7 +126,7 @@ jobs: - name: "EXEC: {Setup Node.js}, independent" uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: - node-version: 'lts/*' + node-version: '24.x' check-latest: true - name: "CHECK: {Resolve dependency profile}" diff --git a/README.md b/README.md index 477f2c9e..d78eb491 100644 --- a/README.md +++ b/README.md @@ -98,10 +98,18 @@ This will: ### Step 4: Use the Network -After startup, `USER_1` is fully configured for FOC: USDFC deposited into FilecoinPay and FWSS approved as an operator. You can use it immediately with synapse-sdk: +After startup, `USER_1` is fully configured for FOC: USDFC deposited into FilecoinPay and FWSS approved as an operator. Use the network immediately with Synapse, filecoin-pin, or other compatible FOC client software: ```bash +# From a synapse-sdk checkout NETWORK=devnet node utils/example-storage-e2e.js + +# With filecoin-pin +filecoin-pin add --network devnet +# Equivalent: NETWORK=devnet filecoin-pin add + +# Exercise foc-devnet's full Synapse-driven system scenario +python3 scenarios/test_synapse_e2e.py ``` All connection details (contract addresses, user keys, SP endpoints) are exported to `devnet-info.json`: diff --git a/ci/README.md b/ci/README.md index cc18d3b3..6b5821f6 100644 --- a/ci/README.md +++ b/ci/README.md @@ -162,6 +162,10 @@ The `version` field can also be an npm dist-tag: The resolver records the concrete package version selected at resolution time and npm `gitHead` when available. +For `synapse-sdk`, npm resolution also records exact compatible versions of +`@filoz/synapse-core` and the `viem` peer dependency. The scenario installs that +set into a temporary consumer project. + ## Overrides Some profile selections can include an optional `overrides` object. Each entry @@ -170,8 +174,8 @@ exists: ```json { - "strategy": "git_tag", - "tag": "synapse-sdk-v1.0.1", + "strategy": "npm_version", + "version": "1.1.1", "overrides": { "nanoid": { "version": "3.3.13", @@ -186,18 +190,12 @@ non-empty strings; the resolver rejects any override missing either field, so an override cannot be added without documenting why. The reason is logged when the override is applied. The resolver does not infer overrides from package metadata. -Overrides are currently allowed only for: - -- `synapse-sdk`, because scenario setup controls its pnpm install. -- `filecoin-pin` selections using `npm_version`, because those install into a - temporary npm project controlled by the scenario. - -Current consumers: +Overrides are currently allowed only for npm-installed `synapse-sdk` and +`filecoin-pin` selections. They are written to the temporary consumer +`package.json`; source profiles use the checkout's committed lockfile. -- `synapse-sdk` writes overrides to the root `pnpm-workspace.yaml` before - running `pnpm install`. -- npm-installed `filecoin-pin` writes overrides to the temporary npm - `package.json` used by the scenario. +Current consumers write npm overrides to the temporary `package.json` used by +their scenario. ## Current Boundary @@ -208,7 +206,7 @@ Installation currently lives in three places (which consume the resolved metadata): - `foc-devnet init`: Lotus, Curio, filecoin-services, and optionally PDP. -- `scenarios/synapse.py`: Synapse SDK scenario dependency. +- `scenarios/synapse_runtime.py`: published or source Synapse scenario runtime. - `scenarios/test_multi_copy_upload.py`: filecoin-pin scenario dependency. This split is intentional, but it is not necessarily the final shape. Resolution diff --git a/ci/dependency-profiles.json b/ci/dependency-profiles.json index babc37fa..ef01dc06 100644 --- a/ci/dependency-profiles.json +++ b/ci/dependency-profiles.json @@ -98,12 +98,12 @@ "repository": "https://github.com/FilOzone/synapse-sdk.git", "npm_package": "@filoz/synapse-sdk", "default": { - "strategy": "git_tag", - "tag": "synapse-sdk-v1.1.1" + "strategy": "npm_version", + "version": "1.1.1" }, "stability": { - "strategy": "git_tag", - "tag": "synapse-sdk-v*" + "strategy": "npm_version", + "version": "latest" }, "frontier": { "strategy": "git_branch", diff --git a/renovate.json b/renovate.json index 34ed6481..437f230d 100644 --- a/renovate.json +++ b/renovate.json @@ -21,12 +21,11 @@ "/^ci/dependency-profiles\\.json$/" ], "matchStrings": [ - "\"tag\":\\s*\"synapse-sdk-v(?[0-9][^\"]*)\"" + "\"synapse-sdk\":\\s*\\{[\\s\\S]{0,500}?\"default\":\\s*\\{\\s*\"strategy\":\\s*\"npm_version\",\\s*\"version\":\\s*\"(?[0-9][^\"]*)\"" ], - "depNameTemplate": "FilOzone/synapse-sdk", - "datasourceTemplate": "github-tags", - "extractVersionTemplate": "^synapse-sdk-v(?.+)$", - "versioningTemplate": "semver" + "depNameTemplate": "@filoz/synapse-sdk", + "datasourceTemplate": "npm", + "versioningTemplate": "npm" }, { "customType": "regex", @@ -34,7 +33,7 @@ "/^ci/dependency-profiles\\.json$/" ], "matchStrings": [ - "\"strategy\":\\s*\"npm_version\",\\s*\"version\":\\s*\"(?[0-9][^\"]*)\"" + "\"filecoin-pin\":\\s*\\{[\\s\\S]{0,500}?\"default\":\\s*\\{\\s*\"strategy\":\\s*\"npm_version\",\\s*\"version\":\\s*\"(?[0-9][^\"]*)\"" ], "depNameTemplate": "filecoin-pin", "datasourceTemplate": "npm", diff --git a/scenarios/create-dataset-smoke.ts b/scenarios/create-dataset-smoke.ts deleted file mode 100644 index fb68b1c4..00000000 --- a/scenarios/create-dataset-smoke.ts +++ /dev/null @@ -1,202 +0,0 @@ -// Copied into synapse-sdk/utils before execution; imports are relative to that destination. -import { readFileSync } from 'fs' -import { homedir } from 'os' -import { join } from 'path' -import { http as viemHttp, maxUint256 } from 'viem' -import { privateKeyToAccount } from 'viem/accounts' -import { Synapse } from '../packages/synapse-sdk/src/index.ts' -import * as ERC20 from '../packages/synapse-core/src/erc20/index.ts' -import * as Pay from '../packages/synapse-core/src/pay/index.ts' -import * as SP from '../packages/synapse-core/src/sp/index.ts' -import { getPdpDataSet } from '@filoz/synapse-core/warm-storage' -import { toChain, validateDevnetInfo } from '../packages/synapse-core/src/devnet/index.ts' - -type SmokeSynapse = ReturnType -type StorageContext = Awaited> - -const sleep = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)) - -function assert(condition: unknown, message: string): asserts condition { - if (!condition) { - throw new Error(message) - } -} - -async function waitForDataSet(client: SmokeSynapse['client'], dataSetId: bigint) { - for (let attempt = 1; attempt <= 15; attempt++) { - const dataSet = await getPdpDataSet(client, { dataSetId }) - if (dataSet != null) { - return dataSet - } - await sleep(2000) - } - throw new Error(`Created data set ${dataSetId} was not returned by getPdpDataSet`) -} - -async function waitForTransactionReceipt(client: SmokeSynapse['client'], hash: `0x${string}`, label: string) { - let lastError: unknown = null - for (let attempt = 1; attempt <= 90; attempt++) { - try { - const receipt = await client.request({ - method: 'eth_getTransactionReceipt', - params: [hash], - }) - if (receipt != null) { - return receipt - } - } catch (error) { - lastError = error - } - - await sleep(1000) - } - - const suffix = lastError instanceof Error ? `; last error: ${lastError.message}` : '' - throw new Error(`${label} transaction ${hash} was not confirmed${suffix}`) -} - -async function prepareWithPlainErc20(synapse: SmokeSynapse, context: StorageContext): Promise { - const { costs, transaction } = await synapse.storage.prepare({ - context, - dataSize: 1n, - }) - console.log(`Prepared account: ready=${costs.ready}, depositNeeded=${costs.depositNeeded}`) - if (transaction == null) { - return - } - - if (costs.depositNeeded > 0n) { - const approveHash = await ERC20.approve(synapse.client, { - amount: costs.depositNeeded, - }) - console.log(`ERC20 approve tx submitted: ${approveHash}`) - const approved = await waitForTransactionReceipt(synapse.client, approveHash, 'ERC20 approve') - console.log(`ERC20 approve confirmed in block ${approved.blockNumber}`) - - const depositHash = await Pay.deposit(synapse.client, { - amount: costs.depositNeeded, - }) - console.log(`FilecoinPay deposit tx submitted: ${depositHash}`) - const deposited = await waitForTransactionReceipt(synapse.client, depositHash, 'FilecoinPay deposit') - console.log(`FilecoinPay deposit confirmed in block ${deposited.blockNumber}`) - } - - if (costs.needsFwssMaxApproval) { - const operatorApprovalHash = await Pay.setOperatorApproval(synapse.client, { - approve: true, - rateAllowance: maxUint256, - lockupAllowance: maxUint256, - }) - console.log(`FWSS operator approval tx submitted: ${operatorApprovalHash}`) - const approvedOperator = await waitForTransactionReceipt( - synapse.client, - operatorApprovalHash, - 'FWSS operator approval' - ) - console.log(`FWSS operator approval confirmed in block ${approvedOperator.blockNumber}`) - } -} - -async function main() { - const devnetInfoPath = - process.env.DEVNET_INFO_PATH || join(homedir(), '.foc-devnet', 'state', 'latest', 'devnet-info.json') - const userIndex = Number(process.env.DEVNET_USER_INDEX || '1') - const raw = JSON.parse(readFileSync(devnetInfoPath, 'utf8')) - const devnetInfo = validateDevnetInfo(raw) - const { info } = devnetInfo - - assert(Number.isInteger(userIndex), `DEVNET_USER_INDEX must be an integer; got ${process.env.DEVNET_USER_INDEX}`) - assert(userIndex >= 0 && userIndex < info.users.length, `DEVNET_USER_INDEX=${userIndex} out of range`) - - const devnetProvider = info.pdp_sps.find((provider) => provider.is_approved) - assert(devnetProvider != null, 'No approved PDP service provider found in devnet-info.json') - - const user = info.users[userIndex] - const chain = toChain(devnetInfo) - if (process.env.RPC_URL) { - chain.rpcUrls = { - ...chain.rpcUrls, - default: { http: [process.env.RPC_URL] }, - public: { http: [process.env.RPC_URL] }, - } - } - - const account = privateKeyToAccount(user.private_key_hex) - assert( - account.address.toLowerCase() === user.evm_addr.toLowerCase(), - `Derived address ${account.address} does not match ${user.name} address ${user.evm_addr}` - ) - - const source = 'foc-devnet-smoke' - const smokeId = process.env.CREATE_DATASET_SMOKE_ID || `cds-${Date.now().toString(36)}` - const metadata = { smoke: smokeId, source } - - console.log(`Devnet run: ${info.run_id}`) - console.log(`User: ${user.name} (${account.address})`) - console.log(`Provider: ${devnetProvider.provider_id} (${devnetProvider.pdp_service_url})`) - console.log(`Smoke metadata: smoke=${smokeId}, source=${source}`) - - const synapse = Synapse.create({ - chain, - transport: viemHttp(), - account, - source, - }) - - const provider = await synapse.providers.getProvider({ providerId: BigInt(devnetProvider.provider_id) }) - assert(provider != null, `Provider ${devnetProvider.provider_id} not found in registry`) - - const preExisting = await synapse.storage.findDataSets() - assert( - !preExisting.some((dataSet) => dataSet.metadata?.smoke === smokeId), - `Smoke metadata ${smokeId} already exists before createDataSet` - ) - - const context = await synapse.storage.createContext({ - providerId: provider.id, - metadata, - withCDN: false, - }) - assert(context.dataSetId == null, `Expected unique metadata to create a new data set, got ${context.dataSetId}`) - - await prepareWithPlainErc20(synapse, context) - - const create = await SP.createDataSet(synapse.client, { - cdn: false, - payee: provider.serviceProvider, - payer: account.address, - serviceURL: devnetProvider.pdp_service_url, - recordKeeper: chain.contracts.fwss.address, - metadata, - }) - console.log(`createDataSet tx submitted: ${create.txHash}`) - - const confirmed = await SP.waitForCreateDataSet({ - statusUrl: create.statusUrl, - timeout: 180000, - }) - assert(confirmed.dataSetId > 0n, `Expected positive dataSetId, got ${confirmed.dataSetId}`) - console.log(`createDataSet confirmed: dataSetId=${confirmed.dataSetId}`) - - const dataSet = await waitForDataSet(synapse.client, confirmed.dataSetId) - assert(dataSet.live === true, `Data set ${confirmed.dataSetId} is not live`) - assert(dataSet.managed === true, `Data set ${confirmed.dataSetId} is not managed by FWSS`) - assert(dataSet.providerId === provider.id, `Data set providerId ${dataSet.providerId} != ${provider.id}`) - assert( - dataSet.payer.toLowerCase() === account.address.toLowerCase(), - `Data set payer ${dataSet.payer} != ${account.address}` - ) - assert( - dataSet.payee.toLowerCase() === provider.serviceProvider.toLowerCase(), - `Data set payee ${dataSet.payee} != ${provider.serviceProvider}` - ) - assert(dataSet.metadata?.smoke === smokeId, `Data set smoke metadata missing or wrong: ${dataSet.metadata?.smoke}`) - assert(dataSet.metadata?.source === source, `Data set source metadata missing or wrong: ${dataSet.metadata?.source}`) - - console.log(`Verified createDataSet smoke dataSetId=${confirmed.dataSetId}`) -} - -main().catch((error) => { - console.error(error) - process.exit(1) -}) diff --git a/scenarios/helpers.py b/scenarios/helpers.py index f59d9d69..4804d6fc 100644 --- a/scenarios/helpers.py +++ b/scenarios/helpers.py @@ -12,12 +12,19 @@ _fail = 0 _log_lines: list = [] +BASE_DIR = Path(os.environ.get("FOC_DEVNET_BASEDIR", Path.home() / ".foc-devnet")) DEVNET_INFO = os.environ.get( - "DEVNET_INFO", os.path.expanduser("~/.foc-devnet/state/latest/devnet-info.json") + "DEVNET_INFO", str(BASE_DIR / "state" / "latest" / "devnet-info.json") +) +FOUNDRY_BIN = BASE_DIR / "artifacts" / "foundry" / "bin" +CAST_ARTIFACT = FOUNDRY_BIN / "cast" +FORGE_ARTIFACT = FOUNDRY_BIN / "forge" +CAST = os.environ.get("CAST") or str( + CAST_ARTIFACT if os.access(CAST_ARTIFACT, os.X_OK) else "cast" +) +FORGE = os.environ.get("FORGE") or str( + FORGE_ARTIFACT if os.access(FORGE_ARTIFACT, os.X_OK) else "forge" ) -FOUNDRY_BIN = Path.home() / ".foc-devnet" / "artifacts" / "foundry" / "bin" -CAST = str(FOUNDRY_BIN / "cast") -FORGE = str(FOUNDRY_BIN / "forge") # ── Logging ────────────────────────────────────────────────── diff --git a/scenarios/run.py b/scenarios/run.py index 119c568e..a89ce0cd 100755 --- a/scenarios/run.py +++ b/scenarios/run.py @@ -30,7 +30,7 @@ ("test_basic_balances", 10), # Allows setup plus five 280s Node attempts and retry delays. ("test_create_dataset_smoke", CREATE_DATASET_SMOKE_TIMEOUT_SECS), - ("test_storage_e2e", 200), + ("test_synapse_e2e", 600), ("test_multi_copy_upload", 600), ("test_caching_subsystem", 200), ] diff --git a/scenarios/synapse-e2e/account.ts b/scenarios/synapse-e2e/account.ts new file mode 100644 index 00000000..4ded34b2 --- /dev/null +++ b/scenarios/synapse-e2e/account.ts @@ -0,0 +1,131 @@ +import assert from 'node:assert/strict' +import * as ERC20 from '@filoz/synapse-core/erc20' +import { accounts, deposit, setOperatorApproval } from '@filoz/synapse-core/pay' +import { Synapse } from '@filoz/synapse-sdk' +import type { StorageContext } from '@filoz/synapse-sdk/storage' +import { type Hash, http } from 'viem' +import { privateKeyToAccount } from 'viem/accounts' +import type { ScenarioEnvironment } from './environment.ts' + +export type ScenarioSynapse = Synapse +export type AccountState = accounts.OutputType + +export function createSynapse(environment: ScenarioEnvironment): ScenarioSynapse { + const account = privateKeyToAccount(environment.privateKey) + if (environment.user != null) { + assert.equal( + account.address.toLowerCase(), + environment.user.evm_addr.toLowerCase(), + 'private key does not match devnet user' + ) + } + return Synapse.create({ + chain: environment.chain, + transport: http(), + account, + source: 'foc-devnet-synapse-e2e', + }) +} + +export async function readAccountState(synapse: ScenarioSynapse): Promise { + return accounts(synapse.client, { address: synapse.client.account.address }) +} + +function delay(milliseconds: number): Promise { + const { promise, resolve } = Promise.withResolvers() + setTimeout(resolve, milliseconds) + return promise +} + +async function waitForTransaction(synapse: ScenarioSynapse, hash: Hash, label: string): Promise { + let lastError: unknown + for (let attempt = 0; attempt < 90; attempt++) { + try { + const receipt = await synapse.client.request({ + method: 'eth_getTransactionReceipt', + params: [hash], + }) + if (receipt != null) return + } catch (error) { + lastError = error + } + await delay(1000) + } + const details = lastError instanceof Error ? `: ${lastError.message}` : '' + throw new Error(`${label} transaction ${hash} was not confirmed${details}`) +} + +export async function prepareAccount(synapse: ScenarioSynapse, dataSize: bigint): Promise { + const before = await readAccountState(synapse) + const { costs, transaction } = await synapse.storage.prepare({ dataSize }) + assert.equal(transaction == null, costs.ready, 'Account readiness and preparation transaction disagree') + console.log( + `Account preparation: ready=${costs.ready} deposit=${costs.depositNeeded} approval=${costs.needsFwssMaxApproval}` + ) + if (transaction != null) { + assert.equal( + transaction.depositAmount, + costs.depositNeeded, + 'Preparation transaction deposit differs from calculated cost' + ) + assert.equal( + transaction.includesApproval, + costs.needsFwssMaxApproval, + 'Preparation transaction approval differs from calculated requirement' + ) + const result = await transaction.execute({ + onHash: (hash) => console.log(`Account funding submitted: ${hash}`), + }) + console.log(`Account funding confirmed: ${result.hash}`) + } + + const ready = await synapse.storage.prepare({ dataSize }) + assert.equal(ready.costs.ready, true, 'Payment account remains unprepared') + assert.equal(ready.transaction, null, 'Prepared payment account still requires a transaction') + const after = await readAccountState(synapse) + assert(after.funds >= before.funds, `Payment funds decreased during preparation: ${before.funds} -> ${after.funds}`) + assert(after.availableFunds > 0n, 'Prepared payment account has no available funds') + console.log( + `Prepared payment state: funds=${after.funds} available=${after.availableFunds} lockup=${after.lockupCurrent}` + ) + return after +} + +export async function prepareAccountWithPlainErc20( + synapse: ScenarioSynapse, + dataSize: bigint, + context: StorageContext +): Promise { + const { costs, transaction } = await synapse.storage.prepare({ context, dataSize }) + assert.equal(transaction == null, costs.ready, 'Account readiness and preparation transaction disagree') + console.log( + `Plain ERC20 preparation: ready=${costs.ready} deposit=${costs.depositNeeded} approval=${costs.needsFwssMaxApproval}` + ) + if (costs.depositNeeded > 0n) { + const approvalHash = await ERC20.approve(synapse.client, { + amount: costs.depositNeeded, + }) + console.log(`USDFC approval submitted: ${approvalHash}`) + await waitForTransaction(synapse, approvalHash, 'USDFC approval') + + const depositHash = await deposit(synapse.client, { + amount: costs.depositNeeded, + }) + console.log(`FilecoinPay deposit submitted: ${depositHash}`) + await waitForTransaction(synapse, depositHash, 'FilecoinPay deposit') + } + if (costs.needsFwssMaxApproval) { + const approvalHash = await setOperatorApproval(synapse.client, { + approve: true, + }) + console.log(`FWSS approval submitted: ${approvalHash}`) + await waitForTransaction(synapse, approvalHash, 'FWSS approval') + } + + const ready = await synapse.storage.prepare({ context, dataSize }) + assert.equal(ready.costs.ready, true, 'Plain ERC20 payment account remains unprepared') + assert.equal(ready.transaction, null, 'Prepared plain ERC20 account still requires a transaction') + const account = await readAccountState(synapse) + assert(account.availableFunds > 0n, 'Prepared plain ERC20 account has no available funds') + return account +} diff --git a/scenarios/synapse-e2e/create-dataset.ts b/scenarios/synapse-e2e/create-dataset.ts new file mode 100644 index 00000000..576ae5f3 --- /dev/null +++ b/scenarios/synapse-e2e/create-dataset.ts @@ -0,0 +1,41 @@ +import assert from 'node:assert/strict' +import * as SP from '@filoz/synapse-core/sp' +import { createSynapse, prepareAccountWithPlainErc20 } from './account.ts' +import { freshMetadata, resolveEnvironment } from './environment.ts' +import { assertCreatedDataSet } from './onchain.ts' +import { createFreshContext } from './storage.ts' + +async function main(): Promise { + const environment = resolveEnvironment({ defaultUserIndex: 1 }) + const synapse = createSynapse(environment) + const metadata = freshMetadata('create-data-set') + + console.log('=== Synapse data set creation probe ===') + const providers = await synapse.providers.getAllActiveProviders() + const provider = providers[0] + assert(provider != null, 'No active PDP provider is available') + console.log(`Provider: ${provider.id} (${provider.pdp.serviceURL})`) + + const context = await createFreshContext(synapse, provider.id, metadata) + await prepareAccountWithPlainErc20(synapse, 1n, context) + + const created = await SP.createDataSet(synapse.client, { + cdn: false, + payee: provider.serviceProvider, + payer: synapse.client.account.address, + serviceURL: provider.pdp.serviceURL, + recordKeeper: environment.chain.contracts.fwss.address, + metadata, + }) + console.log(`Data set creation submitted: ${created.txHash}`) + const confirmed = await SP.waitForCreateDataSet({ statusUrl: created.statusUrl, timeout: 180_000 }) + assert(confirmed.dataSetId > 0n, `Expected a positive data set ID, got ${confirmed.dataSetId}`) + + await assertCreatedDataSet(synapse, confirmed.dataSetId, provider.id, provider.serviceProvider, metadata) + console.log(`Data set created and verified: ${confirmed.dataSetId}`) +} + +main().catch((error: unknown) => { + console.error(error) + process.exitCode = 1 +}) diff --git a/scenarios/synapse-e2e/environment.ts b/scenarios/synapse-e2e/environment.ts new file mode 100644 index 00000000..d819d4e4 --- /dev/null +++ b/scenarios/synapse-e2e/environment.ts @@ -0,0 +1,62 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { homedir } from 'node:os' +import { join } from 'node:path' +import { calibration, mainnet } from '@filoz/synapse-core/chains' +import { toChain, validateDevnetInfo } from '@filoz/synapse-core/devnet' +import type { SynapseOptions } from '@filoz/synapse-sdk' + +export type ScenarioEnvironment = { + chain: NonNullable + filePaths: string[] + network: string + privateKey: `0x${string}` + runId?: string + user?: { name: string; evm_addr: string } +} + +export function freshMetadata(kind: string): Record { + return { + scenario: kind, + run: `${Date.now().toString(36)}-${process.pid.toString(36)}`, + } +} + +export function resolveEnvironment(options: { defaultUserIndex: number; requireFiles?: boolean }): ScenarioEnvironment { + const filePaths = process.argv.slice(2) + assert(!options.requireFiles || filePaths.length > 0, 'Usage: node ') + + const network = process.env.NETWORK ?? 'devnet' + const rpcUrl = process.env.RPC_URL + if (network !== 'devnet') { + const chain = network === 'mainnet' ? mainnet : calibration + const privateKey = process.env.PRIVATE_KEY + assert(privateKey?.startsWith('0x'), 'PRIVATE_KEY must be a 0x-prefixed private key outside devnet') + return { + chain: rpcUrl ? { ...chain, rpcUrls: { ...chain.rpcUrls, default: { http: [rpcUrl] } } } : chain, + filePaths, + network, + privateKey: privateKey as `0x${string}`, + } + } + + const baseDir = process.env.FOC_DEVNET_BASEDIR ?? join(homedir(), '.foc-devnet') + const infoPath = process.env.DEVNET_INFO_PATH ?? join(baseDir, 'state', 'latest', 'devnet-info.json') + const devnet = validateDevnetInfo(JSON.parse(readFileSync(infoPath, 'utf8'))) + const userIndex = Number(process.env.DEVNET_USER_INDEX ?? options.defaultUserIndex) + assert(Number.isInteger(userIndex), `DEVNET_USER_INDEX must be an integer, got ${process.env.DEVNET_USER_INDEX}`) + assert(userIndex >= 0 && userIndex < devnet.info.users.length, `DEVNET_USER_INDEX ${userIndex} is out of range`) + + const user = devnet.info.users[userIndex] + const chain = toChain(devnet) + return { + chain: rpcUrl + ? { ...chain, rpcUrls: { ...chain.rpcUrls, default: { http: [rpcUrl] }, public: { http: [rpcUrl] } } } + : chain, + filePaths, + network, + privateKey: (process.env.PRIVATE_KEY ?? user.private_key_hex) as `0x${string}`, + runId: devnet.info.run_id, + user, + } +} diff --git a/scenarios/synapse-e2e/onchain.ts b/scenarios/synapse-e2e/onchain.ts new file mode 100644 index 00000000..6510102b --- /dev/null +++ b/scenarios/synapse-e2e/onchain.ts @@ -0,0 +1,121 @@ +import assert from 'node:assert/strict' +import { getRail } from '@filoz/synapse-core/pay' +import { findPieceIdsByCidCall, getActivePieceCount } from '@filoz/synapse-core/pdp-verifier' +import { getPdpDataSet } from '@filoz/synapse-core/warm-storage' +import type { UploadResult } from '@filoz/synapse-sdk' +import { readContract } from 'viem/actions' +import type { AccountState, ScenarioSynapse } from './account.ts' + +const delay = (milliseconds: number) => new Promise((resolve) => setTimeout(resolve, milliseconds)) + +async function waitForDataSet(synapse: ScenarioSynapse, dataSetId: bigint) { + for (let attempt = 1; attempt <= 15; attempt++) { + const dataSet = await getPdpDataSet(synapse.client, { dataSetId }) + if (dataSet != null) return dataSet + await delay(2000) + } + throw new Error(`Data set ${dataSetId} was not observable through the public PDP API`) +} + +export async function assertOnchainState( + synapse: ScenarioSynapse, + result: UploadResult, + metadata: Record, + beforeUpload: AccountState +): Promise { + const payment = await synapse.payments.accountInfo() + assert( + payment.funds <= beforeUpload.funds, + `Payment funds increased unexpectedly during upload: ${beforeUpload.funds} -> ${payment.funds}` + ) + assert( + payment.lockupRate > beforeUpload.lockupRate, + `Payment lockup rate did not increase: ${beforeUpload.lockupRate} -> ${payment.lockupRate}` + ) + assert( + payment.availableFunds < beforeUpload.availableFunds, + `Available funds did not decrease: ${beforeUpload.availableFunds} -> ${payment.availableFunds}` + ) + assert(payment.availableFunds > 0n, 'Payment account has no available funds after upload') + console.log( + `Payment state: funds=${payment.funds} available=${payment.availableFunds} lockup=${payment.lockupCurrent} rate=${payment.lockupRate}` + ) + + const dataSetIds = new Set(result.copies.map((copy) => copy.dataSetId)) + assert.equal(dataSetIds.size, result.copies.length, 'Copies must use distinct data sets') + for (const copy of result.copies) { + const dataSet = await waitForDataSet(synapse, copy.dataSetId) + assert.equal(dataSet.live, true, `Data set ${copy.dataSetId} is not live`) + assert.equal(dataSet.managed, true, `Data set ${copy.dataSetId} is not FWSS-managed`) + const activePieceCount = await getActivePieceCount(synapse.client, { + dataSetId: copy.dataSetId, + }) + assert.equal(activePieceCount, 1n, `Data set ${copy.dataSetId} does not contain exactly one active piece`) + const matchingPieceIds = await readContract( + synapse.client, + findPieceIdsByCidCall({ + chain: synapse.client.chain, + dataSetId: copy.dataSetId, + pieceCid: result.pieceCid, + startPieceId: 0n, + limit: 2n, + }) + ) + assert.deepEqual(matchingPieceIds, [copy.pieceId], `Data set ${copy.dataSetId} has the wrong piece`) + assert.equal(dataSet.providerId, copy.providerId, `Data set ${copy.dataSetId} has the wrong provider`) + assert.equal( + dataSet.payer.toLowerCase(), + synapse.client.account.address.toLowerCase(), + `Data set ${copy.dataSetId} has the wrong payer` + ) + assert(dataSet.pdpRailId > 0n, `Data set ${copy.dataSetId} has no PDP payment rail`) + const rail = await getRail(synapse.client, { railId: dataSet.pdpRailId }) + assert.equal( + rail.from.toLowerCase(), + synapse.client.account.address.toLowerCase(), + `Rail ${dataSet.pdpRailId} has the wrong payer` + ) + assert.equal(rail.to.toLowerCase(), dataSet.payee.toLowerCase(), `Rail ${dataSet.pdpRailId} has the wrong payee`) + assert.equal( + rail.operator.toLowerCase(), + synapse.chain.contracts.fwss.address.toLowerCase(), + `Rail ${dataSet.pdpRailId} has the wrong operator` + ) + assert.equal( + rail.validator.toLowerCase(), + synapse.chain.contracts.fwss.address.toLowerCase(), + `Rail ${dataSet.pdpRailId} has the wrong validator` + ) + assert(rail.paymentRate > 0n, `Rail ${dataSet.pdpRailId} has no payment rate`) + assert(rail.lockupPeriod > 0n, `Rail ${dataSet.pdpRailId} has no lockup period`) + assert.equal(rail.endEpoch, 0n, `Rail ${dataSet.pdpRailId} is terminated`) + for (const [key, value] of Object.entries(metadata)) { + assert.equal(dataSet.metadata?.[key], value, `Data set ${copy.dataSetId} metadata ${key} differs`) + } + console.log( + `On-chain data set verified: id=${copy.dataSetId} provider=${copy.providerId} piece=${copy.pieceId} rail=${dataSet.pdpRailId}` + ) + } +} + +export async function assertCreatedDataSet( + synapse: ScenarioSynapse, + dataSetId: bigint, + providerId: bigint, + expectedPayee: string, + metadata: Record +): Promise { + const dataSet = await waitForDataSet(synapse, dataSetId) + assert.equal(dataSet.live, true, `Data set ${dataSetId} is not live`) + assert.equal(dataSet.managed, true, `Data set ${dataSetId} is not FWSS-managed`) + assert.equal(dataSet.providerId, providerId, `Data set ${dataSetId} has the wrong provider`) + assert.equal( + dataSet.payer.toLowerCase(), + synapse.client.account.address.toLowerCase(), + `Data set ${dataSetId} has the wrong payer` + ) + assert.equal(dataSet.payee.toLowerCase(), expectedPayee.toLowerCase(), `Data set ${dataSetId} has the wrong payee`) + for (const [key, value] of Object.entries(metadata)) { + assert.equal(dataSet.metadata?.[key], value, `Data set ${dataSetId} metadata ${key} differs`) + } +} diff --git a/scenarios/synapse-e2e/source-runtime.mjs b/scenarios/synapse-e2e/source-runtime.mjs new file mode 100644 index 00000000..b34bd543 --- /dev/null +++ b/scenarios/synapse-e2e/source-runtime.mjs @@ -0,0 +1,71 @@ +/** + * Runs foc-devnet scenarios against Synapse TypeScript source on Node 24+. + * + * Source profiles install Synapse's production dependency closure, then preload + * this module with `node --import`. Public `@filoz/synapse-sdk` and + * `@filoz/synapse-core` imports resolve to their source counterparts instead of + * the packages' compiled `dist` targets. All other imports use Node's normal + * resolver. + * + * Mappings come from each package's export map, keeping the scenario on the + * public API and avoiding a hard-coded list that drifts as exports change. + * Wildcard exports are deliberately excluded because one wildcard can expose + * paths with no TypeScript source equivalent. + */ +import assert from 'node:assert/strict' +import { existsSync, readFileSync } from 'node:fs' +import { registerHooks } from 'node:module' +import { dirname, join, resolve } from 'node:path' +import { pathToFileURL } from 'node:url' + +const sourceRoot = process.env.SYNAPSE_SDK_SOURCE_DIR +assert(sourceRoot, 'SYNAPSE_SDK_SOURCE_DIR must name the Synapse checkout when using source-runtime.mjs') + +// Export entries may be strings or nested condition objects. Prefer the +// conditions used by this ESM runtime, then inspect package-specific branches. +function exportedTarget(entry) { + if (typeof entry === 'string') return entry + if (entry == null || typeof entry !== 'object') return undefined + for (const condition of ['node', 'import', 'default']) { + const target = exportedTarget(entry[condition]) + if (target != null) return target + } + for (const target of Object.values(entry)) { + const resolved = exportedTarget(target) + if (resolved != null) return resolved + } + return undefined +} + +// Convert concrete public dist exports to source files only when the matching +// TypeScript file exists. Dependencies and private paths remain untouched. +function sourceExports(packageName, packageDirectory) { + const packagePath = join(sourceRoot, packageDirectory, 'package.json') + const packageJson = JSON.parse(readFileSync(packagePath, 'utf8')) + const mappings = new Map() + for (const [subpath, entry] of Object.entries(packageJson.exports ?? {})) { + if (subpath.includes('*')) continue + const target = exportedTarget(entry) + if (target == null) continue + const sourcePath = resolve( + dirname(packagePath), + target.replace(/^\.\/dist\/src\//, './src/').replace(/\.js$/, '.ts') + ) + if (!existsSync(sourcePath)) continue + mappings.set(subpath === '.' ? packageName : `${packageName}/${subpath.slice(2)}`, pathToFileURL(sourcePath).href) + } + return mappings +} + +const sourceMappings = new Map([ + ...sourceExports('@filoz/synapse-sdk', 'packages/synapse-sdk'), + ...sourceExports('@filoz/synapse-core', 'packages/synapse-core'), +]) + +// Short-circuit exact public package matches; delegate everything else. +registerHooks({ + resolve(specifier, context, nextResolve) { + const sourceUrl = sourceMappings.get(specifier) + return sourceUrl == null ? nextResolve(specifier, context) : { url: sourceUrl, shortCircuit: true } + }, +}) diff --git a/scenarios/synapse-e2e/storage.ts b/scenarios/synapse-e2e/storage.ts new file mode 100644 index 00000000..32b2659e --- /dev/null +++ b/scenarios/synapse-e2e/storage.ts @@ -0,0 +1,137 @@ +import assert from 'node:assert/strict' +import { createReadStream } from 'node:fs' +import { readFile, stat } from 'node:fs/promises' +import { Readable } from 'node:stream' +import type { UploadResult } from '@filoz/synapse-sdk' +import type { ScenarioSynapse } from './account.ts' + +export type UploadMilestones = { + selected: Set + stored: Set + copied: Set + failed: Set + submitted: Set + confirmed: Set + uploadedBytes: number +} + +export async function fileSize(filePath: string): Promise { + const info = await stat(filePath) + assert(info.isFile(), `Path is not a file: ${filePath}`) + return BigInt(info.size) +} + +export async function createFreshContext( + synapse: ScenarioSynapse, + providerId: bigint, + metadata: Record +) { + const context = await synapse.storage.createContext({ providerId, metadata, withCDN: false }) + assert( + context.dataSetId === null || context.dataSetId === undefined, + 'Fresh metadata unexpectedly resolved an existing data set' + ) + return context +} + +export async function uploadFile( + synapse: ScenarioSynapse, + filePath: string, + metadata: Record, + copies = 2 +): Promise<{ result: UploadResult; milestones: UploadMilestones }> { + const size = await fileSize(filePath) + const milestones: UploadMilestones = { + selected: new Set(), + stored: new Set(), + copied: new Set(), + failed: new Set(), + submitted: new Set(), + confirmed: new Set(), + uploadedBytes: 0, + } + let lastProgress = 0 + + console.log(`Uploading ${filePath} (${size} bytes) with ${copies} copy target`) + const result = await synapse.storage.upload(Readable.toWeb(createReadStream(filePath)), { + copies, + metadata, + callbacks: { + onProviderSelected: (provider) => { + milestones.selected.add(provider.id) + console.log(`Provider selected: ${provider.id} (${provider.serviceProvider})`) + }, + onStored: (providerId, pieceCid) => { + milestones.stored.add(providerId) + console.log(`Primary stored: provider=${providerId} piece=${pieceCid}`) + }, + onProgress: (bytesUploaded) => { + milestones.uploadedBytes = bytesUploaded + if (bytesUploaded === Number(size) || bytesUploaded - lastProgress >= 10 * 1024 * 1024) { + lastProgress = bytesUploaded + console.log(`Upload progress: ${bytesUploaded}/${size}`) + } + }, + onPullProgress: (providerId, pieceCid, status) => + console.log(`Replication: provider=${providerId} piece=${pieceCid} status=${status}`), + onCopyComplete: (providerId, pieceCid) => { + milestones.copied.add(providerId) + console.log(`Replication complete: provider=${providerId} piece=${pieceCid}`) + }, + onCopyFailed: (providerId, pieceCid, error) => { + milestones.failed.add(providerId) + console.error(`Replication failed: provider=${providerId} piece=${pieceCid}: ${error.message}`) + }, + onPiecesAdded: (hash, providerId) => { + milestones.submitted.add(providerId) + console.log(`Commit submitted: provider=${providerId} tx=${hash}`) + }, + onPiecesConfirmed: (dataSetId, providerId) => { + milestones.confirmed.add(providerId) + console.log(`Commit confirmed: provider=${providerId} dataSet=${dataSetId}`) + }, + }, + }) + return { result, milestones } +} + +export async function assertDownloadedBytes( + synapse: ScenarioSynapse, + pieceCid: UploadResult['pieceCid'], + filePath: string +): Promise { + const [expected, downloaded] = await Promise.all([readFile(filePath), synapse.storage.download({ pieceCid })]) + assert(Buffer.from(downloaded).equals(expected), `Provider-agnostic download differs for piece ${pieceCid}`) +} + +export async function assertDirectRetrievals(result: UploadResult, filePath: string): Promise { + const expected = await readFile(filePath) + for (const copy of result.copies) { + const response = await fetch(copy.retrievalUrl) + assert(response.ok, `Direct retrieval from provider ${copy.providerId} failed with HTTP ${response.status}`) + const body = new Uint8Array(await response.arrayBuffer()) + assert(Buffer.from(body).equals(expected), `Direct retrieval differs for provider ${copy.providerId}`) + console.log(`Direct retrieval verified: provider=${copy.providerId} (${body.byteLength} bytes)`) + } +} + +export function assertCompleteReplication(result: UploadResult, milestones: UploadMilestones): void { + assert.equal(result.requestedCopies, 2, `Expected exactly two requested copies, got ${result.requestedCopies}`) + assert.equal(result.complete, true, `Upload incomplete: ${result.copies.length}/${result.requestedCopies} copies`) + assert.equal(result.copies.length, 2, `Expected exactly two complete copies, got ${result.copies.length}`) + assert.equal(result.failedAttempts.length, 0, 'Default replication produced failed provider attempts') + + const providerIds = new Set(result.copies.map((copy) => copy.providerId)) + const primaryIds = new Set(result.copies.filter((copy) => copy.role === 'primary').map((copy) => copy.providerId)) + const secondaryIds = new Set(result.copies.filter((copy) => copy.role === 'secondary').map((copy) => copy.providerId)) + assert.equal(providerIds.size, 2, 'Copies must be on distinct providers') + assert.equal(primaryIds.size, 1, 'Expected one primary copy') + assert.equal(secondaryIds.size, 1, 'Expected one secondary copy') + assert.deepEqual(milestones.selected, providerIds, 'Provider selection callbacks differ from committed copies') + assert.deepEqual(milestones.stored, primaryIds, 'Primary store callback differs from committed primary') + assert.deepEqual(milestones.copied, secondaryIds, 'Copy callback differs from committed secondary') + assert.deepEqual(milestones.failed, new Set(), 'Replication emitted a failure callback') + assert.deepEqual(milestones.submitted, providerIds, 'Commit submissions differ from committed copies') + assert.deepEqual(milestones.confirmed, providerIds, 'Commit confirmations differ from committed copies') + assert.equal(milestones.uploadedBytes, result.size, 'Upload progress did not reach the complete input size') +} diff --git a/scenarios/synapse-e2e/system-e2e.ts b/scenarios/synapse-e2e/system-e2e.ts new file mode 100644 index 00000000..2425ea68 --- /dev/null +++ b/scenarios/synapse-e2e/system-e2e.ts @@ -0,0 +1,49 @@ +import { createSynapse, prepareAccount } from './account.ts' +import { freshMetadata, resolveEnvironment } from './environment.ts' +import { assertOnchainState } from './onchain.ts' +import { + assertCompleteReplication, + assertDirectRetrievals, + assertDownloadedBytes, + fileSize, + uploadFile, +} from './storage.ts' + +async function main(): Promise { + const environment = resolveEnvironment({ defaultUserIndex: 0, requireFiles: true }) + if (environment.filePaths.length !== 1) throw new Error('system-e2e.ts accepts exactly one file path') + + const [filePath] = environment.filePaths + const metadata = freshMetadata('system-e2e') + console.log('=== Synapse system E2E ===') + console.log(`Network: ${environment.network}${environment.runId == null ? '' : ` run=${environment.runId}`}`) + console.log(`Fresh metadata: ${JSON.stringify(metadata)}`) + + console.log('\nPhase 1: initialize account') + const synapse = createSynapse(environment) + console.log(`Wallet: ${synapse.client.account.address}`) + + console.log('\nPhase 2: prepare payment account') + const preparedAccount = await prepareAccount(synapse, await fileSize(filePath)) + + console.log('\nPhase 3: upload and replicate') + const { result, milestones } = await uploadFile(synapse, filePath, metadata, 2) + assertCompleteReplication(result, milestones) + console.log(`Replication complete: piece=${result.pieceCid}`) + + console.log('\nPhase 4: verify provider-agnostic retrieval') + await assertDownloadedBytes(synapse, result.pieceCid, filePath) + console.log('Provider-agnostic retrieval verified') + + console.log('\nPhase 5: verify every provider directly') + await assertDirectRetrievals(result, filePath) + + console.log('\nPhase 6: verify observable on-chain state') + await assertOnchainState(synapse, result, metadata, preparedAccount) + console.log('\n=== SUCCESS: complete two-provider Synapse journey verified ===') +} + +main().catch((error: unknown) => { + console.error(error) + process.exitCode = 1 +}) diff --git a/scenarios/synapse-e2e/upload-probe.ts b/scenarios/synapse-e2e/upload-probe.ts new file mode 100644 index 00000000..f9047d78 --- /dev/null +++ b/scenarios/synapse-e2e/upload-probe.ts @@ -0,0 +1,40 @@ +/** + * Uploads one file and verifies its Synapse and on-chain result. + * + * The cache scenario runs this probe before inspecting Curio's Scylla rows. + * `UPLOAD_COPIES` selects the copy count and defaults to one. + */ +import assert from 'node:assert/strict' +import { createSynapse, prepareAccount } from './account.ts' +import { freshMetadata, resolveEnvironment } from './environment.ts' +import { assertOnchainState } from './onchain.ts' +import { assertDirectRetrievals, assertDownloadedBytes, fileSize, uploadFile } from './storage.ts' + +async function main(): Promise { + const environment = resolveEnvironment({ defaultUserIndex: 0, requireFiles: true }) + if (environment.filePaths.length !== 1) throw new Error('upload-probe.ts accepts exactly one file path') + const copies = Number(process.env.UPLOAD_COPIES ?? '1') + assert( + Number.isInteger(copies) && copies > 0, + `UPLOAD_COPIES must be a positive integer, got ${process.env.UPLOAD_COPIES}` + ) + + const [filePath] = environment.filePaths + const metadata = freshMetadata('upload-probe') + const synapse = createSynapse(environment) + console.log(`=== Synapse upload probe: ${copies} copy target ===`) + const preparedAccount = await prepareAccount(synapse, await fileSize(filePath)) + + const { result } = await uploadFile(synapse, filePath, metadata, copies) + assert.equal(result.complete, true, `Upload incomplete: ${result.copies.length}/${result.requestedCopies} copies`) + assert.equal(result.copies.length, copies, `Expected ${copies} committed copies, got ${result.copies.length}`) + await assertDownloadedBytes(synapse, result.pieceCid, filePath) + await assertDirectRetrievals(result, filePath) + await assertOnchainState(synapse, result, metadata, preparedAccount) + console.log(`Upload probe verified: ${result.pieceCid}`) +} + +main().catch((error: unknown) => { + console.error(error) + process.exitCode = 1 +}) diff --git a/scenarios/synapse.py b/scenarios/synapse.py deleted file mode 100644 index d6b96e94..00000000 --- a/scenarios/synapse.py +++ /dev/null @@ -1,165 +0,0 @@ -#!/usr/bin/env python3 -"""Shared helpers for cloning, building, and uploading via synapse-sdk.""" - -from __future__ import annotations - -import os -import json -import subprocess -import time -from pathlib import Path - -from scenarios.dependencies import component -from scenarios.helpers import fail, info, ok, run_cmd, sh - -STATE_FORK_ERROR = "refusing explicit call due to state fork at epoch" -UPLOAD_RETRY_DELAYS_SECS = (5, 10, 15, 30) - - -def apply_pnpm_workspace_overrides(sdk_dir: Path, overrides: dict) -> bool: - if not overrides: - return True - - workspace = sdk_dir / "pnpm-workspace.yaml" - if not workspace.is_file(): - fail(f"pnpm-workspace.yaml not found at {workspace}") - return False - - lines = workspace.read_text().splitlines() - updated = [] - index = 0 - while index < len(lines): - line = lines[index] - if line == "overrides:": - index += 1 - while index < len(lines) and ( - not lines[index] or lines[index].startswith((" ", "#")) - ): - index += 1 - continue - updated.append(line) - index += 1 - - if updated and updated[-1]: - updated.append("") - updated.append("overrides:") - for package, spec in sorted(overrides.items()): - updated.append(f" {json.dumps(package)}: {json.dumps(spec['version'])}") - - workspace.write_text("\n".join(updated) + "\n") - for package, spec in sorted(overrides.items()): - info(f"pnpm override {package}={spec['version']} ({spec['reason']})") - return True - - -def clone_and_build(tmp_dir: Path) -> Path | None: - """Clone synapse-sdk into tmp_dir, install deps, build. Returns sdk_dir or None on failure.""" - dependency = component("synapse-sdk") - repository = dependency["repository"] - checkout = dependency.get("commit") or dependency["ref"] - sdk_dir = tmp_dir / "synapse-sdk" - if not run_cmd( - ["git", "clone", repository, str(sdk_dir)], label="clone synapse-sdk" - ): - return None - if not run_cmd( - ["git", "checkout", "--detach", checkout], - cwd=str(sdk_dir), - label=f"checkout synapse-sdk {checkout}", - ): - return None - sdk_commit = sh(f"git -C {sdk_dir} rev-parse HEAD") - expected_commit = dependency.get("commit") - if expected_commit and sdk_commit != expected_commit: - raise RuntimeError( - f"synapse-sdk checkout is {sdk_commit}, expected {expected_commit}" - ) - info(f"synapse-sdk commit: {sdk_commit}") - overrides = dependency.get("overrides", {}) - if not apply_pnpm_workspace_overrides(sdk_dir, overrides): - return None - # Install/build only what the e2e example needs, skipping the unrelated - # playground, docs and react workspaces. - if not run_cmd( - ["pnpm", "install", "--filter", "utils..."], - cwd=str(sdk_dir), - label="pnpm install", - ): - return None - if not run_cmd( - [ - "pnpm", - "-r", - "--filter", - "@filoz/synapse-core", - "--filter", - "@filoz/synapse-sdk", - "run", - "build", - ], - cwd=str(sdk_dir), - label="pnpm build", - ): - return None - return sdk_dir - - -def run_node_script( - sdk_dir: Path, - script_path: Path, - label: str, - args: list[str] | None = None, - env: dict | None = None, - timeout: int | None = None, -): - """Run a Node script in synapse-sdk with retry handling for transient state forks.""" - script_arg = str(script_path) - if script_path.is_absolute(): - try: - script_arg = str(script_path.relative_to(sdk_dir)) - except ValueError: - pass - - cmd = ["node", script_arg, *(args or [])] - process_env = {**os.environ, **(env or {})} - max_attempts = len(UPLOAD_RETRY_DELAYS_SECS) + 1 - - for attempt in range(1, max_attempts + 1): - result = subprocess.run( - cmd, - cwd=str(sdk_dir), - env=process_env, - text=True, - capture_output=True, - timeout=timeout, - ) - details = "\n".join( - part for part in (result.stderr.strip(), result.stdout.strip()) if part - ) - if result.returncode == 0: - if details: - info(details) - ok(label) - return - - if STATE_FORK_ERROR not in details or attempt == max_attempts: - fail(f"{label} (exit={result.returncode}) {details}") - - delay = UPLOAD_RETRY_DELAYS_SECS[attempt - 1] - info( - f"{label}: Lotus refused eth_call while crossing a state fork; " - f"retrying in {delay}s (attempt {attempt}/{max_attempts})" - ) - time.sleep(delay) - - -def upload_file(sdk_dir: Path, filepath: str, label: str): - """Upload a single file via example-storage-e2e.js.""" - env = {**os.environ, "NETWORK": "devnet"} - run_node_script( - sdk_dir, - Path("utils/example-storage-e2e.js"), - label, - args=[str(filepath)], - env=env, - ) diff --git a/scenarios/synapse_runtime.py b/scenarios/synapse_runtime.py new file mode 100755 index 00000000..585dd156 --- /dev/null +++ b/scenarios/synapse_runtime.py @@ -0,0 +1,315 @@ +#!/usr/bin/env python3 +"""Prepare and run the Synapse E2E consumer or source runtime.""" + +from __future__ import annotations + +import json +import os +import shutil +import subprocess +import time +from dataclasses import dataclass +from pathlib import Path + +from scenarios.dependencies import component +from scenarios.helpers import fail, info, ok, run_cmd + +STATE_FORK_ERROR = "refusing explicit call due to state fork at epoch" +UPLOAD_RETRY_DELAYS_SECS = (5, 10, 15, 30) +RUNTIME_MARKER = ".synapse-runtime.json" + + +@dataclass(frozen=True) +class SynapseRuntime: + work_dir: Path + source: str + provenance: str + source_dir: Path | None = None + + +def _scenario_dir() -> Path: + return Path(__file__).with_name("synapse-e2e") + + +def _runtime_marker(work_dir: Path) -> Path: + return work_dir / RUNTIME_MARKER + + +def _npm_view(package: str, version: str, *fields: str): + result = subprocess.run( + ["npm", "view", f"{package}@{version}", *fields, "--json"], + text=True, + capture_output=True, + ) + if result.returncode: + raise RuntimeError( + f"npm metadata lookup failed for {package}@{version}: {result.stderr.strip()}" + ) + value = json.loads(result.stdout) + if fields == ("version",): + return _npm_version(value, package, version) + return value + + +def _npm_version(value, package: str, requested: str) -> str: + if isinstance(value, str) and value: + return value + if isinstance(value, list): + for version in reversed(value): + if isinstance(version, str) and version: + return version + raise RuntimeError(f"npm returned no version for {package}@{requested}") + + +def _npm_runtime_dependencies(dependency: dict) -> dict[str, str]: + metadata = _npm_view( + dependency["package"], dependency["version"], "dependencies", "peerDependencies" + ) + core_range = metadata.get("dependencies", {}).get("@filoz/synapse-core") + viem_range = metadata.get("peerDependencies", {}).get("viem") or metadata.get( + "dependencies", {} + ).get("viem") + if not isinstance(core_range, str) or not isinstance(viem_range, str): + raise RuntimeError( + f"{dependency['package']}@{dependency['version']} must declare " + "@filoz/synapse-core and viem" + ) + return { + "@filoz/synapse-core": _npm_version( + _npm_view("@filoz/synapse-core", core_range, "version"), + "@filoz/synapse-core", + core_range, + ), + "viem": _npm_version( + _npm_view("viem", viem_range, "version"), "viem", viem_range + ), + } + + +def _write_manifest(work_dir: Path, dependency: dict) -> None: + runtime_dependencies = dependency.get("runtime_dependencies") + if not isinstance(runtime_dependencies, dict): + runtime_dependencies = _npm_runtime_dependencies(dependency) + + dependencies = { + dependency["package"]: dependency["version"], + **runtime_dependencies, + } + overrides = { + package: spec["version"] + for package, spec in dependency.get("overrides", {}).items() + } + manifest = { + "name": "foc-devnet-synapse-e2e", + "private": True, + "type": "module", + "dependencies": dependencies, + } + if overrides: + manifest["overrides"] = overrides + (work_dir / "package.json").write_text(json.dumps(manifest, indent=2) + "\n") + + +def _copy_scenarios(work_dir: Path) -> None: + source = _scenario_dir() + if not source.is_dir(): + raise RuntimeError(f"Synapse scenario directory not found: {source}") + shutil.copytree(source, work_dir, dirs_exist_ok=True) + + +def _source_pnpm_version(source_dir: Path) -> str: + package_manager = json.loads((source_dir / "package.json").read_text()).get( + "packageManager" + ) + if not isinstance(package_manager, str) or not package_manager.startswith("pnpm@"): + raise RuntimeError(f"Synapse source has no declared pnpm version: {source_dir}") + return package_manager.removeprefix("pnpm@") + + +def _source_commit(source_dir: Path) -> str: + result = subprocess.run( + ["git", "-C", str(source_dir), "rev-parse", "HEAD"], + text=True, + capture_output=True, + ) + if result.returncode: + raise RuntimeError(f"Cannot determine Synapse source commit at {source_dir}") + return result.stdout.strip() + + +def _load_runtime(work_dir: Path) -> SynapseRuntime: + marker = _runtime_marker(work_dir) + if not marker.is_file(): + raise RuntimeError(f"SYNAPSE_RUNTIME_DIR is not a prepared runtime: {work_dir}") + data = json.loads(marker.read_text()) + source_dir = data.get("source_dir") + return SynapseRuntime( + work_dir=work_dir, + source=data["source"], + provenance=data["provenance"], + source_dir=Path(source_dir) if source_dir else None, + ) + + +def prepare_synapse_runtime(work_dir: Path) -> SynapseRuntime: + """Prepare one reusable runtime and return its executable scenario location.""" + reused_dir = os.environ.get("SYNAPSE_RUNTIME_DIR") + if reused_dir: + return _load_runtime(Path(reused_dir).resolve()) + + work_dir = work_dir.resolve() + work_dir.mkdir(parents=True, exist_ok=True) + dependency = component("synapse-sdk") + local_source = os.environ.get("SYNAPSE_SDK_SOURCE_DIR") + + if local_source or dependency.get("source") != "npm": + source_dir = ( + Path(local_source).resolve() if local_source else work_dir / "synapse-sdk" + ) + if local_source: + if not source_dir.is_dir(): + raise RuntimeError( + f"SYNAPSE_SDK_SOURCE_DIR is not a directory: {source_dir}" + ) + provenance = f"local:{source_dir}@{_source_commit(source_dir)}" + else: + checkout = dependency.get("commit") or dependency.get("ref") + if not checkout: + raise RuntimeError("resolved Synapse source has no commit or ref") + if not run_cmd( + ["git", "clone", dependency["repository"], str(source_dir)], + label="clone synapse-sdk", + ): + raise RuntimeError("failed to clone synapse-sdk") + if not run_cmd( + ["git", "checkout", "--detach", checkout], + cwd=str(source_dir), + label=f"checkout synapse-sdk {checkout}", + ): + raise RuntimeError("failed to checkout synapse-sdk") + actual_commit = _source_commit(source_dir) + expected_commit = dependency.get("commit") + if expected_commit and actual_commit != expected_commit: + raise RuntimeError( + f"synapse-sdk checkout is {actual_commit}, expected {expected_commit}" + ) + provenance = f"git:{dependency['repository']}@{actual_commit}" + + pnpm_version = _source_pnpm_version(source_dir) + source_node_modules = source_dir / "packages" / "synapse-sdk" / "node_modules" + has_runtime_closure = (source_node_modules / "viem").is_dir() and ( + source_node_modules / "@filoz" / "synapse-core" + ).is_dir() + if not local_source or not has_runtime_closure: + if not run_cmd( + [ + "pnpm", + "install", + "--frozen-lockfile", + "--prod", + "--ignore-scripts", + "--filter", + "@filoz/synapse-sdk...", + ], + cwd=str(source_dir), + label=f"install Synapse production runtime (pnpm@{pnpm_version})", + ): + raise RuntimeError("failed to install Synapse production runtime") + if not source_node_modules.is_dir(): + raise RuntimeError( + f"Synapse production install has no SDK node_modules: {source_node_modules}" + ) + runtime_node_modules = work_dir / "node_modules" + if runtime_node_modules.exists() or runtime_node_modules.is_symlink(): + raise RuntimeError( + f"Synapse runtime node_modules already exists: {runtime_node_modules}" + ) + runtime_node_modules.symlink_to(source_node_modules, target_is_directory=True) + provenance = f"{provenance} (pnpm@{pnpm_version})" + runtime = SynapseRuntime(work_dir, "source", provenance, source_dir) + else: + _write_manifest(work_dir, dependency) + if not run_cmd( + [ + "npm", + "install", + "--omit=dev", + "--ignore-scripts", + "--package-lock=false", + ], + cwd=str(work_dir), + label="install Synapse consumer runtime", + ): + raise RuntimeError("failed to install Synapse consumer runtime") + runtime = SynapseRuntime( + work_dir, + "npm", + f"npm:{dependency['package']}@{dependency['version']}", + ) + + _copy_scenarios(work_dir) + _runtime_marker(work_dir).write_text( + json.dumps( + { + "source": runtime.source, + "provenance": runtime.provenance, + "source_dir": str(runtime.source_dir) if runtime.source_dir else None, + }, + indent=2, + ) + + "\n" + ) + info(f"Synapse runtime: {runtime.provenance}") + return runtime + + +def run_node_script( + runtime: SynapseRuntime, + script_name: str, + label: str, + args: list[str] | None = None, + env: dict | None = None, + timeout: int | None = None, +) -> None: + """Run a prepared scenario entrypoint with retries for transient state forks.""" + script = runtime.work_dir / script_name + if not script.is_file(): + raise RuntimeError(f"Synapse scenario entrypoint not found: {script}") + + cmd = ["node"] + process_env = {**os.environ, **(env or {})} + if runtime.source_dir: + loader = runtime.work_dir / "source-runtime.mjs" + if not loader.is_file(): + raise RuntimeError(f"Synapse source runtime hook not found: {loader}") + cmd.extend(["--import", str(loader)]) + process_env["SYNAPSE_SDK_SOURCE_DIR"] = str(runtime.source_dir) + cmd.extend([str(script), *(args or [])]) + + max_attempts = len(UPLOAD_RETRY_DELAYS_SECS) + 1 + for attempt in range(1, max_attempts + 1): + result = subprocess.run( + cmd, + cwd=str(runtime.work_dir), + env=process_env, + text=True, + capture_output=True, + timeout=timeout, + ) + details = "\n".join( + part for part in (result.stderr.strip(), result.stdout.strip()) if part + ) + if result.returncode == 0: + if details: + info(details) + ok(label) + return + if STATE_FORK_ERROR not in details or attempt == max_attempts: + fail(f"{label} (exit={result.returncode}) {details}") + + delay = UPLOAD_RETRY_DELAYS_SECS[attempt - 1] + info( + f"{label}: Lotus refused eth_call while crossing a state fork; " + f"retrying in {delay}s (attempt {attempt}/{max_attempts})" + ) + time.sleep(delay) diff --git a/scenarios/test_basic_balances.py b/scenarios/test_basic_balances.py index b0d540f4..b16c9648 100644 --- a/scenarios/test_basic_balances.py +++ b/scenarios/test_basic_balances.py @@ -9,7 +9,7 @@ def run(): - assert_ok(f"test -x {CAST}", "cast is installed") + assert_ok(f"command -v {CAST}", "cast is installed") d = devnet_info()["info"] lotus_rpc = d["lotus"]["host_rpc_url"] usdfc_addr = d["contracts"]["mockusdfc_addr"] diff --git a/scenarios/test_caching_subsystem.py b/scenarios/test_caching_subsystem.py index 662510bd..28a59908 100644 --- a/scenarios/test_caching_subsystem.py +++ b/scenarios/test_caching_subsystem.py @@ -27,7 +27,7 @@ sh, write_random_file, ) -from scenarios.synapse import clone_and_build, upload_file +from scenarios.synapse_runtime import prepare_synapse_runtime, run_node_script SMALL_FILE_SIZE = 20 * 1024 * 1024 # 20MB, below the 32MB threshold LARGE_FILE_SIZE = 80 * 1024 * 1024 # 80MB, above the 32MB threshold @@ -71,11 +71,17 @@ def _row_count(cql_output): return int(match.group(1)) -def _upload_and_count(sdk_dir, filepath, label, scylla_container): +def _upload_and_count(runtime, filepath, label, scylla_container): """Upload a file and return the cache row count afterward.""" import time - upload_file(sdk_dir, filepath.name, label) + run_node_script( + runtime, + "upload-probe.ts", + label, + args=[str(filepath)], + env={"NETWORK": "devnet"}, + ) info(f"Waiting {CACHE_WAIT_SECS}s for caching tasks") time.sleep(CACHE_WAIT_SECS) output = _cql(scylla_container, "SELECT * FROM curio.pdp_cache_layer") @@ -85,9 +91,7 @@ def _upload_and_count(sdk_dir, filepath, label, scylla_container): def run(): - assert_ok("command -v git", "git is installed") assert_ok("command -v node", "node is installed") - assert_ok("command -v pnpm", "pnpm is installed") run_index = _next_run_index() seed_small = RAND_SEED_SMALL + run_index @@ -101,25 +105,23 @@ def run(): init_count = _row_count(init_output) info(f"Initial row count = {init_count}") - with tempfile.TemporaryDirectory(prefix="synapse-sdk-cache-") as tmp: - sdk_dir = clone_and_build(Path(tmp)) - if not sdk_dir: - return + with tempfile.TemporaryDirectory(prefix="synapse-cache-") as tmp: + runtime = prepare_synapse_runtime(Path(tmp)) - small_file = sdk_dir / "small_20mb" - large_file = sdk_dir / "large_80mb" + small_file = runtime.work_dir / "small_20mb" + large_file = runtime.work_dir / "large_80mb" write_random_file(small_file, SMALL_FILE_SIZE, seed_small) write_random_file(large_file, LARGE_FILE_SIZE, seed_large) info("Uploading 20MB piece (below 32MB threshold)") after_small = _upload_and_count( - sdk_dir, small_file, "upload 20MB piece", scylla_container + runtime, small_file, "upload 20MB piece", scylla_container ) assert_eq(after_small, init_count, "cache rows count should not increase") info("Uploading 80MB piece (above 32MB threshold)") after_large = _upload_and_count( - sdk_dir, large_file, "upload 80MB piece", scylla_container + runtime, large_file, "upload 80MB piece", scylla_container ) assert_gt(after_large, init_count, "cache rows count should increase") diff --git a/scenarios/test_create_dataset_smoke.py b/scenarios/test_create_dataset_smoke.py index 43e6e6ae..25f8a0ec 100644 --- a/scenarios/test_create_dataset_smoke.py +++ b/scenarios/test_create_dataset_smoke.py @@ -5,35 +5,26 @@ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) -import shutil import tempfile from pathlib import Path from scenarios.helpers import assert_ok, info -from scenarios.synapse import clone_and_build, run_node_script +from scenarios.synapse_runtime import prepare_synapse_runtime, run_node_script SMOKE_TIMEOUT_SECS = 280 SMOKE_USER_INDEX = "1" # USER_2; USER_1 is used by existing storage scenarios. -SMOKE_SCRIPT_SOURCE = Path(__file__).with_name("create-dataset-smoke.ts") def run(): - assert_ok("command -v git", "git is installed") assert_ok("command -v node", "node is installed") - assert_ok("command -v pnpm", "pnpm is installed") - with tempfile.TemporaryDirectory(prefix="synapse-sdk-createdataset-") as tmp: - sdk_dir = clone_and_build(Path(tmp)) - if not sdk_dir: - return - - script_path = sdk_dir / "utils" / SMOKE_SCRIPT_SOURCE.name - shutil.copyfile(SMOKE_SCRIPT_SOURCE, script_path) + with tempfile.TemporaryDirectory(prefix="synapse-createdataset-") as tmp: + runtime = prepare_synapse_runtime(Path(tmp)) info("Running createDataSet smoke script against devnet") run_node_script( - sdk_dir, - script_path, + runtime, + "create-dataset.ts", "createDataSet smoke test", env={"DEVNET_USER_INDEX": SMOKE_USER_INDEX}, timeout=SMOKE_TIMEOUT_SECS, diff --git a/scenarios/test_storage_e2e.py b/scenarios/test_synapse_e2e.py similarity index 53% rename from scenarios/test_storage_e2e.py rename to scenarios/test_synapse_e2e.py index d0c3fd04..4b9a2409 100644 --- a/scenarios/test_storage_e2e.py +++ b/scenarios/test_synapse_e2e.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""End-to-end storage test: upload a random file via synapse-sdk against the devnet.""" +"""Synapse-driven end-to-end exercise of the deployed FOC system.""" import os, sys # noqa: E401 @@ -9,7 +9,7 @@ from pathlib import Path from scenarios.helpers import assert_eq, assert_ok, info, write_random_file -from scenarios.synapse import clone_and_build, upload_file +from scenarios.synapse_runtime import prepare_synapse_runtime, run_node_script RAND_FILE_NAME = "random_file" RAND_FILE_SIZE = 20 * 1024 * 1024 @@ -17,16 +17,12 @@ def run(): - assert_ok("command -v git", "git is installed") assert_ok("command -v node", "node is installed") - assert_ok("command -v pnpm", "pnpm is installed") - with tempfile.TemporaryDirectory(prefix="synapse-sdk-") as tmp: - sdk_dir = clone_and_build(Path(tmp)) - if not sdk_dir: - return + with tempfile.TemporaryDirectory(prefix="synapse-e2e-") as tmp: + runtime = prepare_synapse_runtime(Path(tmp)) - random_file = sdk_dir / RAND_FILE_NAME + random_file = runtime.work_dir / RAND_FILE_NAME info(f"Creating random file ({RAND_FILE_SIZE} bytes)") write_random_file(random_file, RAND_FILE_SIZE, RAND_FILE_SEED) assert_eq( @@ -35,11 +31,13 @@ def run(): f"{RAND_FILE_NAME} created with exact size {RAND_FILE_SIZE} bytes", ) - info("Running Synapse SDK storage e2e script against devnet") - upload_file( - sdk_dir, - RAND_FILE_NAME, - "NETWORK=devnet node utils/example-storage-e2e.js random_file", + info("Running the Synapse-driven system E2E against devnet") + run_node_script( + runtime, + "system-e2e.ts", + "Synapse system E2E", + args=[str(random_file)], + env={"NETWORK": "devnet"}, ) diff --git a/scripts/resolve-ci-dependencies.py b/scripts/resolve-ci-dependencies.py index 0c87082d..c9d2cc69 100644 --- a/scripts/resolve-ci-dependencies.py +++ b/scripts/resolve-ci-dependencies.py @@ -233,12 +233,29 @@ def select_latest_tag( return tag, commit +def npm_version(value, package: str, requested: str) -> str: + """Normalize `npm view ... version --json` output. + + npm returns one string for an exact version or dist-tag and an ordered list + for a range; the final non-empty list entry is the newest match. + """ + if isinstance(value, str) and value: + return value + if isinstance(value, list): + for version in reversed(value): + if isinstance(version, str) and version: + return version + raise ResolutionError(f"npm returned no version for {package}@{requested}") + + def npm_metadata(package: str, version: str, runner=run_command) -> dict: - resolved_version = json.loads( - runner(["npm", "view", f"{package}@{version}", "version", "--json"]) + resolved_version = npm_version( + json.loads( + runner(["npm", "view", f"{package}@{version}", "version", "--json"]) + ), + package, + version, ) - if not resolved_version: - raise ResolutionError(f"npm returned no version for {package}@{version}") git_head_output = runner( ["npm", "view", f"{package}@{resolved_version}", "gitHead", "--json"] ) @@ -246,6 +263,35 @@ def npm_metadata(package: str, version: str, runner=run_command) -> dict: return {"version": resolved_version, "gitHead": git_head} +def npm_runtime_dependencies( + package: str, version: str, runner=run_command +) -> dict[str, str]: + metadata = json.loads( + runner( + [ + "npm", + "view", + f"{package}@{version}", + "dependencies", + "peerDependencies", + "--json", + ] + ) + ) + dependencies = metadata.get("dependencies", {}) + peer_dependencies = metadata.get("peerDependencies", {}) + core_range = dependencies.get("@filoz/synapse-core") + viem_range = peer_dependencies.get("viem") or dependencies.get("viem") + if not isinstance(core_range, str) or not isinstance(viem_range, str): + raise ResolutionError( + f"{package}@{version} must declare @filoz/synapse-core and viem" + ) + + core = npm_metadata("@filoz/synapse-core", core_range, runner)["version"] + viem = npm_metadata("viem", viem_range, runner)["version"] + return {"@filoz/synapse-core": core, "viem": viem} + + def read_gitlink(repository: str, commit: str, path: str, runner=run_command) -> str: with tempfile.TemporaryDirectory(prefix="foc-devnet-ci-deps-") as directory: repo_dir = Path(directory) / "repo" @@ -284,9 +330,7 @@ def validate_overrides(name: str, strategy: str, overrides) -> dict: "string 'version' and 'reason' fields" ) - if name == "synapse-sdk": - return dict(sorted(overrides.items())) - if name == "filecoin-pin" and strategy == "npm_version": + if name in {"synapse-sdk", "filecoin-pin"} and strategy == "npm_version": return dict(sorted(overrides.items())) raise ResolutionError( f"{name} overrides are not supported with strategy {strategy!r}" @@ -371,6 +415,10 @@ def resolve_component( version=data["version"], commit=data.get("gitHead", ""), ) + if name == "synapse-sdk": + resolved["runtime_dependencies"] = npm_runtime_dependencies( + component["npm_package"], data["version"], runner + ) else: raise ResolutionError(f"Unsupported strategy {strategy!r} for {name}") overrides = selection.get("overrides") diff --git a/scripts/setup-scenarios-prerequisites.sh b/scripts/setup-scenarios-prerequisites.sh index 65e18475..5611ba4e 100755 --- a/scripts/setup-scenarios-prerequisites.sh +++ b/scripts/setup-scenarios-prerequisites.sh @@ -4,7 +4,7 @@ # dependencies so that scenario scripts only run tests, not setup. # # Installs Foundry (cast, forge) if not already present, and verifies -# that git, node, and pnpm are available. +# that git, Node.js 24+, npm, and pnpm are available. # # Usage: # ./scripts/setup-scenarios-prerequisites.sh @@ -36,7 +36,7 @@ verify_checksum() { # ── 0. Verify basic system tools ──────────────────────────── info "Checking basic system tools..." -for tool in git node pnpm; do +for tool in git node npm pnpm; do if command -v "$tool" &>/dev/null; then pass "$tool is installed ($(command -v "$tool"))" else @@ -44,6 +44,11 @@ for tool in git node pnpm; do fi done +NODE_MAJOR=$(node -p 'process.versions.node.split(".")[0]') +if (( NODE_MAJOR < 24 )); then + fail "Node.js 24 or newer is required for native TypeScript scenarios." +fi + # ── 1. Foundry (cast / forge) ─────────────────────────────── info "Checking Foundry..." diff --git a/scripts/tests/test_resolve_ci_dependencies.py b/scripts/tests/test_resolve_ci_dependencies.py index c009924f..71c15712 100644 --- a/scripts/tests/test_resolve_ci_dependencies.py +++ b/scripts/tests/test_resolve_ci_dependencies.py @@ -427,6 +427,14 @@ def test_manifest_profile_rejects_missing_component_selection(self): with self.assertRaisesRegex(resolver.ResolutionError, "no such selection"): resolver.load_manifest(path) + def test_npm_version_uses_latest_value_from_npm_array(self): + self.assertEqual( + resolver.npm_version(["1.0.0", "1.1.1"], "package", "latest"), + "1.1.1", + ) + with self.assertRaisesRegex(resolver.ResolutionError, "no version"): + resolver.npm_version([], "package", "latest") + def test_npm_version_resolves_dist_tag_to_npm_version(self): component = { "repository": "https://example.test/filecoin-pin.git", @@ -459,6 +467,70 @@ def test_npm_version_resolves_dist_tag_to_npm_version(self): self.assertEqual(resolved["source"], "npm") self.assertEqual(resolved["version"], "1.1.1") + def test_synapse_npm_resolution_includes_exact_runtime_dependencies(self): + component = { + "repository": "https://example.test/synapse.git", + "npm_package": "@filoz/synapse-sdk", + "default": {"strategy": "npm_version", "version": "1.1.1"}, + } + runner = FakeRunner( + { + ( + "npm", + "view", + "@filoz/synapse-sdk@1.1.1", + "version", + "--json", + ): '"1.1.1"', + ( + "npm", + "view", + "@filoz/synapse-sdk@1.1.1", + "gitHead", + "--json", + ): '""', + ( + "npm", + "view", + "@filoz/synapse-sdk@1.1.1", + "dependencies", + "peerDependencies", + "--json", + ): '{"dependencies":{"@filoz/synapse-core":"^1.1.1"},"peerDependencies":{"viem":"2.x"}}', + ( + "npm", + "view", + "@filoz/synapse-core@^1.1.1", + "version", + "--json", + ): '"1.1.1"', + ( + "npm", + "view", + "@filoz/synapse-core@1.1.1", + "gitHead", + "--json", + ): '""', + ("npm", "view", "viem@2.x", "version", "--json"): '["2.0.0", "2.52.0"]', + ( + "npm", + "view", + "viem@2.52.0", + "gitHead", + "--json", + ): '""', + } + ) + + resolved = resolver.resolve_component( + "synapse-sdk", component, "default", runner + ) + + self.assertEqual( + resolved["runtime_dependencies"], + {"@filoz/synapse-core": "1.1.1", "viem": "2.52.0"}, + ) + def test_profile_overrides_are_copied_to_resolved_component(self): component = { "repository": "https://example.test/filecoin-pin.git", diff --git a/scripts/tests/test_scenario_dependencies.py b/scripts/tests/test_scenario_dependencies.py index 78af2456..d7ae81c3 100644 --- a/scripts/tests/test_scenario_dependencies.py +++ b/scripts/tests/test_scenario_dependencies.py @@ -1,3 +1,4 @@ +import json import tempfile import unittest import subprocess @@ -5,7 +6,11 @@ from unittest.mock import patch from scenarios.dependencies import format_markdown_table -from scenarios.synapse import clone_and_build, run_node_script +from scenarios.synapse_runtime import ( + SynapseRuntime, + prepare_synapse_runtime, + run_node_script, +) from scenarios.test_multi_copy_upload import setup_filecoin_pin @@ -43,54 +48,183 @@ def test_dependency_table_contains_all_resolved_components(self): ): self.assertIn(expected, table) - @patch("scenarios.synapse.sh", return_value="deadbeef") - @patch("scenarios.synapse.run_cmd", return_value=True) + @patch("scenarios.synapse_runtime._copy_scenarios") + @patch("scenarios.synapse_runtime.run_cmd", return_value=True) @patch( - "scenarios.synapse.component", + "scenarios.synapse_runtime.component", return_value={ + "source": "npm", + "package": "@filoz/synapse-sdk", + "version": "1.1.1", + "runtime_dependencies": { + "@filoz/synapse-core": "1.1.1", + "viem": "2.52.0", + }, + "overrides": {"nanoid": {"version": "3.3.13", "reason": "test"}}, + }, + ) + def test_npm_runtime_installs_exact_consumer_manifest( + self, _component, run_cmd, _copy_scenarios + ): + with tempfile.TemporaryDirectory() as directory: + runtime = prepare_synapse_runtime(Path(directory)) + manifest = json.loads((Path(directory) / "package.json").read_text()) + + self.assertEqual(runtime.source, "npm") + self.assertEqual( + manifest["dependencies"], + { + "@filoz/synapse-sdk": "1.1.1", + "@filoz/synapse-core": "1.1.1", + "viem": "2.52.0", + }, + ) + self.assertEqual(manifest["overrides"], {"nanoid": "3.3.13"}) + self.assertEqual( + run_cmd.call_args.args[0], + [ + "npm", + "install", + "--omit=dev", + "--ignore-scripts", + "--package-lock=false", + ], + ) + + @patch("scenarios.synapse_runtime._copy_scenarios") + @patch("scenarios.synapse_runtime.run_cmd", return_value=True) + @patch( + "scenarios.synapse_runtime._npm_view", + side_effect=[ + { + "dependencies": {"@filoz/synapse-core": "^1.1.1"}, + "peerDependencies": {"viem": "2.x"}, + }, + "1.1.1", + ["2.0.0", "2.52.0"], + ], + ) + @patch( + "scenarios.synapse_runtime.component", + return_value={ + "source": "npm", + "package": "@filoz/synapse-sdk", + "version": "1.1.1", + }, + ) + def test_npm_runtime_resolves_fallback_consumer_dependencies( + self, _component, npm_view, run_cmd, _copy_scenarios + ): + with tempfile.TemporaryDirectory() as directory: + prepare_synapse_runtime(Path(directory)) + manifest = json.loads((Path(directory) / "package.json").read_text()) + + self.assertEqual( + manifest["dependencies"], + { + "@filoz/synapse-sdk": "1.1.1", + "@filoz/synapse-core": "1.1.1", + "viem": "2.52.0", + }, + ) + self.assertEqual( + npm_view.call_args_list[0].args, + ( + "@filoz/synapse-sdk", + "1.1.1", + "dependencies", + "peerDependencies", + ), + ) + + @patch.dict("os.environ", {"SYNAPSE_SDK_SOURCE_DIR": ""}, clear=False) + @patch("scenarios.synapse_runtime._copy_scenarios") + @patch("scenarios.synapse_runtime._source_commit", return_value="deadbeef") + @patch("scenarios.synapse_runtime.run_cmd", return_value=True) + @patch( + "scenarios.synapse_runtime.component", + return_value={ + "source": "git", "repository": "https://example.test/synapse.git", - "ref": "sdk-v1.0.0", "commit": "deadbeef", - "overrides": {"nanoid": {"version": "3.3.13", "reason": "test"}}, }, ) - def test_synapse_checkout_uses_resolved_commit(self, _component, run_cmd, _sh): - def fake_run_cmd(command, **_kwargs): - if command[:2] == ["git", "clone"]: - sdk_dir = Path(command[3]) - sdk_dir.mkdir(parents=True) - (sdk_dir / "pnpm-workspace.yaml").write_text( - "packages:\n - packages/*\n" - ) - return True - - run_cmd.side_effect = fake_run_cmd + def test_source_runtime_checks_out_and_installs_production_closure( + self, _component, run_cmd, _source_commit, _copy_scenarios + ): with tempfile.TemporaryDirectory() as directory: - clone_and_build(Path(directory)) - workspace = Path(directory) / "synapse-sdk" / "pnpm-workspace.yaml" - workspace_text = workspace.read_text() - checkout = run_cmd.call_args_list[1] + source = Path(directory) / "synapse-sdk" + source.mkdir() + (source / "packages" / "synapse-sdk" / "node_modules").mkdir(parents=True) + (source / "package.json").write_text('{"packageManager":"pnpm@11.5.3"}') + runtime = prepare_synapse_runtime(Path(directory)) + self.assertEqual( + (runtime.work_dir / "node_modules").resolve(), + source / "packages" / "synapse-sdk" / "node_modules", + ) + + commands = [call.args[0] for call in run_cmd.call_args_list] + self.assertIn(["git", "checkout", "--detach", "deadbeef"], commands) + self.assertIn( + [ + "pnpm", + "install", + "--frozen-lockfile", + "--prod", + "--ignore-scripts", + "--filter", + "@filoz/synapse-sdk...", + ], + commands, + ) + + @patch("scenarios.synapse_runtime._copy_scenarios") + @patch("scenarios.synapse_runtime._source_commit", return_value="localcommit") + @patch("scenarios.synapse_runtime.run_cmd", return_value=True) + @patch( + "scenarios.synapse_runtime.component", + return_value={ + "source": "npm", + "package": "@filoz/synapse-sdk", + "version": "1.1.1", + }, + ) + def test_local_source_runtime_uses_declared_pnpm( + self, _component, run_cmd, _source_commit, _copy_scenarios + ): + with tempfile.TemporaryDirectory() as directory: + work_dir = Path(directory) / "runtime" + source_dir = Path(directory) / "synapse-source" + source_dir.mkdir() + source_node_modules = ( + source_dir / "packages" / "synapse-sdk" / "node_modules" + ) + (source_node_modules / "viem").mkdir(parents=True) + (source_node_modules / "@filoz" / "synapse-core").mkdir(parents=True) + (source_dir / "package.json").write_text('{"packageManager":"pnpm@11.5.3"}') + with patch.dict("os.environ", {"SYNAPSE_SDK_SOURCE_DIR": str(source_dir)}): + runtime = prepare_synapse_runtime(work_dir) + self.assertEqual( - checkout.args[0], - ["git", "checkout", "--detach", "deadbeef"], + runtime.provenance, f"local:{source_dir}@localcommit (pnpm@11.5.3)" ) commands = [call.args[0] for call in run_cmd.call_args_list] - self.assertNotIn(["pnpm", "pkg", "set"], [command[:3] for command in commands]) - self.assertIn(' "nanoid": "3.3.13"', workspace_text) + self.assertFalse(any(command[:2] == ["git", "clone"] for command in commands)) + self.assertFalse(any(command[0] == "pnpm" for command in commands)) - @patch("scenarios.synapse.ok") - @patch("scenarios.synapse.info") - @patch("scenarios.synapse.subprocess.run") - def test_run_node_script_uses_sdk_cwd_and_env(self, run, _info, ok): + @patch("scenarios.synapse_runtime.ok") + @patch("scenarios.synapse_runtime.info") + @patch("scenarios.synapse_runtime.subprocess.run") + def test_run_node_script_uses_consumer_cwd_and_env(self, run, _info, ok): run.return_value = subprocess.CompletedProcess( - ["node", "smoke.mjs"], 0, stdout="done\n", stderr="" + ["node", "smoke.ts"], 0, stdout="done\n", stderr="" ) with tempfile.TemporaryDirectory() as directory: - sdk_dir = Path(directory) - script = sdk_dir / "smoke.mjs" + work_dir = Path(directory) + (work_dir / "smoke.ts").touch() run_node_script( - sdk_dir, - script, + SynapseRuntime(work_dir, "npm", "npm:@filoz/synapse-sdk@1.1.1"), + "smoke.ts", "run smoke", args=["random_file"], env={"DEVNET_USER_INDEX": "1"}, @@ -98,31 +232,71 @@ def test_run_node_script_uses_sdk_cwd_and_env(self, run, _info, ok): ) kwargs = run.call_args.kwargs - self.assertEqual(run.call_args.args[0], ["node", "smoke.mjs", "random_file"]) - self.assertEqual(kwargs["cwd"], str(sdk_dir)) + self.assertEqual( + run.call_args.args[0], ["node", str(work_dir / "smoke.ts"), "random_file"] + ) + self.assertEqual(kwargs["cwd"], str(work_dir)) self.assertEqual(kwargs["env"]["DEVNET_USER_INDEX"], "1") self.assertEqual(kwargs["timeout"], 30) ok.assert_called_once_with("run smoke") - @patch("scenarios.synapse.time.sleep") - @patch("scenarios.synapse.ok") - @patch("scenarios.synapse.info") - @patch("scenarios.synapse.subprocess.run") + @patch("scenarios.synapse_runtime.ok") + @patch("scenarios.synapse_runtime.info") + @patch("scenarios.synapse_runtime.subprocess.run") + def test_run_node_script_uses_source_runtime_hook(self, run, _info, _ok): + run.return_value = subprocess.CompletedProcess( + ["node"], 0, stdout="", stderr="" + ) + with tempfile.TemporaryDirectory() as directory: + work_dir = Path(directory) + source_dir = work_dir / "synapse-sdk" + source_dir.mkdir() + (work_dir / "source-runtime.mjs").touch() + (work_dir / "system-e2e.ts").touch() + run_node_script( + SynapseRuntime(work_dir, "source", "git:example@deadbeef", source_dir), + "system-e2e.ts", + "run system e2e", + ) + + self.assertEqual( + run.call_args.args[0], + [ + "node", + "--import", + str(work_dir / "source-runtime.mjs"), + str(work_dir / "system-e2e.ts"), + ], + ) + self.assertEqual( + run.call_args.kwargs["env"]["SYNAPSE_SDK_SOURCE_DIR"], str(source_dir) + ) + + @patch("scenarios.synapse_runtime.time.sleep") + @patch("scenarios.synapse_runtime.ok") + @patch("scenarios.synapse_runtime.info") + @patch("scenarios.synapse_runtime.subprocess.run") def test_run_node_script_retries_state_fork_error(self, run, _info, ok, sleep): run.side_effect = [ subprocess.CompletedProcess( - ["node", "smoke.mjs"], + ["node", "smoke.ts"], 1, stdout="", stderr="refusing explicit call due to state fork at epoch 42", ), subprocess.CompletedProcess( - ["node", "smoke.mjs"], 0, stdout="done\n", stderr="" + ["node", "smoke.ts"], 0, stdout="done\n", stderr="" ), ] with tempfile.TemporaryDirectory() as directory: - run_node_script(Path(directory), Path("smoke.mjs"), "run smoke") + work_dir = Path(directory) + (work_dir / "smoke.ts").touch() + run_node_script( + SynapseRuntime(work_dir, "npm", "npm:@filoz/synapse-sdk@1.1.1"), + "smoke.ts", + "run smoke", + ) self.assertEqual(run.call_count, 2) sleep.assert_called_once_with(5)