diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 15ba9be4..01a6db37 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -50,7 +50,45 @@ jobs: fi echo "code=$code" | tee -a "$GITHUB_OUTPUT" + # Runs for docs-only changes too: the js tests read the README. + contracts: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3 + with: + cache-bin: "false" + - name: Install the stellar CLI + run: scripts/ci/install-tools.sh stellar + - name: Build the contracts + run: stellar contract build + # A build path in the Wasm would tie its hash to the checkout location. + - name: Print the Wasm hashes and check no build path is embedded + run: | + sha256sum target/wasm32v1-none/release/*.wasm + if grep -l -a -E '/home/runner|/Users/|\.cargo/registry' target/wasm32v1-none/release/*.wasm; then + echo "These Wasm files embed a build path, so their hashes depend on where the repository is checked out." + exit 1 + fi + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 22 + - run: npm i -g npm@11.7.0 + - run: npm ci + - name: Generate the TypeScript clients from the Wasm + run: npm run build:clients + # One path, so the artifact's root is the clients folder itself. + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: clients + path: app-lib/clients/ + if-no-files-found: error + retention-days: 7 + js: + needs: contracts runs-on: ubuntu-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -62,8 +100,14 @@ jobs: - run: npm i -g npm@11.7.0 - run: npm --version - run: npm ci + - name: Download the generated clients + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + name: clients + path: app-lib/clients - run: npm run lint - run: npm run format:check + - run: npm run typecheck - run: npm run test secret-scan: @@ -163,14 +207,6 @@ jobs: # The same cargo build Scaffold runs, so its build below reuses the Wasm. - name: Build the contracts run: stellar contract build - # A build path in the Wasm would tie its hash to the checkout location. - - name: Print the Wasm hashes and check no build path is embedded - run: | - sha256sum target/wasm32v1-none/release/*.wasm - if grep -l -a -E '/home/runner|/Users/|\.cargo/registry' target/wasm32v1-none/release/*.wasm; then - echo "These Wasm files embed a build path, so their hashes depend on where the repository is checked out." - exit 1 - fi - name: Build the vault without the wind-down minimum run: CARGO_TARGET_DIR=target/short-wind-down stellar contract build @@ -295,8 +331,6 @@ jobs: run: npm run deploy -w scripts/harness env: HARNESS_NETWORK: local - - name: Typecheck the harness workspace - run: npm run typecheck -w scripts/harness - name: Run the vault happy path on the local network run: npm run test:happy-path -w scripts/harness env: @@ -311,7 +345,7 @@ jobs: HARNESS_NETWORK: local - name: Keep the harness report when it fails if: failure() - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: harness-runs path: scripts/harness/runs/ @@ -344,13 +378,15 @@ jobs: # The check branch protection requires. Rust and network skip only for docs; the secret scan runs on pull requests. build: if: always() - needs: [changes, js, secret-scan, rust-tests, rust-checks, network] + needs: + [changes, contracts, js, secret-scan, rust-tests, rust-checks, network] runs-on: ubuntu-latest steps: - name: Check the results env: CODE: ${{ needs.changes.outputs.code }} CHANGES: ${{ needs.changes.result }} + CONTRACTS: ${{ needs.contracts.result }} JS: ${{ needs.js.result }} EVENT: ${{ github.event_name }} SECRET_SCAN: ${{ needs.secret-scan.result }} @@ -358,8 +394,8 @@ jobs: RUST_CHECKS: ${{ needs.rust-checks.result }} NETWORK: ${{ needs.network.result }} run: | - echo "changes=$CHANGES js=$JS event=$EVENT secret-scan=$SECRET_SCAN rust-tests=$RUST_TESTS rust-checks=$RUST_CHECKS network=$NETWORK code=$CODE" - [ "$CHANGES" = success ] && [ "$JS" = success ] || exit 1 + echo "changes=$CHANGES contracts=$CONTRACTS js=$JS event=$EVENT secret-scan=$SECRET_SCAN rust-tests=$RUST_TESTS rust-checks=$RUST_CHECKS network=$NETWORK code=$CODE" + [ "$CHANGES" = success ] && [ "$CONTRACTS" = success ] && [ "$JS" = success ] || exit 1 if [ "$EVENT" = pull_request ]; then [ "$SECRET_SCAN" = success ] || exit 1; fi if [ "$CODE" != false ]; then [ "$RUST_TESTS" = success ] && [ "$RUST_CHECKS" = success ] && [ "$NETWORK" = success ] || exit 1 diff --git a/AGENTS.md b/AGENTS.md index 7ad69795..32389266 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -51,11 +51,14 @@ does not carry over. Read it for Soroban and OZ mechanics only. enable an experimental `soroban-sdk` feature (`spec_shaking_v2`) that only works through the CLI wrapper. The devshell pins the Stellar CLI it expects. - **Tests:** `cargo test` from the repo root runs every workspace member against - the in-memory `Env`. There is no unit-test runner for `app/` or `app-lib/`; - `e2e/` runs Playwright separately. CI runs `cargo test --locked` on every pull - request except one that changes only Markdown files outside `docs/evidence/`: - that one skips the Rust and local-network jobs. The secret scan runs on - every pull request. + the in-memory `Env`. `npm run test` runs vitest over `app/`, `app-lib/` and + `scripts/`, and `npm run typecheck` checks `app/`, `app-lib/` and the + harness. Both use the generated contract clients, so run + `stellar contract build && npm run build:clients` first. `e2e/` runs + Playwright separately. CI runs `cargo test --locked` on every pull request + except one that changes only Markdown files outside `docs/evidence/`: that + one skips the Rust and local-network jobs. The secret scan runs on every pull + request. - **Toolchain:** `nix develop` provides it, or rustup honours `rust-toolchain.toml`. @@ -97,11 +100,18 @@ apply as the corresponding code lands here. re-exports them and builds each runtime client with `new Client(...)` from its generated `src/index`, whose embedded spec means no page fetches a contract's instance or Wasm. Vite dedupes `@stellar/stellar-sdk` so a client's nested copy - never reaches the bundle, and vitest aliases the generated modules to - `vitest.clients.ts`, since tests run without them. + never reaches the bundle. A fresh clone builds `app` with no cargo, no stellar CLI and no local network, but needs RPC access to the selected network at build time. A local scaffold build writes the same directory from the WASM. +- The clients come from one generator, the SDK's `BindingGenerator`, which + types an empty `Option` as `null`. `npm run build:clients` and the harness + deploy write each client's `src/` from the local Wasm, with no network, and + compile it to the `dist/` the harness imports. A workspace build + (`npm run build --workspaces`) recompiles `dist/` from `src/`. The scaffold's + `--build-clients` writes the stellar CLI's bindings instead, which type an + empty `Option` as `undefined`, so run `npm run build:clients` again after it + before type-checking. - When `cargo` itself builds a contract for the Wasm target, rather than `stellar contract build` (a size check with `cargo build --release --target wasm32v1-none`, for example), set diff --git a/README.md b/README.md index 6944d796..6a284556 100644 --- a/README.md +++ b/README.md @@ -152,6 +152,19 @@ Formatting and secret-scanning run on commit via a `prek` hook — see `.pre-commit-config.yaml` and run `prek install` once. CI also scans every pull request's commits with the same gitleaks version. +### Console and script tests + +The tests and the type check for `app/`, `app-lib/` and `scripts/` run against +the TypeScript clients generated from the contracts. Generate them once, and +again after a contract's interface changes: + +```sh +npm ci +stellar contract build && npm run build:clients +npm run test # stops and names any client that is missing +npm run typecheck # app, app-lib and the harness +``` + ### Integration & On-Chain Tests From a fresh clone to a vault set deployed on a local network. Run these inside diff --git a/app-lib/buffer.d.ts b/app-lib/buffer.d.ts new file mode 100644 index 00000000..6f67cd86 --- /dev/null +++ b/app-lib/buffer.d.ts @@ -0,0 +1,7 @@ +import { type Buffer as BufferPackage } from "buffer" + +// The generated clients name `Buffer` as a global; in the browser it comes from +// the `buffer` package, as in the app. +declare global { + type Buffer = BufferPackage +} diff --git a/app-lib/contracts.test.ts b/app-lib/contracts.test.ts index 85b30bd7..aa29ef79 100644 --- a/app-lib/contracts.test.ts +++ b/app-lib/contracts.test.ts @@ -1,6 +1,8 @@ -import type { ClientOptions } from "@stellar/stellar-sdk/contract" +import { + Client as ContractClient, + type ClientOptions, +} from "@stellar/stellar-sdk/contract" import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" -import { Client as GeneratedClient } from "../vitest.clients" import { connectAsset, connectAsyncVault, @@ -12,7 +14,7 @@ import { import { server } from "./rpc" const optionsOf = (client: unknown): ClientOptions => { - if (!(client instanceof GeneratedClient)) + if (!(client instanceof ContractClient)) throw new Error("not built from the generated bindings") return client.options } diff --git a/app-lib/multisig.test.ts b/app-lib/multisig.test.ts index ba13adc6..d4cf294b 100644 --- a/app-lib/multisig.test.ts +++ b/app-lib/multisig.test.ts @@ -16,12 +16,14 @@ import { } from "@stellar/stellar-sdk" import { AssembledTransaction, + Client as ContractClient, Spec, type ClientOptions, + type MethodOptions, } from "@stellar/stellar-sdk/contract" import { Api } from "@stellar/stellar-sdk/rpc" import { beforeEach, describe, expect, it, vi } from "vitest" -import { Client as GeneratedClient } from "../vitest.clients" +import type { AccountSigners } from "./accounts" import { connectAsyncVault } from "./contracts" import { networkPassphrase } from "./env" import { @@ -89,12 +91,13 @@ const signedBy = (...keypairs: Keypair[]) => { return tx } -const signers = { +const signers: AccountSigners = { signers: [ { key: signerOne.publicKey(), weight: 1 }, { key: signerTwo.publicKey(), weight: 2 }, ], thresholds: { low: 1, med: 3, high: 3 }, + sequence: "1", } const resultArm = (code: string) => @@ -552,9 +555,10 @@ describe("transactionStatus", () => { }) const timeoutOf = (client: unknown): number | undefined => { - if (!(client instanceof GeneratedClient)) + if (!(client instanceof ContractClient)) throw new Error("not the generated vault client") - return client.options.timeoutInSeconds + // The client hands its options to each call, method defaults included. + return (client.options as ClientOptions & MethodOptions).timeoutInSeconds } describe("operator timeout", () => { diff --git a/app-lib/tsconfig.json b/app-lib/tsconfig.json index e9bd1553..a3cfa899 100644 --- a/app-lib/tsconfig.json +++ b/app-lib/tsconfig.json @@ -1,4 +1,5 @@ { "extends": "@theahaco/ts-config/typescript.app", - "include": ["."] + "include": ["."], + "exclude": ["clients/*/dist", "node_modules"] } diff --git a/app/package.json b/app/package.json index 74bbc631..3a0be82f 100644 --- a/app/package.json +++ b/app/package.json @@ -12,7 +12,7 @@ "preview": "vite preview", "lint": "eslint .", "format": "prettier . --write", - "typecheck": "tsc" + "typecheck": "tsc -b" }, "dependencies": { "@stellar-scaffold/app-lib": "*", diff --git a/app/tsconfig.json b/app/tsconfig.json index fb124188..6e920220 100644 --- a/app/tsconfig.json +++ b/app/tsconfig.json @@ -2,6 +2,7 @@ "files": [], "references": [ { "path": "./tsconfig.app.json" }, - { "path": "./tsconfig.node.json" } + { "path": "./tsconfig.node.json" }, + { "path": "../app-lib" } ] } diff --git a/package.json b/package.json index 667a3685..5a174316 100644 --- a/package.json +++ b/package.json @@ -11,8 +11,10 @@ "format:check": "prettier . --check", "build:clients": "npm run build:clients -w scripts/harness", "seed": "npm run seed -w scripts/harness --", - "pretest": "node scripts/generate-addresses.mjs", - "test": "vitest run" + "pretest": "node scripts/clients-guard.mjs && node scripts/generate-addresses.mjs", + "test": "vitest run", + "pretypecheck": "node scripts/clients-guard.mjs --compiled && node scripts/generate-addresses.mjs", + "typecheck": "npm run typecheck -w app -w scripts/harness" }, "workspaces": [ "app", diff --git a/scripts/clients-guard.mjs b/scripts/clients-guard.mjs new file mode 100644 index 00000000..7589205f --- /dev/null +++ b/scripts/clients-guard.mjs @@ -0,0 +1,14 @@ +import { dirname, resolve } from "node:path" +import { fileURLToPath } from "node:url" +import { missingClients, missingClientsMessage } from "./lib/clients.mjs" + +// Runs before the root `test`, and with `--compiled` before `typecheck`. +const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..") + +const missing = missingClients(repoRoot, { + compiled: process.argv.includes("--compiled"), +}) +if (missing.length > 0) { + console.error(missingClientsMessage(missing)) + process.exit(1) +} diff --git a/scripts/generate-clients.mjs b/scripts/generate-clients.mjs index 76ee8957..baa8c892 100644 --- a/scripts/generate-clients.mjs +++ b/scripts/generate-clients.mjs @@ -1,10 +1,11 @@ #!/usr/bin/env node // Generates a typed client per deployed contract from its on-chain spec, so a // build needs Node and RPC access only, with no cargo or Stellar CLI. -import { mkdirSync, readFileSync, writeFileSync } from "node:fs" +import { readFileSync } from "node:fs" import { dirname, resolve } from "node:path" import { fileURLToPath } from "node:url" import { BindingGenerator, rpc } from "@stellar/stellar-sdk" +import { writeClient } from "./lib/clients.mjs" import { contractIds, networkOf, @@ -26,21 +27,7 @@ const generate = async ({ name, id }) => { rpcUrl, networkPassphrase, }) - const dir = resolve(repoRoot, "app-lib/clients", name) - mkdirSync(resolve(dir, "src"), { recursive: true }) - const sources = { - "src/index.ts": files.index, - "src/types.ts": files.types, - "src/client.ts": files.client, - "package.json": files.packageJson.replace( - /"name": "[^"]+"/, - `"name": "${name}"`, - ), - "tsconfig.json": files.tsConfig, - } - for (const [path, content] of Object.entries(sources)) { - writeFileSync(resolve(dir, path), content) - } + writeClient(resolve(repoRoot, "app-lib/clients", name), files) } await Promise.all(contracts.map(generate)) diff --git a/scripts/harness/build-clients.ts b/scripts/harness/build-clients.ts index d7e3e1da..83637b71 100644 --- a/scripts/harness/build-clients.ts +++ b/scripts/harness/build-clients.ts @@ -1,6 +1,8 @@ import { existsSync } from "node:fs" import { join } from "node:path" -import { generateClient, rootSdkVersion } from "./lib/cli.js" + +import { stellarAssetGenerator } from "./lib/asset-client.js" +import { generateClient, rootSdkVersion, wasmGenerator } from "./lib/cli.js" import { CONTRACT_NAMES, REPO_ROOT, @@ -10,7 +12,7 @@ import { const wasmOf = (name: ContractName): string => join(REPO_ROOT, "target", "wasm32v1-none", "release", `${name}.wasm`) -const clientDir = (name: ContractName): string => +const clientDir = (name: ContractName | "asset"): string => join(REPO_ROOT, "app-lib", "clients", name) async function main(): Promise { @@ -23,8 +25,17 @@ async function main(): Promise { const sdkVersion = rootSdkVersion() for (const name of CONTRACT_NAMES) { - await generateClient(wasmOf(name), clientDir(name), sdkVersion) + await generateClient( + clientDir(name), + wasmGenerator(wasmOf(name)), + sdkVersion, + ) } + await generateClient( + clientDir("asset"), + await stellarAssetGenerator(), + sdkVersion, + ) console.log("clients generated") } diff --git a/scripts/harness/deploy.ts b/scripts/harness/deploy.ts index 0bd3a475..4fbd370f 100644 --- a/scripts/harness/deploy.ts +++ b/scripts/harness/deploy.ts @@ -18,6 +18,7 @@ import { ensureAssetContract, generateClient, rootSdkVersion, + wasmGenerator, } from "./lib/cli.js" import { clearIncomplete, @@ -438,7 +439,11 @@ async function main(): Promise { ) for (const name of CONTRACT_NAMES) { - await generateClient(wasmOf(name), clientDir(name), sdkVersion) + await generateClient( + clientDir(name), + wasmGenerator(wasmOf(name)), + sdkVersion, + ) } console.log("clients generated") diff --git a/scripts/harness/lib/asset-client.test.ts b/scripts/harness/lib/asset-client.test.ts new file mode 100644 index 00000000..104f8c74 --- /dev/null +++ b/scripts/harness/lib/asset-client.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from "vitest" + +import { stellarAssetGenerator } from "./asset-client.js" + +describe("stellarAssetGenerator", () => { + it("generates the Stellar Asset Contract's client without a network", async () => { + const generator = await stellarAssetGenerator() + + const { client } = generator.generate({ contractName: "asset" }) + + expect(client).toContain("export class Client") + for (const method of ["balance", "transfer", "decimals", "clawback"]) { + expect(client).toMatch(new RegExp(`\\b${method}\\b`)) + } + }) +}) diff --git a/scripts/harness/lib/asset-client.ts b/scripts/harness/lib/asset-client.ts new file mode 100644 index 00000000..ff3894fe --- /dev/null +++ b/scripts/harness/lib/asset-client.ts @@ -0,0 +1,27 @@ +import { BindingGenerator, StrKey, xdr, type rpc } from "@stellar/stellar-sdk" + +// The SDK gives out its Stellar Asset Contract spec only after a lookup, which this answers. +const stellarAssetLookup = { + getLedgerEntries(key: xdr.LedgerKey) { + const instance = new xdr.ScContractInstance({ + executable: xdr.ContractExecutable.contractExecutableStellarAsset(), + storage: null, + }) + const val = xdr.LedgerEntryData.contractData( + new xdr.ContractDataEntry({ + ext: new xdr.ExtensionPoint(0), + contract: key.contractData().contract(), + key: key.contractData().key(), + durability: xdr.ContractDataDurability.persistent(), + val: xdr.ScVal.scvContractInstance(instance), + }), + ) + return Promise.resolve({ entries: [{ key, val }] }) + }, +} as unknown as rpc.Server + +export const stellarAssetGenerator = (): Promise => + BindingGenerator.fromContractId( + StrKey.encodeContract(Buffer.alloc(32)), + stellarAssetLookup, + ) diff --git a/scripts/harness/lib/cli.test.ts b/scripts/harness/lib/cli.test.ts index a7c87ad7..2e74acd0 100644 --- a/scripts/harness/lib/cli.test.ts +++ b/scripts/harness/lib/cli.test.ts @@ -1,4 +1,5 @@ -import { readFileSync } from "node:fs" +import { existsSync, mkdtempSync, readFileSync } from "node:fs" +import { tmpdir } from "node:os" import { join } from "node:path" import { @@ -11,7 +12,13 @@ import { } from "@stellar/stellar-sdk" import { describe, expect, it } from "vitest" -import { ensureAssetContract, pinClientSdk, rootSdkVersion } from "./cli.js" +import { stellarAssetGenerator } from "./asset-client.js" +import { + ensureAssetContract, + pinClientSdk, + rootSdkVersion, + writeClientSources, +} from "./cli.js" import { REPO_ROOT } from "./deployment.js" import { type Network } from "./network.js" @@ -75,3 +82,20 @@ describe("rootSdkVersion", () => { expect(rootSdkVersion()).toBe(rootPkg.overrides?.["@stellar/stellar-sdk"]) }) }) + +describe("writeClientSources", () => { + it("writes the SDK's bindings named after the client, with its stellar-sdk pinned", async () => { + const outDir = join(mkdtempSync(join(tmpdir(), "client-")), "asset") + + writeClientSources(outDir, await stellarAssetGenerator(), "16.4.1") + + const pkg = JSON.parse( + readFileSync(join(outDir, "package.json"), "utf8"), + ) as { name: string; dependencies: Record } + expect(pkg.name).toBe("asset") + expect(pkg.dependencies["@stellar/stellar-sdk"]).toBe("16.4.1") + for (const file of ["index.ts", "types.ts", "client.ts"]) { + expect(existsSync(join(outDir, "src", file))).toBe(true) + } + }) +}) diff --git a/scripts/harness/lib/cli.ts b/scripts/harness/lib/cli.ts index 227b631e..3936528e 100644 --- a/scripts/harness/lib/cli.ts +++ b/scripts/harness/lib/cli.ts @@ -1,10 +1,11 @@ import { execFile } from "node:child_process" -import { existsSync, readFileSync, writeFileSync } from "node:fs" +import { readFileSync, writeFileSync } from "node:fs" import { createRequire } from "node:module" import { basename, join } from "node:path" import { promisify } from "node:util" import { + BindingGenerator, Contract, StrKey, type Asset, @@ -12,6 +13,7 @@ import { type rpc, } from "@stellar/stellar-sdk" +import { writeClient } from "../../lib/clients.mjs" import { REPO_ROOT } from "./deployment.js" import { type Network } from "./network.js" @@ -163,35 +165,39 @@ export function rootSdkVersion(): string { return version } -export async function generateClient( - wasm: string, +export const wasmGenerator = (wasm: string): BindingGenerator => + BindingGenerator.fromWasm(readFileSync(wasm)) + +/** Writes a client's SDK bindings to `outDir`, named after it, with its stellar-sdk pinned. */ +export function writeClientSources( outDir: string, + generator: BindingGenerator, sdkVersion: string, -): Promise { - await stellar([ - "contract", - "bindings", - "typescript", - "--wasm", - wasm, - "--output-dir", - outDir, - "--overwrite", - ]) +): void { + writeClient(outDir, generator.generate({ contractName: basename(outDir) })) const pkgPath = join(outDir, "package.json") - if (existsSync(pkgPath)) { - const pkg = JSON.parse(readFileSync(pkgPath, "utf8")) as ClientPackageJson - const pinned = pinClientSdk(pkg, sdkVersion) - if (pinned !== pkg) { - writeFileSync(pkgPath, JSON.stringify(pinned, null, 2) + "\n") - } + const pkg = JSON.parse(readFileSync(pkgPath, "utf8")) as ClientPackageJson + const pinned = pinClientSdk(pkg, sdkVersion) + if (pinned !== pkg) { + writeFileSync(pkgPath, JSON.stringify(pinned, null, 2) + "\n") } - // TypeScript 6 rejects the CLI-generated tsconfig without an explicit rootDir. +} + +/** Writes a client's sources and compiles its `dist/`, which the harness imports. */ +export async function generateClient( + outDir: string, + generator: BindingGenerator, + sdkVersion: string, +): Promise { + writeClientSources(outDir, generator, sdkVersion) + // TypeScript 6 needs the rootDir and the node types the generated tsconfig leaves to defaults. await exec(process.execPath, [ tsc, "-p", outDir, "--rootDir", join(outDir, "src"), + "--types", + "node", ]) } diff --git a/scripts/lib/clients.d.mts b/scripts/lib/clients.d.mts new file mode 100644 index 00000000..fded30a1 --- /dev/null +++ b/scripts/lib/clients.d.mts @@ -0,0 +1,14 @@ +// Types for clients.mjs, which the harness imports from TypeScript. +import type { BindingGenerator } from "@stellar/stellar-sdk" + +export function missingClients( + root: string, + options?: { compiled?: boolean }, +): string[] + +export function missingClientsMessage(missing: string[]): string + +export function writeClient( + dir: string, + files: ReturnType, +): void diff --git a/scripts/lib/clients.mjs b/scripts/lib/clients.mjs new file mode 100644 index 00000000..753f6e72 --- /dev/null +++ b/scripts/lib/clients.mjs @@ -0,0 +1,49 @@ +import { existsSync, mkdirSync, readdirSync, writeFileSync } from "node:fs" +import { basename, join } from "node:path" + +const crateClients = (root) => + readdirSync(join(root, "contracts"), { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name.replaceAll("-", "_")) + .sort() + +// Tests import `src/`; the type check also needs each contract client's `dist/`. +export const missingClients = (root, { compiled = false } = {}) => + [ + ...crateClients(root).flatMap((name) => [ + `app-lib/clients/${name}/src/index.ts`, + ...(compiled ? [`app-lib/clients/${name}/dist/index.js`] : []), + ]), + "app-lib/clients/asset/src/index.ts", + ].filter((path) => !existsSync(join(root, path))) + +export const missingClientsMessage = (missing) => + [ + "", + "The generated contract clients are missing:", + "", + ...missing.map((path) => ` - ${path}`), + "", + "Build the contracts and generate the clients first:", + "", + " stellar contract build && npm run build:clients", + "", + ].join("\n") + +export const writeClient = (dir, files) => { + const name = basename(dir) + mkdirSync(join(dir, "src"), { recursive: true }) + const sources = { + "src/index.ts": files.index, + "src/types.ts": files.types, + "src/client.ts": files.client, + "package.json": files.packageJson.replace( + /"name": "[^"]+"/, + `"name": "${name}"`, + ), + "tsconfig.json": files.tsConfig, + } + for (const [path, content] of Object.entries(sources)) { + writeFileSync(join(dir, path), content) + } +} diff --git a/scripts/lib/clients.test.mjs b/scripts/lib/clients.test.mjs new file mode 100644 index 00000000..b1f35bfd --- /dev/null +++ b/scripts/lib/clients.test.mjs @@ -0,0 +1,129 @@ +import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" + +import { describe, expect, it } from "vitest" +import { + missingClients, + missingClientsMessage, + writeClient, +} from "./clients.mjs" + +const repo = (contracts, clients, { unbuilt = [] } = {}) => { + const root = mkdtempSync(join(tmpdir(), "clients-")) + for (const name of contracts) { + mkdirSync(join(root, "contracts", name), { recursive: true }) + } + for (const name of clients) { + const client = join(root, "app-lib", "clients", name) + mkdirSync(join(client, "src"), { recursive: true }) + writeFileSync(join(client, "src", "index.ts"), "export {}\n") + if (unbuilt.includes(name)) continue + mkdirSync(join(client, "dist"), { recursive: true }) + writeFileSync(join(client, "dist", "index.js"), "export {}\n") + } + return root +} + +const source = (name) => `app-lib/clients/${name}/src/index.ts` +const compiled = (name) => `app-lib/clients/${name}/dist/index.js` + +describe("missingClients", () => { + it("finds none when every contract and the asset have a client", () => { + const root = repo( + ["async-vault", "nav-oracle"], + ["async_vault", "nav_oracle", "asset"], + ) + + expect(missingClients(root)).toEqual([]) + expect(missingClients(root, { compiled: true })).toEqual([]) + }) + + it("names each contract without a client, in its underscored form", () => { + const root = repo(["async-vault", "nav-oracle"], ["async_vault", "asset"]) + + expect(missingClients(root)).toEqual([source("nav_oracle")]) + }) + + it("needs a client for the asset, which has no crate", () => { + const root = repo(["async-vault"], ["async_vault"]) + + expect(missingClients(root)).toEqual([source("asset")]) + }) + + it("passes the test guard with sources alone, since the tests import src", () => { + const root = repo(["async-vault"], ["async_vault", "asset"], { + unbuilt: ["async_vault", "asset"], + }) + + expect(missingClients(root)).toEqual([]) + }) + + it("fails the typecheck guard without each contract client's dist, which the harness imports", () => { + const root = repo(["async-vault"], ["async_vault", "asset"], { + unbuilt: ["async_vault", "asset"], + }) + + const missing = missingClients(root, { compiled: true }) + + expect(missing).toEqual([compiled("async_vault")]) + expect(missingClientsMessage(missing)).toContain("npm run build:clients") + }) + + it("names them all when the clients folder is empty", () => { + const root = repo(["async-vault", "share-token"], []) + mkdirSync(join(root, "app-lib", "clients"), { recursive: true }) + + expect(missingClients(root, { compiled: true })).toEqual([ + source("async_vault"), + compiled("async_vault"), + source("share_token"), + compiled("share_token"), + source("asset"), + ]) + }) +}) + +describe("missingClientsMessage", () => { + it("lists each missing path and the command that generates them", () => { + const message = missingClientsMessage([source("asset")]) + + expect(message).toContain(source("asset")) + expect(message).toContain("stellar contract build && npm run build:clients") + }) +}) + +describe("writeClient", () => { + const files = { + index: "export * from './client.js'\n", + types: "export {}\n", + client: "export class Client {}\n", + packageJson: '{\n "name": "generated-name",\n "version": "0.0.1"\n}\n', + tsConfig: "{}\n", + } + + it("writes the generated sources where the app imports them", () => { + const root = repo(["async-vault"], []) + + writeClient(join(root, "app-lib", "clients", "async_vault"), files) + + const src = join(root, "app-lib", "clients", "async_vault", "src") + expect(readFileSync(join(src, "index.ts"), "utf8")).toBe(files.index) + expect(readFileSync(join(src, "types.ts"), "utf8")).toBe(files.types) + expect(readFileSync(join(src, "client.ts"), "utf8")).toBe(files.client) + }) + + it("names the package after the client", () => { + const root = repo([], []) + + writeClient(join(root, "app-lib", "clients", "nav_oracle"), files) + + const pkg = JSON.parse( + readFileSync( + join(root, "app-lib", "clients", "nav_oracle", "package.json"), + "utf8", + ), + ) + expect(pkg).toEqual({ name: "nav_oracle", version: "0.0.1" }) + }) +}) diff --git a/vitest.clients.ts b/vitest.clients.ts deleted file mode 100644 index d3864a0a..00000000 --- a/vitest.clients.ts +++ /dev/null @@ -1,5 +0,0 @@ -import type { ClientOptions } from "@stellar/stellar-sdk/contract" - -export class Client { - constructor(public readonly options: ClientOptions) {} -} diff --git a/vitest.config.ts b/vitest.config.ts index 662d0ea6..546a5d5a 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -3,23 +3,11 @@ import { configDefaults, defineConfig } from "vitest/config" const setupFiles = [resolve(import.meta.dirname, "vitest.setup.ts")] -// The generated bindings are fetched from the network at build time, so CI -// tests run without them. -const resolveGeneratedClients = { - alias: [ - { - find: /^\.\/clients\/\w+\/src\/index$/, - replacement: resolve(import.meta.dirname, "vitest.clients.ts"), - }, - ], -} - export default defineConfig({ test: { projects: [ { root: "./app", - resolve: resolveGeneratedClients, test: { name: "app", environment: "jsdom", @@ -40,12 +28,11 @@ export default defineConfig({ }, { root: "./app-lib", - resolve: resolveGeneratedClients, test: { name: "app-lib", environment: "node", include: ["**/*.test.ts"], - // `clients/` is generated and does not exist yet. + // Generated code, with no tests of its own. exclude: [...configDefaults.exclude, "clients/**"], }, },