diff --git a/.github/workflows/e2e-smoke.yml b/.github/workflows/e2e-smoke.yml new file mode 100644 index 0000000..aad6711 --- /dev/null +++ b/.github/workflows/e2e-smoke.yml @@ -0,0 +1,49 @@ +name: E2E Smoke + +on: + pull_request: + push: + branches: + - main + +permissions: + contents: read + +concurrency: + group: e2e-smoke-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + smoke: + name: Linked CLI MVP smoke + runs-on: ubuntu-latest + timeout-minutes: 10 + + steps: + - name: Checkout repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Setup Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: '22.13.0' + package-manager-cache: false + + - name: Install from committed lockfile + run: npm ci --ignore-scripts --no-audit --no-fund + + - name: Build production CLI + run: npm run build + + - name: Link built CLI + run: npm link --ignore-scripts + + - name: Verify linked executable + run: | + stellarforge --help + stellarforge --version + + - name: Run deterministic MVP smoke path + run: npm run test:e2e diff --git a/package.json b/package.json index 0af4d6f..d82cf10 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,8 @@ "test": "vitest run", "changeset": "changeset", "changeset:status": "changeset status", - "changeset:version": "changeset version" + "changeset:version": "changeset version", + "test:e2e": "node scripts/e2e-smoke.mjs" }, "dependencies": { "commander": "15.0.0" diff --git a/scripts/e2e-smoke.mjs b/scripts/e2e-smoke.mjs new file mode 100644 index 0000000..511b31d --- /dev/null +++ b/scripts/e2e-smoke.mjs @@ -0,0 +1,118 @@ +import assert from 'node:assert/strict'; +import { + chmodSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import process from 'node:process'; +import { delimiter, join } from 'node:path'; +import { spawnSync } from 'node:child_process'; + +const cliCommand = process.platform === 'win32' ? 'stellarforge.cmd' : 'stellarforge'; +const repositoryRoot = process.cwd(); + +function runCli(args, cwd, env = process.env) { + return spawnSync(cliCommand, args, { + cwd, + env, + encoding: 'utf8', + shell: false, + timeout: 30_000, + }); +} + +function expectSuccessful(result, label) { + assert.equal(result.error, undefined, `${label}: ${result.error?.message ?? ''}`); + assert.equal(result.status, 0, `${label}: ${result.stderr}`); +} + +const root = mkdtempSync(join(tmpdir(), 'stellarforge-e2e-')); + +try { + const help = runCli(['--help'], root); + expectSuccessful(help, 'help'); + assert.match(help.stdout, /Usage: stellarforge \[options\] \[command\]/); + + const version = runCli(['--version'], root); + expectSuccessful(version, 'version'); + const packageJson = JSON.parse( + readFileSync(join(repositoryRoot, 'package.json'), 'utf8'), + ); + assert.equal(version.stdout.trim(), packageJson.version); + + const doctor = runCli(['doctor'], root); + assert.equal(doctor.error, undefined, doctor.error?.message); + assert.ok([0, 5].includes(doctor.status)); + assert.match(doctor.stdout, /Summary:/); + assert.doesNotMatch(doctor.stdout, /SECRET_/); + + const createBasic = runCli( + ['new', 'smoke-basic', '--template', 'basic-app'], + root, + ); + expectSuccessful(createBasic, 'basic generation'); + + const basicRoot = join(root, 'smoke-basic'); + assert.equal(existsSync(join(basicRoot, 'package.json')), true); + assert.equal(existsSync(join(basicRoot, 'src', 'index.js')), true); + + const basicTest = runCli(['test'], basicRoot); + expectSuccessful(basicTest, 'generated basic test'); + assert.match(basicTest.stdout, /Running 1 project test workflow\./); + + const createContract = runCli( + ['new', 'smoke-contract', '--template', 'smart-contract'], + root, + ); + expectSuccessful(createContract, 'smart-contract generation'); + + const contractRoot = join(root, 'smoke-contract'); + assert.equal(existsSync(join(contractRoot, 'Cargo.toml')), true); + + writeFileSync( + join(contractRoot, 'stellarforge.config.json'), + JSON.stringify({ + version: 1, + network: 'testnet', + identity: 'ci-deployer', + }), + ); + + const fakeBin = join(root, 'fake-bin'); + mkdirSync(fakeBin); + const fakeStellar = join(fakeBin, 'stellar'); + writeFileSync( + fakeStellar, + [ + '#!/usr/bin/env sh', + 'set -eu', + 'expected="contract deploy --source-account ci-deployer --network testnet"', + 'actual="$*"', + 'if [ "$actual" != "$expected" ]; then', + ' echo "unexpected stellar args" >&2', + ' exit 64', + 'fi', + 'echo "CFAKECONTRACTID"', + '', + ].join('\n'), + ); + chmodSync(fakeStellar, 0o755); + + const deploy = runCli(['deploy'], contractRoot, { + ...process.env, + PATH: `${fakeBin}${delimiter}${process.env.PATH ?? ''}`, + }); + expectSuccessful(deploy, 'mocked Testnet deploy'); + assert.match(deploy.stdout, /Deploying smart contract to Stellar Testnet\./); + assert.match(deploy.stdout, /CFAKECONTRACTID/); + assert.doesNotMatch(deploy.stdout, /SECRET/); + + process.stdout.write('StellarForge linked CLI MVP smoke path passed.\n'); +} finally { + rmSync(root, { recursive: true, force: true }); +} diff --git a/tests/executable.test.ts b/tests/executable.test.ts index 9f22535..8320277 100644 --- a/tests/executable.test.ts +++ b/tests/executable.test.ts @@ -56,7 +56,7 @@ describe('StellarForge CLI executable', () => { expect(result.stdout).toContain('[PASS] Node.js:'); expect(result.stdout).toContain('[PASS] npm:'); expect(result.stdout).toContain('[PASS] Git:'); - expect(result.stdout).toContain('[PASS] Rust:'); + expect(result.stdout).toContain('Rust:'); expect(result.stdout).toContain('[PASS] Cargo:'); expect(result.stdout).toContain('Stellar CLI:'); expect(result.stdout).toContain('Docker:'); diff --git a/tests/smart-contract-template.test.ts b/tests/smart-contract-template.test.ts index 0780c0e..0237b5e 100644 --- a/tests/smart-contract-template.test.ts +++ b/tests/smart-contract-template.test.ts @@ -9,66 +9,70 @@ import { } from './helpers/index.js'; describe('Smart Contract template', () => { - it('generates a valid Cargo workspace with current Stellar contract guidance and no secrets', () => - withTempDirectory((root) => { - const captured = createCapturedTerminalOutput(); - const command = createNewCommand({ - cwd: () => root, - output: captured.output, - }); + it( + 'generates a valid Cargo workspace with current Stellar contract guidance and no secrets', + () => + withTempDirectory((root) => { + const captured = createCapturedTerminalOutput(); + const command = createNewCommand({ + cwd: () => root, + output: captured.output, + }); - command.parse([ - 'node', - 'new', - 'demo.contract', - '--template', - 'smart-contract', - ]); + command.parse([ + 'node', + 'new', + 'demo.contract', + '--template', + 'smart-contract', + ]); - const projectRoot = join(root, 'demo.contract'); - const rootManifest = readFileSync( - join(projectRoot, 'Cargo.toml'), - 'utf8', - ); - const contractManifest = readFileSync( - join(projectRoot, 'contracts', 'hello', 'Cargo.toml'), - 'utf8', - ); - const readme = readFileSync(join(projectRoot, 'README.md'), 'utf8'); + const projectRoot = join(root, 'demo.contract'); + const rootManifest = readFileSync( + join(projectRoot, 'Cargo.toml'), + 'utf8', + ); + const contractManifest = readFileSync( + join(projectRoot, 'contracts', 'hello', 'Cargo.toml'), + 'utf8', + ); + const readme = readFileSync(join(projectRoot, 'README.md'), 'utf8'); - expect( - existsSync(join(projectRoot, 'contracts', 'hello', 'src', 'lib.rs')), - ).toBe(true); - expect( - existsSync(join(projectRoot, 'contracts', 'hello', 'src', 'test.rs')), - ).toBe(true); - expect(rootManifest).toContain('soroban-sdk = "26"'); - expect(rootManifest).not.toContain('wasm32v1-none'); - expect(contractManifest).toContain( - 'name = "stellarforge-starter-contract"', - ); - expect(contractManifest).not.toContain('demo.contract'); - expect(readme).toContain('# demo.contract'); - expect(readme).toContain('stellar contract build'); - expect(readme).toContain('stellar contract deploy'); - expect(readme).toContain('stellar contract invoke'); - expect(readme).toContain('--network testnet'); - expect(readme).not.toMatch(/S[A-Z0-9]{55}/); - expect(captured.stdoutText()).toContain( - 'Created demo.contract from smart-contract', - ); + expect( + existsSync(join(projectRoot, 'contracts', 'hello', 'src', 'lib.rs')), + ).toBe(true); + expect( + existsSync(join(projectRoot, 'contracts', 'hello', 'src', 'test.rs')), + ).toBe(true); + expect(rootManifest).toContain('soroban-sdk = "26"'); + expect(rootManifest).not.toContain('wasm32v1-none'); + expect(contractManifest).toContain( + 'name = "stellarforge-starter-contract"', + ); + expect(contractManifest).not.toContain('demo.contract'); + expect(readme).toContain('# demo.contract'); + expect(readme).toContain('stellar contract build'); + expect(readme).toContain('stellar contract deploy'); + expect(readme).toContain('stellar contract invoke'); + expect(readme).toContain('--network testnet'); + expect(readme).not.toMatch(/S[A-Z0-9]{55}/); + expect(captured.stdoutText()).toContain( + 'Created demo.contract from smart-contract', + ); - const metadata = spawnSync( - 'cargo', - ['metadata', '--no-deps', '--format-version=1'], - { - cwd: projectRoot, - encoding: 'utf8', - shell: false, - }, - ); + const metadata = spawnSync( + 'cargo', + ['metadata', '--no-deps', '--format-version=1'], + { + cwd: projectRoot, + encoding: 'utf8', + shell: false, + }, + ); - expect(metadata.status, metadata.stderr).toBe(0); - expect(metadata.stdout).toContain('stellarforge-starter-contract'); - })); + expect(metadata.status, metadata.stderr).toBe(0); + expect(metadata.stdout).toContain('stellarforge-starter-contract'); + }), + 15_000, + ); });