diff --git a/.github/workflows/contract-reproducibility.yml b/.github/workflows/contract-reproducibility.yml new file mode 100644 index 00000000..af057878 --- /dev/null +++ b/.github/workflows/contract-reproducibility.yml @@ -0,0 +1,89 @@ +name: Contract Build Reproducibility + +on: + pull_request: + paths: + - 'contract/**' + - 'scripts/reproducible-contract-build.sh' + - '.github/workflows/contract-reproducibility.yml' + - 'docs/CONTRACT_BUILD_REPRODUCIBILITY.md' + push: + branches: [main] + paths: + - 'contract/**' + - 'scripts/reproducible-contract-build.sh' + - '.github/workflows/contract-reproducibility.yml' + workflow_dispatch: + +permissions: + contents: read + +jobs: + build: + name: Clean contract build (${{ matrix.build }}) + runs-on: ubuntu-24.04 + timeout-minutes: 25 + strategy: + fail-fast: false + matrix: + build: [first, second] + steps: + - uses: actions/checkout@v4 + - name: Install pinned Rust toolchain and target + shell: bash + run: | + mkdir -p reproducibility-output + toolchain=$(sed -n 's/^channel = "\([^"]*\)"$/\1/p' contract/rust-toolchain.toml) + test -n "$toolchain" + rustup toolchain install "$toolchain" --profile minimal --target wasm32v1-none 2>&1 | tee reproducibility-output/toolchain.log + - name: Build committed sources without shared caches + run: bash scripts/reproducible-contract-build.sh '${{ matrix.build }}' "$GITHUB_WORKSPACE/reproducibility-output" + - name: Upload build outputs and diagnostics + if: always() + uses: actions/upload-artifact@v4 + with: + name: contract-repro-${{ matrix.build }} + path: reproducibility-output/ + if-no-files-found: warn + retention-days: 14 + + compare: + name: Compare contract Wasm bytes + needs: build + if: always() + runs-on: ubuntu-24.04 + timeout-minutes: 5 + steps: + - name: Download build diagnostics + uses: actions/download-artifact@v4 + with: + pattern: contract-repro-* + path: builds + - name: Compare and report + if: always() + shell: bash + run: | + mkdir -p comparison + first=builds/contract-repro-first/hello_world.wasm + second=builds/contract-repro-second/hello_world.wasm + if [[ ! -f "$first" || ! -f "$second" ]]; then + echo 'Reproducibility could not be verified: one or both builds did not produce Wasm. Inspect the build logs.' | tee comparison/result.txt >> "$GITHUB_STEP_SUMMARY" + exit 1 + fi + sha256sum "$first" "$second" | tee comparison/SHA256SUMS >> "$GITHUB_STEP_SUMMARY" + if cmp -s "$first" "$second"; then + echo 'PASS: clean builds produced byte-identical release Wasm.' | tee comparison/result.txt >> "$GITHUB_STEP_SUMMARY" + else + echo 'FAIL: release Wasm differs. Inspect both environment.txt files, build logs, and byte-differences.txt.' | tee comparison/result.txt >> "$GITHUB_STEP_SUMMARY" + # cmp returns 1 for differences; retain only the first 100 offsets. + (cmp -l "$first" "$second" | head -n 100) > comparison/byte-differences.txt || true + exit 1 + fi + - name: Upload comparison diagnostics + if: always() + uses: actions/upload-artifact@v4 + with: + name: contract-repro-comparison + path: comparison/ + if-no-files-found: warn + retention-days: 14 diff --git a/.github/workflows/frontend-accessibility.yml b/.github/workflows/frontend-accessibility.yml new file mode 100644 index 00000000..84fe21bc --- /dev/null +++ b/.github/workflows/frontend-accessibility.yml @@ -0,0 +1,52 @@ +name: Frontend accessibility + +on: + pull_request: + paths: + - 'dashboard/**' + - '.github/workflows/frontend-accessibility.yml' + - 'docs/accessibility-automation.md' + push: + branches: [main, master] + paths: + - 'dashboard/**' + - '.github/workflows/frontend-accessibility.yml' + workflow_dispatch: + +permissions: + contents: read + +jobs: + accessibility: + runs-on: ubuntu-latest + timeout-minutes: 10 + defaults: + run: + working-directory: dashboard + shell: bash + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '22' + cache: npm + cache-dependency-path: dashboard/package-lock.json + - run: npm ci + - name: Check shared components and representative page flows + run: | + mkdir -p accessibility-results + set -o pipefail + npm run test:a11y -- --ci --verbose --json --outputFile=accessibility-results/jest.json 2>&1 | tee accessibility-results/jest.txt + - name: Preserve accessibility reports + if: always() + uses: actions/upload-artifact@v4 + with: + name: frontend-accessibility-results + path: dashboard/accessibility-results/ + if-no-files-found: warn + retention-days: 14 + - name: Record review scope + if: always() + run: | + echo '### Frontend accessibility' >> "$GITHUB_STEP_SUMMARY" + echo 'See the test log and frontend-accessibility-results artifact for rule IDs, affected elements, and remediation links. Automated DOM checks do not replace keyboard, screen-reader, zoom, contrast, or mobile browser review. See docs/accessibility-automation.md.' >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/listener-mutation.yml b/.github/workflows/listener-mutation.yml new file mode 100644 index 00000000..d7e7aa66 --- /dev/null +++ b/.github/workflows/listener-mutation.yml @@ -0,0 +1,47 @@ +name: Listener mutation testing + +on: + pull_request: + paths: + - 'listener/**' + - '.github/workflows/listener-mutation.yml' + push: + branches: [main] + paths: + - 'listener/**' + - '.github/workflows/listener-mutation.yml' + workflow_dispatch: + +permissions: + contents: read + +jobs: + critical-events: + runs-on: ubuntu-latest + timeout-minutes: 30 + defaults: + run: + working-directory: listener + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '22.22.0' + cache: npm + cache-dependency-path: listener/package-lock.json + - run: npm ci + - name: Mutate critical event processing + run: node scripts/mutation/run.cjs + - name: Publish mutation summary + if: always() + run: | + if [ -f reports/mutation/summary.md ]; then + cat reports/mutation/summary.md >> "$GITHUB_STEP_SUMMARY" + fi + - uses: actions/upload-artifact@v4 + if: always() + with: + name: critical-event-mutations + path: listener/reports/mutation/ + if-no-files-found: warn + retention-days: 14 diff --git a/.github/workflows/listener-node-compatibility.yml b/.github/workflows/listener-node-compatibility.yml new file mode 100644 index 00000000..bba85d1f --- /dev/null +++ b/.github/workflows/listener-node-compatibility.yml @@ -0,0 +1,63 @@ +name: Listener Node.js compatibility + +on: + pull_request: + paths: ['listener/**', '.github/workflows/listener-node-compatibility.yml'] + push: + branches: [main] + paths: ['listener/**', '.github/workflows/listener-node-compatibility.yml'] + workflow_dispatch: + +permissions: + contents: read + +jobs: + compatibility: + name: Listener / Node ${{ matrix.node }} + runs-on: ubuntu-24.04 + timeout-minutes: 20 + strategy: + fail-fast: false + matrix: + node: ['22', '24'] + defaults: + run: + working-directory: listener + shell: bash + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node }} + cache: npm + cache-dependency-path: listener/package-lock.json + - name: Record runtime and dependency installation + id: install + run: | + set -o pipefail + mkdir -p reports/node-compatibility + node --version | tee reports/node-compatibility/node.txt + npm --version | tee reports/node-compatibility/npm.txt + npm ci 2>&1 | tee reports/node-compatibility/install.log + - name: Build listener + id: build + run: | + set -o pipefail + npm run build 2>&1 | tee reports/node-compatibility/build.log + - name: Run notification processing tests + if: ${{ !cancelled() && steps.install.outcome == 'success' }} + run: | + set -o pipefail + npm test -- --ci --runInBand src/__tests__/smoke/notification-pipeline.smoke.test.ts src/services/event-normalization.test.ts src/services/notification-claim-lease.test.ts 2>&1 | tee reports/node-compatibility/tests.log + - name: Exercise emitted JavaScript and real runtime dependencies + if: ${{ !cancelled() && steps.build.outcome == 'success' }} + run: | + set -o pipefail + npm run test:runtime 2>&1 | tee reports/node-compatibility/runtime.log + - name: Upload runtime-specific diagnostics + if: always() + uses: actions/upload-artifact@v4 + with: + name: listener-node-${{ matrix.node }} + path: listener/reports/node-compatibility/ + if-no-files-found: warn diff --git a/.gitignore b/.gitignore index 4f77ebb1..df357f74 100644 --- a/.gitignore +++ b/.gitignore @@ -20,3 +20,9 @@ data/ test-data/ *.db *.db-journal + +# Isolated mutation sources and generated CI reports +.mutation-*/ +reports/mutation/ +reports/accessibility/ +reports/node-compatibility/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8cb40b3a..32d60270 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -101,7 +101,7 @@ Before you start, make sure you have: - **Rust** (stable) + WebAssembly target: `rustup target add wasm32-unknown-unknown` - **Stellar CLI**: `cargo install --locked stellar-cli --features opt` -- **Node.js 22** (used by both listener and dashboard in CI) +- **Node.js 22** (default for listener and dashboard); the listener also supports Node.js 24. See [listener Node.js compatibility](listener/docs/node-compatibility.md) for the CI matrix and runtime assumptions. - **Git** For a detailed walkthrough including platform-specific notes, see [`CONTRIBUTOR_SETUP.md`](CONTRIBUTOR_SETUP.md). diff --git a/README.md b/README.md index 8ff56202..7bbec810 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,11 @@ NotifyChain is an open-source event monitoring and notification system designed The project enables developers to build reactive decentralized applications without continuously polling the blockchain. +CI quality checks: [dashboard accessibility](docs/accessibility-automation.md), +[listener Node.js support](listener/docs/node-compatibility.md), +[critical event mutation testing](listener/docs/mutation-testing.md), and +[contract build reproducibility](docs/CONTRACT_BUILD_REPRODUCIBILITY.md). + ## Table of Contents 1. [Architecture Overview](#architecture-overview) diff --git a/contract/rust-toolchain.toml b/contract/rust-toolchain.toml new file mode 100644 index 00000000..9aa60e30 --- /dev/null +++ b/contract/rust-toolchain.toml @@ -0,0 +1,4 @@ +[toolchain] +channel = "1.90.0" +profile = "minimal" +targets = ["wasm32v1-none"] diff --git a/dashboard/.gitignore b/dashboard/.gitignore index a9927e74..c0a6d84e 100644 --- a/dashboard/.gitignore +++ b/dashboard/.gitignore @@ -2,3 +2,4 @@ node_modules dist storybook-static .DS_Store +accessibility-results/ diff --git a/dashboard/jest.accessibility.config.cjs b/dashboard/jest.accessibility.config.cjs new file mode 100644 index 00000000..6b3b5a21 --- /dev/null +++ b/dashboard/jest.accessibility.config.cjs @@ -0,0 +1,9 @@ +const base = require('./jest.config.cjs'); + +module.exports = { + ...base, + testMatch: [ + '/src/__tests__/accessibility.test.tsx', + '/src/__tests__/accessibility-flows.test.tsx', + ], +}; diff --git a/dashboard/jest.setup.cjs b/dashboard/jest.setup.cjs index edc7f6fb..0a18b4aa 100644 --- a/dashboard/jest.setup.cjs +++ b/dashboard/jest.setup.cjs @@ -1,7 +1,5 @@ require('@testing-library/jest-dom'); -const { jest } = require('@types/jest'); - const originalFetch = global.fetch; const originalSetTimeout = global.setTimeout; const originalSetInterval = global.setInterval; @@ -136,7 +134,7 @@ function failureInjectionFetch(url, init) { return originalFetch(url, init); } -fobject.defineProperty(HTMLElement.prototype, 'clientHeight', { +Object.defineProperty(HTMLElement.prototype, 'clientHeight', { configurable: true, get() { return 600; diff --git a/dashboard/package.json b/dashboard/package.json index 261765ef..92a5436e 100644 --- a/dashboard/package.json +++ b/dashboard/package.json @@ -13,6 +13,7 @@ "lint": "node ./node_modules/eslint/bin/eslint.js \"src/**/*.{ts,tsx}\" --max-warnings=0", "format:check": "node ./node_modules/prettier/bin/prettier.cjs --check \"src/**/*.{ts,tsx}\" --config ../.prettierrc", "test": "node ./node_modules/jest/bin/jest.js", + "test:a11y": "node ./node_modules/jest/bin/jest.js --config jest.accessibility.config.cjs --runInBand", "test:wallet": "node ./node_modules/jest/bin/jest.js src/__tests__/wallet-integration.test.tsx", "benchmark": "node ./node_modules/jest/bin/jest.js src/benchmark", "storybook": "node ./node_modules/storybook/bin/index.cjs dev -p 6006", diff --git a/dashboard/src/__tests__/accessibility-flows.test.tsx b/dashboard/src/__tests__/accessibility-flows.test.tsx new file mode 100644 index 00000000..09c9c249 --- /dev/null +++ b/dashboard/src/__tests__/accessibility-flows.test.tsx @@ -0,0 +1,154 @@ +import { useState } from 'react'; +import { render, screen, waitFor, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { axe, toHaveNoViolations } from 'jest-axe'; +import { ExportHistoryPage } from '../pages/ExportHistoryPage'; +import { NotificationSearchPage } from '../pages/NotificationSearchPage'; +import { Modal } from '../components/Modal'; +import { searchNotifications } from '../services/eventsApi'; +import type { NotificationSearchResponse } from '../services/eventsApi'; +import { downloadBlob } from '../utils/notificationExport'; + +expect.extend(toHaveNoViolations); + +// Mock external boundaries, preserving actual page and shared-component markup. +jest.mock('../services/wallet', () => ({ + connectWallet: jest.fn(), + disconnectWallet: jest.fn(), +})); +jest.mock('../services/eventsApi', () => ({ + searchNotifications: jest.fn(), + isListenerApiTimeoutError: () => false, +})); +jest.mock('../utils/notificationExport', () => ({ + ...jest.requireActual('../utils/notificationExport'), + downloadBlob: jest.fn(), +})); + +const mockedSearch = jest.mocked(searchNotifications); +const response: NotificationSearchResponse = { + results: [{ + id: 1, + source: 'scheduled', + status: 'PENDING', + notificationType: 'email', + targetRecipient: 'reader@example.com', + eventId: 'event-accessibility', + txHash: 'transaction-accessibility', + contractAddress: 'contract-accessibility', + createdAt: '2026-01-15T12:00:00.000Z', + payload: null, + failureReason: null, + }], + total: 21, + limit: 20, + offset: 0, + itemCount: 1, + totalPages: 2, +}; + +beforeEach(() => { + // axe requires real timers; debounce and loading transitions run normally. + jest.useRealTimers(); + mockedSearch.mockReset(); + mockedSearch.mockResolvedValue(response); + jest.mocked(downloadBlob).mockClear(); +}); + +test('export history: loaded table, pagination, filtering and empty-state recovery', async () => { + const user = userEvent.setup(); + const { container } = render(); + await screen.findByRole('table', {}, { timeout: 2000 }); + expect(await axe(container)).toHaveNoViolations(); + + await user.click(screen.getByRole('button', { name: 'Next', exact: true })); + expect(screen.getByText('Page 2 of 3')).toBeInTheDocument(); + expect(await axe(container)).toHaveNoViolations(); + + await user.selectOptions(screen.getByLabelText('Status'), 'failed'); + expect(screen.getByText('User activity digest export')).toBeInTheDocument(); + expect(await axe(container)).toHaveNoViolations(); + + await user.type(screen.getByLabelText('Search Exports'), 'no-matching-export'); + expect(screen.getByRole('heading', { name: 'No export records found' })).toBeInTheDocument(); + expect(await axe(container)).toHaveNoViolations(); + + await user.click(screen.getByRole('button', { name: 'Clear filters' })); + expect(screen.getByRole('table')).toBeInTheDocument(); + expect(screen.getByLabelText('Search Exports')).toHaveValue(''); +}); + +test('notification search: initial state, filtered results, pagination, empty and error feedback', async () => { + const user = userEvent.setup(); + const { container } = render(); + expect(screen.getByRole('heading', { name: 'Start searching' })).toBeInTheDocument(); + expect(await axe(container)).toHaveNoViolations(); + + await user.selectOptions(screen.getByLabelText('Filter by notification type'), 'email'); + await screen.findByText('reader@example.com'); + expect(mockedSearch).toHaveBeenLastCalledWith(expect.any(String), expect.objectContaining({ type: 'email' })); + expect(await axe(container)).toHaveNoViolations(); + + await user.click(screen.getByRole('button', { name: 'Next page' })); + await waitFor(() => expect(mockedSearch).toHaveBeenLastCalledWith( + expect.any(String), expect.objectContaining({ offset: 20 }), + )); + await screen.findByText('reader@example.com'); + expect(await axe(container)).toHaveNoViolations(); + + mockedSearch.mockResolvedValue({ ...response, results: [], total: 0, totalPages: 0, itemCount: 0 }); + await user.selectOptions(screen.getByLabelText('Filter by delivery status'), 'FAILED'); + await screen.findByRole('heading', { name: 'No notifications match your search' }); + expect(await axe(container)).toHaveNoViolations(); + + mockedSearch.mockRejectedValue(new Error('Search service unavailable')); + await user.selectOptions(screen.getByLabelText('Filter by delivery status'), 'PENDING'); + expect(await screen.findByRole('alert')).toHaveTextContent('Search service unavailable'); + expect(await axe(container)).toHaveNoViolations(); + + await user.click(screen.getByRole('button', { name: 'Clear all filters' })); + expect(screen.getByRole('heading', { name: 'Start searching' })).toBeInTheDocument(); +}); + +test('dialog: keyboard opening, focus containment, Escape and focus restoration', async () => { + function DialogFlow() { + const [open, setOpen] = useState(false); + return
+

Notification details

+ + setOpen(false)} title="Delivery details"> + + +
; + } + const user = userEvent.setup(); + const { container } = render(); + await user.tab(); + expect(screen.getByRole('button', { name: 'Open details' })).toHaveFocus(); + await user.keyboard('{Enter}'); + const dialog = screen.getByRole('dialog', { name: 'Delivery details' }); + expect(within(dialog).getByRole('button', { name: 'Close modal' })).toHaveFocus(); + expect(await axe(container)).toHaveNoViolations(); + await user.tab({ shift: true }); + expect(within(dialog).getByRole('button', { name: 'Retry delivery' })).toHaveFocus(); + await user.tab(); + expect(within(dialog).getByRole('button', { name: 'Close modal' })).toHaveFocus(); + await user.keyboard('{Escape}'); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Open details' })).toHaveFocus(); +}); + +test('notification export exposes completion feedback and keeps an accessible format selector', async () => { + mockedSearch.mockResolvedValue({ ...response, total: 1, totalPages: 1 }); + const user = userEvent.setup(); + const { container } = render(); + const exportButton = screen.getByRole('button', { name: 'Export filtered notifications' }); + expect(exportButton).toBeDisabled(); + await user.selectOptions(screen.getByLabelText('Filter by notification type'), 'email'); + await screen.findByText('reader@example.com'); + await user.selectOptions(screen.getByLabelText('Export format'), 'csv'); + await user.click(exportButton); + expect(await screen.findByRole('status')).toHaveTextContent('Exported 1 notification as CSV.'); + expect(downloadBlob).toHaveBeenCalledWith(expect.any(Blob), expect.stringMatching(/\.csv$/)); + expect(await axe(container)).toHaveNoViolations(); +}); diff --git a/dashboard/src/pages/NotificationSearchPage.tsx b/dashboard/src/pages/NotificationSearchPage.tsx index 1ca38f27..ecc90050 100644 --- a/dashboard/src/pages/NotificationSearchPage.tsx +++ b/dashboard/src/pages/NotificationSearchPage.tsx @@ -723,7 +723,7 @@ export function NotificationSearchPage() {
{visiblePages.map( @@ -924,4 +924,4 @@ function NotificationResultCard({ ); -} \ No newline at end of file +} diff --git a/docs/CONTRACT_BUILD_REPRODUCIBILITY.md b/docs/CONTRACT_BUILD_REPRODUCIBILITY.md new file mode 100644 index 00000000..b91fc213 --- /dev/null +++ b/docs/CONTRACT_BUILD_REPRODUCIBILITY.md @@ -0,0 +1,80 @@ +# Contract build reproducibility + +The `Contract Build Reproducibility` workflow checks that the committed +`hello-world` contract produces **byte-identical release Wasm** in two clean +Ubuntu 24.04 jobs. A missing output, failed build, or byte difference fails the +check. This is an explicit reproducibility check, not a contract test suite. + +## Fixed inputs and normalization + +- `contract/rust-toolchain.toml` pins Rust/Cargo to **1.90.0** with the minimal + profile and the `wasm32v1-none` target. This is newer than the Rust 1.88 + requirement of dependencies such as `time` 0.3.46 and `serde_with` 3.16.1 + currently present in `contract/Cargo.lock`. +- Both builds use the same Git commit, package, target, and release profile. + `cargo build --locked` rejects lockfile changes. The lockfile checksum, + compiler version (including commit and host), Cargo version, kernel, paths, + and flags are saved in `environment.txt`. +- Each job extracts `git archive HEAD:contract` into a new temporary source + directory. The second build uses a different directory depth. Uncommitted + local edits are deliberately excluded. Existing target outputs are removed. +- Fresh Cargo homes and target directories prevent artifact-cache reuse. + Incremental compilation is disabled. Dependencies are fetched separately, + verified against lockfile checksums, and compiled in each job. +- `--remap-path-prefix` maps source, dependency, target, and (when configured) + Rustup directories to stable virtual paths. Release debug information is + disabled and symbols are stripped by the existing Cargo release profile. + `SOURCE_DATE_EPOCH` is the commit timestamp; locale and timezone are fixed. + These settings do not guarantee arbitrary future build scripts respect them. + +## Outputs and failure diagnosis + +Each build uploads the raw `hello_world.wasm`, its SHA-256 checksum, compiler +log, toolchain installation log, and environment metadata for 14 days. Uploads +are attempted even after a failure. The comparison job reports hashes in the +GitHub job summary and uploads a result and, on mismatch, the first 100 +different byte offsets (offsets are decimal; byte values are octal). Missing +outputs fail explicitly; inspect the individual build artifacts for the cause. + +To investigate a mismatch, compare compiler and lockfile metadata first, then +inspect the two saved raw Wasm binaries. Do not strip or rewrite an unexpected +section just to make the check pass: document the source of nondeterminism and +make any proposed normalization a reviewed policy change. + +## Scope and excluded artifacts + +Only `target/wasm32v1-none/release/hello_world.wasm` is the comparison subject. +There are no allowed byte differences in that file, including custom sections. +The following outputs are deliberately excluded because they can contain host +paths, timings, metadata, or packaging timestamps: + +- Cargo fingerprints, dependency files (`.d`), intermediate libraries, build + scripts and their outputs, and the rest of `target/`. +- Compiler/installation logs and `environment.txt` (source paths intentionally + differ), checksum manifests containing output paths, and CI artifact ZIPs. +- Debug/test binaries, `release-with-logs` builds, and any Wasm transformed by + Stellar CLI, `wasm-opt`, signing, deployment, or external packaging tools. + +The check does not yet claim reproducibility across operating systems, +architectures, compiler versions, or different Ubuntu runner image revisions. +`ubuntu-24.04` receives updates; it is not an immutable container image. Each +run records the actual compiler and host environment. Toolchain downloads and +registry access are still required. New contracts must be added to both the +build script and comparison policy before they are covered. + +## Reproducing the CI procedure locally + +On Linux with Git, Bash, GNU tar/coreutils, rustup, and the pinned Rust toolchain +and target already installed, run from the repository root: + +```sh +bash scripts/reproducible-contract-build.sh first /tmp/notify-first +bash scripts/reproducible-contract-build.sh second /tmp/notify-second +cmp /tmp/notify-first/hello_world.wasm /tmp/notify-second/hello_world.wasm +``` + +Use separate fresh machines or containers for the same environment isolation as +CI. The script builds **committed** contract sources, so commit intended contract +changes before using this procedure. Bump the toolchain pin deliberately with +dependency upgrades and review the resulting binary differences. This workflow +was added without executing builds locally; CI must establish its first result. diff --git a/docs/accessibility-automation.md b/docs/accessibility-automation.md new file mode 100644 index 00000000..41ae70be --- /dev/null +++ b/docs/accessibility-automation.md @@ -0,0 +1,51 @@ +# Dashboard accessibility automation + +The `Frontend accessibility` workflow runs on dashboard pull requests, pushes to +`main`/`master`, and manual dispatch. It uses the dashboard lockfile and existing +Jest, Testing Library, user-event, and jest-axe dependencies. No browser download +or live listener/wallet service is required. + +From `dashboard`, run `npm ci`, then `npm run test:a11y`. The dedicated config +inherits the dashboard transforms and module mappings, and selects only the +shared-component accessibility suite and the representative interaction suite. +Real timers are required during axe scans. + +## Coverage + +- Shared components: modal, mobile navigation drawer, theme toggle, toast viewport, + and dashboard layout landmarks. +- Export history: loaded table, next page, failed-status filtering, search with no + matches, and clearing filters to recover. +- Notification search: initial guidance, type filter and result cards, pagination, + empty results, service failure alert, and clearing filters. +- Notification export: disabled state before filters, format selection, download + boundary invocation, and completion status feedback. +- Keyboard dialog flow: opening with Enter, initial focus, forward/backward focus + containment, Escape dismissal, and restoration to the trigger. + +Page tests render actual page/component markup; notification API and wallet service +boundaries are mocked. They do not exercise a deployed site, live data, or wallet +extensions. The pagination page-number wrapper uses a named group because its +children are buttons rather than list items. + +## Reading failures + +The job fails on test errors or axe violations. The verbose console output and +`frontend-accessibility-results` artifact contain `jest.txt` and `jest.json`, +including matcher diagnostics with axe rule IDs, affected selectors/markup, and +help links. Artifacts are uploaded even after test failure and retained for 14 +days; dependency-install failures may occur before reports exist. Fix the +underlying markup and rerun the focused command; do not suppress a rule just to +make CI pass. New major pages and interactive states should extend this suite. + +## Manual review remains required + +JSDOM cannot validate visual layout, reliable color contrast, actual browser focus +behavior, assistive-technology announcements, or usability. A passing job is not +WCAG conformance. Before release, manually check keyboard-only navigation and +visible focus across desktop/mobile menus and dialogs; screen-reader names, +headings, table navigation, loading/error announcements; light/dark contrast; +200%/400% zoom and reflow; and representative mobile browser interactions. +Record the browsers, assistive technologies, findings, and follow-up issues in the +PR or release review. Pages outside the documented subset require manual review +and future automation rather than an assumption of coverage. diff --git a/listener/docs/mutation-testing.md b/listener/docs/mutation-testing.md new file mode 100644 index 00000000..9c0ae80b --- /dev/null +++ b/listener/docs/mutation-testing.md @@ -0,0 +1,43 @@ +# Critical event mutation testing + +From `listener`, install the committed dependencies with `npm ci`, then run +`node scripts/mutation/run.cjs` (Node 22.22.0 is the CI reference runtime). +No additional mutation dependencies are needed. The dedicated runner uses the +locked Jest 30 and SWC transformer directly, avoiding a Jest-version-specific +mutation adapter and the repository's legacy ts-jest setup. + +The checked-in defect catalog in `scripts/mutation/mutants.cjs` covers: + +| Production module | Behaviors mutated | +| --- | --- | +| `services/event-normalization.ts` | Contract whitespace removal, transaction hash preservation, payload version preservation | +| `utils/event-utils.ts` | Empty and wildcard allow-lists, membership decisions, processing versus skipping | +| `services/event-deduplication-service.ts` | Claim acquisition, terminal completion delivery flags, missing-claim insertion, equal-ledger reorg boundary | + +The normalization and event-utils unit suites run alongside focused state +transition tests in `src/mutation/critical-events.test.ts`. State tests use a +database double to exercise public service decisions; they do not validate SQL +execution, transaction isolation, or real RPC encoding. Existing integration +tests remain necessary for those properties. + +Every invocation first validates that each mutation anchor occurs exactly once +and runs an unmodified baseline. It then injects each defect independently into +a disposable source copy, starts a fresh Jest process with caching disabled, +and restores the copy before the next defect. The working source is never +rewritten. The catalog, dependency lock, UTC environment, serial test execution, +and recorded Node version make the selected experiment reproducible. + +`reports/mutation/results.json` contains each mutation and outcome; +`summary.md` lists survivors in the CI job summary. Per-mutation Jest JSON and +console logs are uploaded as a workflow artifact even when the job fails. +Any survivor fails CI. Timeouts, import/syntax errors, empty test runs, failed +baseline runs, and stale anchors fail CI as infrastructure errors; they never +count as killed mutations. The score is killed / (killed + survived), excluding +infrastructure errors. Inspect the assertion failures before adding assertions +for survivors. Document any equivalent mutation explicitly rather than silently +crediting it as a kill. + +This is a curated defect model, not exhaustive AST mutation coverage. Add new +reviewed catalog entries when new critical behavior appears. Its score only +describes these selected defects and must not be presented as total listener +coverage. Reports and disposable `.mutation-*` folders are generated artifacts. diff --git a/listener/docs/node-compatibility.md b/listener/docs/node-compatibility.md new file mode 100644 index 00000000..eb4268d4 --- /dev/null +++ b/listener/docs/node-compatibility.md @@ -0,0 +1,40 @@ +# Listener Node.js support + +The listener supports Node.js **22.x and 24.x** (`^22.0.0 || ^24.0.0` in +`package.json`). Node 22 remains the Docker and default CI runtime. Other majors, +including odd-numbered releases, are outside the tested support contract. Use +the latest patch in a supported major; the matrix intentionally tracks those +patches and records the resolved Node and npm versions in each artifact. + +`listener-node-compatibility.yml` independently installs the frozen lockfile, +builds the listener, runs the notification pipeline/normalization/lease tests, +and exercises emitted lease JavaScript plus real Express, SQLite native bindings, +and Node fetch on each major. Matrix jobs do not cancel each other on failure. +The runtime smoke test uses loopback HTTP and in-memory SQLite, requiring no +RPC credentials, persisted database, or external notification providers. It is +a runtime compatibility check for those dependencies and modules, not a full +production startup or live Stellar integration test. Jest uses SDK doubles; +passing the unit suite alone does not establish compatibility with a live SDK. + +To reproduce after installing dependencies in `listener`, run on each major: + +```sh +npm ci +npm run build +npm test -- --ci --runInBand src/__tests__/smoke/notification-pipeline.smoke.test.ts src/services/event-normalization.test.ts src/services/notification-claim-lease.test.ts +npm run test:runtime +``` + +Runtime code uses built-in fetch/AbortSignal and a native SQLite addon; do not +assume that transpiling TypeScript makes older Node versions compatible. Native +addons must be installed separately under each runtime; do not reuse a +`node_modules` directory across majors. CI uses Linux; Windows/macOS native addon +packaging is not covered by this matrix. The TypeScript lock entry is aligned +with the existing `~5.7.2` manifest range (5.7.3); its tarball integrity comes +from the npm registry metadata, without changing the dependency range. + +Build errors fail the check even if a focused unit suite passes. Existing source +or dependency failures must be resolved before claiming a green compatibility +result; this workflow does not suppress diagnostics or substitute transpilation +for a successful full build. Support-policy changes must update the manifest, +lockfile root metadata, this document and the CI matrix together. diff --git a/listener/package-lock.json b/listener/package-lock.json index 0b3e1ee0..d619b2be 100644 --- a/listener/package-lock.json +++ b/listener/package-lock.json @@ -8,6 +8,9 @@ "name": "listener", "version": "1.0.0", "license": "ISC", + "engines": { + "node": "^22.0.0 || ^24.0.0" + }, "dependencies": { "cors": "^2.8.6", "dotenv": "^17.4.2", @@ -37,7 +40,7 @@ "prettier": "3.3.3", "ts-jest": "^29.4.12", "ts-node": "^10.9.2", - "typescript": "~5.4.5" + "typescript": "~5.7.2" } }, "node_modules/@babel/code-frame": { @@ -12117,9 +12120,9 @@ } }, "node_modules/typescript": { - "version": "5.4.5", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.5.tgz", - "integrity": "sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ==", + "version": "5.7.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.3.tgz", + "integrity": "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==", "dev": true, "license": "Apache-2.0", "bin": { diff --git a/listener/package.json b/listener/package.json index fd82918f..44c5c3a9 100644 --- a/listener/package.json +++ b/listener/package.json @@ -2,6 +2,9 @@ "name": "listener", "version": "1.0.0", "main": "dist/index.js", + "engines": { + "node": "^22.0.0 || ^24.0.0" + }, "scripts": { "dev": "ts-node src/index.ts", "build": "node ./node_modules/typescript/bin/tsc", @@ -12,6 +15,8 @@ "lint": "node ./node_modules/typescript/bin/tsc --noEmit", "format:check": "node ./node_modules/prettier/bin/prettier.cjs --check \"src/**/*.ts\" --config ../.prettierrc", "test": "node ./node_modules/jest/bin/jest.js", + "test:runtime": "node --test scripts/runtime-compatibility.test.cjs", + "test:mutation": "node scripts/mutation/run.cjs", "check:event-schema": "ts-node src/schema/compatibility-check.ts", "test:stress": "node ./node_modules/jest/bin/jest.js src/__tests__/stress.test.ts --runInBand --detectOpenHandles", "test:load": "node ./node_modules/jest/bin/jest.js src/__tests__/load-test-runner.test.ts --runInBand", diff --git a/listener/scripts/mutation/mutants.cjs b/listener/scripts/mutation/mutants.cjs new file mode 100644 index 00000000..abfbd093 --- /dev/null +++ b/listener/scripts/mutation/mutants.cjs @@ -0,0 +1,27 @@ +// Deliberately small, reviewed defect model. Each anchor must occur exactly once. +module.exports = [ + ['normalization-contract-trim', 'src/services/event-normalization.ts', + 'contractAddress: context.contractAddress.trim(),', 'contractAddress: context.contractAddress,'], + ['normalization-hash-loss', 'src/services/event-normalization.ts', + 'const txHash = typeof raw.txHash', 'const txHash = false && typeof raw.txHash'], + ['normalization-version-loss', 'src/services/event-normalization.ts', + 'payloadVersion,', 'payloadVersion: null,'], + ['filter-empty-denied', 'src/utils/event-utils.ts', + 'allowedEvents.length === 0', 'allowedEvents.length < 0'], + ['filter-wildcard-denied', 'src/utils/event-utils.ts', + "allowedEvents.includes('*')", "allowedEvents.includes('__never__')"], + ['filter-allowlist-inverted', 'src/utils/event-utils.ts', + 'return allowedEvents.includes(eventName);', 'return !allowedEvents.includes(eventName);'], + ['filter-classification-inverted', 'src/utils/event-utils.ts', + 'if (!matchesEventFilter(eventName, allowedEvents)) {', + 'if (matchesEventFilter(eventName, allowedEvents)) {'], + ['claim-result-inverted', 'src/services/event-deduplication-service.ts', + 'if (result.changes === 1) {', 'if (result.changes !== 1) {'], + ['completion-fallback-inverted', 'src/services/event-deduplication-service.ts', + 'if (result.changes === 0) {', 'if (result.changes !== 0) {'], + ['completion-delivery-inverted', 'src/services/event-deduplication-service.ts', + '[status, notificationSent ? 1 : 0, errorReason ?? null, fingerprint]', + '[status, notificationSent ? 0 : 1, errorReason ?? null, fingerprint]'], + ['reorg-ledger-boundary', 'src/services/event-deduplication-service.ts', + 'newLedgerNumber < lastCursor.ledgerNumber', 'newLedgerNumber <= lastCursor.ledgerNumber'], +].map(([id, file, before, after]) => ({ id, file, before, after })); diff --git a/listener/scripts/mutation/run.cjs b/listener/scripts/mutation/run.cjs new file mode 100644 index 00000000..81a950ea --- /dev/null +++ b/listener/scripts/mutation/run.cjs @@ -0,0 +1,97 @@ +const fs = require('node:fs'); +const path = require('node:path'); +const { spawnSync } = require('node:child_process'); +const mutants = require('./mutants.cjs'); + +const root = path.resolve(__dirname, '../..'); +const reportDir = path.join(root, 'reports/mutation'); +// Keep the sandbox below listener so node_modules resolves without copying it. +const sandbox = fs.mkdtempSync(path.join(root, '.mutation-')); +const results = []; +let baseline; + +function runSuite(label) { + const outputFile = path.join(reportDir, `${label}.jest.json`); + fs.rmSync(outputFile, { force: true }); + const result = spawnSync(process.execPath, [ + path.join(root, 'node_modules/jest/bin/jest.js'), + '--config', path.join(sandbox, 'jest.config.cjs'), + '--runInBand', '--no-cache', '--json', '--outputFile', outputFile, + ], { + cwd: sandbox, + env: { ...process.env, CI: 'true', TZ: 'UTC', FORCE_COLOR: '0' }, + encoding: 'utf8', timeout: 120_000, maxBuffer: 10 * 1024 * 1024, + }); + fs.writeFileSync(path.join(reportDir, `${label}.log`), + `${result.stdout || ''}\n${result.stderr || ''}\n${result.error?.message || ''}`); + if (result.error || result.signal || !fs.existsSync(outputFile)) return 'error'; + const report = JSON.parse(fs.readFileSync(outputFile, 'utf8')); + // A syntax/import failure must never be credited as a detected defect. + if (report.numRuntimeErrorTestSuites > 0 || report.numTotalTests === 0) return 'error'; + if (result.status === 0 && report.success) return 'survived'; + if (report.numFailedTests > 0) return 'killed'; + return 'error'; +} + +try { + fs.mkdirSync(reportDir, { recursive: true }); + fs.cpSync(path.join(root, 'src'), path.join(sandbox, 'src'), { recursive: true }); + const config = { + rootDir: sandbox, + testEnvironment: 'node', + testMatch: ['/src/services/event-normalization.test.ts', + '/src/utils/event-utils.test.ts', '/src/mutation/critical-events.test.ts'], + transform: { '^.+\\.tsx?$': [require.resolve('@swc/jest', { paths: [root] }), { + jsc: { parser: { syntax: 'typescript' }, target: 'es2020' }, + module: { type: 'commonjs' }, + }] }, + moduleNameMapper: { + '^@stellar/stellar-sdk$': '/src/__mocks__/@stellar/stellar-sdk.ts', + '^uuid$': '/src/__mocks__/uuid.js', + '^node-cache$': '/src/__mocks__/node-cache.ts', + }, + }; + fs.writeFileSync(path.join(sandbox, 'jest.config.cjs'), `module.exports = ${JSON.stringify(config)};`); + // Fail closed when source moves: do not silently run zero or stale mutations. + for (const mutant of mutants) { + const source = fs.readFileSync(path.join(root, mutant.file), 'utf8'); + if (source.split(mutant.before).length !== 2) { + throw new Error(`Mutation anchor must occur once: ${mutant.id} (${mutant.file})`); + } + } + baseline = runSuite('baseline'); + if (baseline !== 'survived') throw new Error('Baseline suite failed; inspect baseline.log before interpreting mutations.'); + for (const mutant of mutants) { + const target = path.join(sandbox, mutant.file); + const original = fs.readFileSync(target, 'utf8'); + try { + fs.writeFileSync(target, original.replace(mutant.before, mutant.after)); + const status = runSuite(mutant.id); + results.push({ ...mutant, status }); + console.log(`${status}: ${mutant.id}`); + } finally { + fs.writeFileSync(target, original); + } + } + if (results.some(({ status }) => status !== 'killed')) process.exitCode = 1; +} catch (error) { + console.error(error.message); + results.push({ id: 'harness', status: 'error', message: error.message }); + process.exitCode = 1; +} finally { + fs.mkdirSync(reportDir, { recursive: true }); + const killed = results.filter(({ status }) => status === 'killed').length; + const surviving = results.filter(({ status }) => status === 'survived').length; + const score = killed + surviving ? 100 * killed / (killed + surviving) : null; + const report = { node: process.version, baseline, mutationScore: score, results }; + fs.writeFileSync(path.join(reportDir, 'results.json'), JSON.stringify(report, null, 2)); + fs.writeFileSync(path.join(reportDir, 'summary.md'), [ + '# Critical event mutation results', '', + `Node: ${process.version}. Score: ${score === null ? 'unavailable' : score.toFixed(1) + '%'}.`, '', + '| Mutation | Status |', '| --- | --- |', + ...results.map(({ id, status }) => `| ${id} | ${status} |`), '', + 'Survivors require a stronger behavioral assertion or a documented equivalent-mutation explanation.', + 'Errors are infrastructure failures and are excluded from the score.', '', + ].join('\n')); + fs.rmSync(sandbox, { recursive: true, force: true }); +} diff --git a/listener/scripts/runtime-compatibility.test.cjs b/listener/scripts/runtime-compatibility.test.cjs new file mode 100644 index 00000000..29401943 --- /dev/null +++ b/listener/scripts/runtime-compatibility.test.cjs @@ -0,0 +1,72 @@ +// Exercise emitted JavaScript and real dependencies; Jest's module doubles do +// not validate the native SQLite binding or Node's runtime APIs. +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const express = require('express'); +const sqlite3 = require('sqlite3'); +const { open } = require('sqlite'); +const { + NotificationClaimLease, + calculateLeaseRenewIntervalMs, +} = require('../dist/services/notification-claim-lease.js'); + +test('compiled listener lease renews and stops after ownership is lost', async () => { + let owned = true; + const calls = []; + const lease = new NotificationClaimLease({ + notificationId: 7, + processorId: 'compat-worker', + lockTimeoutMs: 30000, + renew: async (...args) => { + calls.push(args); + return owned; + }, + }); + assert.equal(calculateLeaseRenewIntervalMs(30000), 10000); + try { + assert.equal(await lease.heartbeatNow(), true); + owned = false; + assert.equal(await lease.heartbeatNow(), false); + assert.equal(lease.isLeaseLost(), true); + assert.equal(await lease.heartbeatNow(), false); + assert.deepEqual(calls, [ + [7, 'compat-worker', 30000], + [7, 'compat-worker', 30000], + ]); + } finally { + await lease.stop(); + } +}); + +test('native SQLite, Express and fetch work together on the selected Node version', async () => { + const db = await open({ filename: ':memory:', driver: sqlite3.Database }); + let server; + try { + await db.exec('CREATE TABLE notifications (id INTEGER PRIMARY KEY, status TEXT NOT NULL)'); + await db.run('INSERT INTO notifications (status) VALUES (?)', 'PENDING'); + const app = express(); + app.get('/notifications/:id', async (req, res, next) => { + try { + const row = await db.get('SELECT * FROM notifications WHERE id = ?', req.params.id); + res.json(row); + } catch (error) { + next(error); + } + }); + server = await new Promise((resolve, reject) => { + const instance = app.listen(0, '127.0.0.1', () => resolve(instance)); + instance.once('error', reject); + }); + const response = await fetch(`http://127.0.0.1:${server.address().port}/notifications/1`, { + signal: AbortSignal.timeout(5000), + }); + assert.equal(response.status, 200); + assert.deepEqual(await response.json(), { id: 1, status: 'PENDING' }); + } finally { + if (server) { + server.closeAllConnections(); + await new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve())); + } + await db.close(); + } +}); diff --git a/listener/src/mutation/critical-events.test.ts b/listener/src/mutation/critical-events.test.ts new file mode 100644 index 00000000..a258ac37 --- /dev/null +++ b/listener/src/mutation/critical-events.test.ts @@ -0,0 +1,99 @@ +import { EventDeduplicationService } from '../services/event-deduplication-service'; +import { Database } from '../database/database'; +import { matchesEventFilter } from '../utils/event-utils'; +import { normalizeSorobanEvent } from '../services/event-normalization'; +import { xdr } from '@stellar/stellar-sdk'; + +jest.mock('../utils/logger', () => ({ + __esModule: true, + default: { info: jest.fn(), warn: jest.fn(), error: jest.fn() }, +})); +jest.mock('../database/database', () => ({ Database: jest.fn() })); + +describe('critical normalization and filtering boundaries', () => { + it('preserves transaction identity while normalizing contract whitespace', () => { + const result = normalizeSorobanEvent( + { + id: 'event', + type: 'contract', + ledger: 0, + txHash: 'transaction', + topic: [], + value: xdr.ScVal.scvU32(1), + }, + { contractAddress: ' contract ' }, + ); + expect(result).toMatchObject({ + ok: true, + event: { + eventId: 'event', + contractAddress: 'contract', + ledger: 0, + txHash: 'transaction', + payloadVersion: 1, + }, + }); + }); + + it('allows named and unnamed events with an empty allow-list', () => { + expect(matchesEventFilter('NotificationScheduled', [])).toBe(true); + expect(matchesEventFilter(null, [])).toBe(true); + }); +}); + +describe('critical event claim and completion transitions', () => { + const run = jest.fn<(...args: unknown[]) => Promise<{ changes: number }>>(); + const all = jest.fn<(...args: unknown[]) => Promise>(); + let service: EventDeduplicationService; + + beforeEach(() => { + jest.resetAllMocks(); + service = new EventDeduplicationService({ run, all } as unknown as Database); + }); + + it.each([0, 1])('only acquires a claim when exactly one row changes (%i)', async (changes) => { + run.mockResolvedValue({ changes }); + await expect(service.claimEvent('event', 'contract', 20, 'tx', 'contract')).resolves.toEqual({ + claimed: changes === 1, + alreadySeen: changes !== 1, + }); + }); + + it.each([true, false])('persists delivery outcome %s with terminal status', async (sent) => { + run.mockResolvedValue({ changes: 1 }); + await service.completeEvent( + 'event', 'contract', 20, 'tx', 'contract', sent, 'ERROR', 'delivery failed', + ); + expect(run).toHaveBeenCalledTimes(1); + expect(run.mock.calls[0][1]).toEqual([ + 'ERROR', sent ? 1 : 0, 'delivery failed', expect.any(String), + ]); + }); + + it('creates a terminal record when a fail-open claim left no row', async () => { + run.mockResolvedValueOnce({ changes: 0 }).mockResolvedValueOnce({ changes: 1 }); + await service.completeEvent('event', 'contract', 20, undefined, 'contract', true, 'PROCESSED'); + expect(run).toHaveBeenCalledTimes(2); + expect(run.mock.calls[1][0]).toContain('INSERT INTO processed_events'); + expect(run.mock.calls[1][1]).toEqual([ + 'event', 'contract', expect.any(String), 20, null, 'contract', 1, 'PROCESSED', null, + ]); + }); + + it.each([ + { ledger: 19, expected: true }, + { ledger: 20, expected: false }, + { ledger: 21, expected: false }, + ])('detects a reorg only when the ledger decreases ($ledger)', async ({ ledger, expected }) => { + all.mockResolvedValue([ + { + contract_address: 'contract', + cursor: 'cursor', + ledger_number: 20, + reorg_detected: 0, + reorg_detection_count: 0, + }, + ]); + await expect(service.detectReorg('contract', ledger)).resolves.toBe(expected); + }); +}); diff --git a/scripts/reproducible-contract-build.sh b/scripts/reproducible-contract-build.sh new file mode 100644 index 00000000..0aa17433 --- /dev/null +++ b/scripts/reproducible-contract-build.sh @@ -0,0 +1,63 @@ +#!/usr/bin/env bash +# Build the committed contract in a fresh path. Requires Bash and GNU tools. +set -euo pipefail + +repo_root=$(git rev-parse --show-toplevel) +label=${1:?Usage: bash scripts/reproducible-contract-build.sh first|second OUTPUT_DIRECTORY} +case "$label" in + first|second) ;; + *) echo "Build label must be first or second" >&2; exit 2 ;; +esac +output=${2:?Specify an output directory} +mkdir -p "$output" +output=$(cd "$output" && pwd) +toolchain=$(sed -n 's/^channel = "\([^"]*\)"$/\1/p' "$repo_root/contract/rust-toolchain.toml") +test -n "$toolchain" +scratch=$(mktemp -d "${RUNNER_TEMP:-${TMPDIR:-/tmp}}/notify-repro-${label}-XXXXXX") +# Intentionally vary both the parent path and the depth of the source tree. +if [[ "$label" == first ]]; then + source_root="$scratch/source" +else + source_root="$scratch/a/different/depth/contract-source" +fi +mkdir -p "$source_root" +export CARGO_HOME="$scratch/cargo-home" +export CARGO_TARGET_DIR="$scratch/target" +export CARGO_INCREMENTAL=0 +export SOURCE_DATE_EPOCH +SOURCE_DATE_EPOCH=$(git -C "$repo_root" show -s --format=%ct HEAD) +export TZ=UTC LC_ALL=C +# Exclude workstation modifications, old outputs, and unrelated repository files. +git -C "$repo_root" archive HEAD:contract | tar -x -C "$source_root" +rm -rf -- "$source_root/target" +# Map source, dependency, and output paths to stable virtual locations. The +# release profile already disables debug info and strips symbols. +unset CARGO_ENCODED_RUSTFLAGS +export RUSTFLAGS="--remap-path-prefix=$source_root=/contract --remap-path-prefix=$CARGO_HOME=/cargo --remap-path-prefix=$CARGO_TARGET_DIR=/target" +if [[ -n "${RUSTUP_HOME:-}" ]]; then + RUSTFLAGS+=" --remap-path-prefix=$RUSTUP_HOME=/rustup" +fi +{ + echo "commit=$(git -C "$repo_root" rev-parse HEAD)" + echo "toolchain=$toolchain" + echo "runner_image=${ImageOS:-unknown} ${ImageVersion:-unknown}" + echo "runner_arch=${RUNNER_ARCH:-unknown}" + echo "target=wasm32v1-none profile=release package=hello-world" + echo "source=$source_root" + echo "cargo_home=$CARGO_HOME" + echo "target_dir=$CARGO_TARGET_DIR" + echo "SOURCE_DATE_EPOCH=$SOURCE_DATE_EPOCH" + echo "RUSTFLAGS=$RUSTFLAGS" + uname -a + rustc +"$toolchain" --version --verbose + cargo +"$toolchain" --version --verbose + sha256sum "$source_root/Cargo.lock" +} > "$output/environment.txt" 2>&1 + +cd "$source_root" +# tee preserves compiler diagnostics; pipefail preserves the Cargo failure code. +cargo +"$toolchain" build --locked --release --target wasm32v1-none \ + --package hello-world --lib 2>&1 | tee "$output/build.log" +cp "$CARGO_TARGET_DIR/wasm32v1-none/release/hello_world.wasm" "$output/hello_world.wasm" +(cd "$output" && sha256sum hello_world.wasm > SHA256SUMS) +# Preserve raw outputs for diagnosis; runner teardown removes scratch directories.