diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a4735c7f..5a5798a9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,18 +6,22 @@ on: - main pull_request: +permissions: + contents: read + jobs: build_and_test: runs-on: ubuntu-latest steps: - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@v4 - name: Set up Node.js - uses: actions/setup-node@v6 + uses: actions/setup-node@v4 with: node-version: '20' + cache: 'npm' - name: Install Dependencies run: npm ci @@ -28,8 +32,111 @@ jobs: - name: Run Unit Tests run: npm test -- --watch=false + e2e: + runs-on: ubuntu-latest + needs: build_and_test + permissions: + contents: read + + strategy: + fail-fast: false + matrix: + shardIndex: [1, 2, 3, 4] + shardTotal: [4] + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + + - name: Install Dependencies + run: npm ci + - name: Install Playwright Browsers - run: npx playwright install --with-deps + run: npx playwright install --with-deps chromium + + - name: Run E2E Tests (Shard ${{ matrix.shardIndex }}/${{ matrix.shardTotal }}) + run: | + npm run start & + for i in {1..60}; do + if curl --silent --fail http://localhost:4200 > /dev/null; then + break + fi + if [ "$i" -eq 60 ]; then + echo "Timed out waiting for http://localhost:4200" + exit 1 + fi + sleep 2 + done + npx playwright test --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }} + env: + CI: true + + - name: Upload blob report + uses: actions/upload-artifact@v4 + if: always() + with: + name: blob-report-${{ matrix.shardIndex }} + path: blob-report/ + retention-days: 1 + + merge_reports: + runs-on: ubuntu-latest + needs: e2e + if: always() + permissions: + contents: read + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + + - name: Install Dependencies + run: npm ci + + - name: Download all blob reports + uses: actions/download-artifact@v4 + with: + path: all-blob-reports + pattern: blob-report-* + merge-multiple: true + + - name: Merge Playwright reports + run: npx playwright merge-reports --reporter html ./all-blob-reports + + - name: Upload merged HTML report + uses: actions/upload-artifact@v4 + with: + name: playwright-report + path: playwright-report/ + retention-days: 30 + + security_scan: + runs-on: ubuntu-latest + permissions: + contents: read + + steps: + - name: Checkout repository + uses: actions/checkout@v4 - - name: Run E2E Tests - run: npm run start & npx wait-on http://localhost:4200 && npx playwright test + - name: Check for insecure Math.random() in randomization domain + run: | + echo "Scanning for Math.random() usage in randomization-engine..." + if grep -rn "Math\.random()" src/app/domain/randomization-engine/; then + echo "ERROR: Math.random() detected in randomization-engine domain." + echo "Use seedrandom or the Web Crypto API (crypto.getRandomValues) instead." + exit 1 + fi + echo "No insecure Math.random() found in randomization domain." diff --git a/angular.json b/angular.json index 9aa752cb..ab5b8e0c 100644 --- a/angular.json +++ b/angular.json @@ -49,8 +49,8 @@ "budgets": [ { "type": "initial", - "maximumWarning": "1.5MB", - "maximumError": "2.5MB" + "maximumWarning": "1.85MB", + "maximumError": "1.93MB" }, { "type": "anyComponentStyle", diff --git a/package-lock.json b/package-lock.json index a7d85246..88eb31e5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -37,6 +37,7 @@ "@angular/cli": "21.2.7", "@angular/compiler-cli": "21.2.7", "@angular/platform-browser-dynamic": "21.2.7", + "@axe-core/playwright": "^4.11.1", "@playwright/test": "^1.58.2", "@semantic-release/changelog": "^6.0.3", "@semantic-release/commit-analyzer": "^13.0.1", @@ -966,6 +967,19 @@ "dev": true, "license": "MIT" }, + "node_modules/@axe-core/playwright": { + "version": "4.11.1", + "resolved": "https://registry.npmjs.org/@axe-core/playwright/-/playwright-4.11.1.tgz", + "integrity": "sha512-mKEfoUIB1MkVTht0BGZFXtSAEKXMJoDkyV5YZ9jbBmZCcWDz71tegNsdTkIN8zc/yMi5Gm2kx7Z5YQ9PfWNAWw==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "axe-core": "~4.11.1" + }, + "peerDependencies": { + "playwright-core": ">= 1.0.0" + } + }, "node_modules/@babel/code-frame": { "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", @@ -6736,6 +6750,16 @@ "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", "license": "MIT" }, + "node_modules/axe-core": { + "version": "4.11.3", + "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.11.3.tgz", + "integrity": "sha512-zBQouZixDTbo3jMGqHKyePxYxr1e5W8UdTmBQ7sNtaA9M2bE32daxxPLS/jojhKOHxQ7LWwPjfiwf/fhaJWzlg==", + "dev": true, + "license": "MPL-2.0", + "engines": { + "node": ">=4" + } + }, "node_modules/axobject-query": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", diff --git a/package.json b/package.json index 9a6136e3..665ead7e 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,7 @@ "@angular/cli": "21.2.7", "@angular/compiler-cli": "21.2.7", "@angular/platform-browser-dynamic": "21.2.7", + "@axe-core/playwright": "^4.11.1", "@playwright/test": "^1.58.2", "@semantic-release/changelog": "^6.0.3", "@semantic-release/commit-analyzer": "^13.0.1", diff --git a/playwright.config.ts b/playwright.config.ts index 5ce0d4b7..92272690 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -6,7 +6,7 @@ export default defineConfig({ forbidOnly: !!process.env.CI, retries: process.env.CI ? 2 : 0, workers: process.env.CI ? 1 : undefined, - reporter: 'html', + reporter: process.env.CI ? [['blob'], ['line']] : 'html', use: { trace: 'on-first-retry', }, diff --git a/src/app/app.ts b/src/app/app.ts index b9b4e2d9..27b3ed7a 100644 --- a/src/app/app.ts +++ b/src/app/app.ts @@ -235,24 +235,24 @@ import {APP_VERSION} from '../environments/version'; Equipose -

+

Free, browser-based stratified block randomization for clinical trials. No server. No sign-up. No data shared.

-

© {{ currentYear }} Frederick de Ruiter

+

© {{ currentYear }} Frederick de Ruiter

-

Navigation

- About - Generator - Verify Schema - GitHub +

Navigation

+ About + Generator + Verify Schema + GitHub
-

Details

+

Details

{{ appVersion }} @@ -263,7 +263,7 @@ import {APP_VERSION} from '../environments/version'; MIT License
-

+

@@ -271,7 +271,7 @@ import {APP_VERSION} from '../environments/version';

+ class="inline-flex items-center gap-1.5 text-xs text-gray-600 dark:text-slate-400 hover:text-indigo-600 dark:hover:text-indigo-400 transition-colors"> diff --git a/src/app/domain/randomization-engine/components/monte-carlo-modal.component.ts b/src/app/domain/randomization-engine/components/monte-carlo-modal.component.ts index e63f826c..4f136fb0 100644 --- a/src/app/domain/randomization-engine/components/monte-carlo-modal.component.ts +++ b/src/app/domain/randomization-engine/components/monte-carlo-modal.component.ts @@ -232,8 +232,8 @@ export class MonteCarloModalComponent { deviationClass(arm: MonteCarloArmResult): string { const d = this.deviation(arm); - if (d < 0.1) return 'text-emerald-600 dark:text-emerald-400'; - if (d < 1) return 'text-amber-600 dark:text-amber-400'; + if (d < 0.1) return 'text-emerald-700 dark:text-emerald-400'; + if (d < 1) return 'text-amber-700 dark:text-amber-400'; return 'text-red-600 dark:text-rose-400'; } @@ -245,8 +245,8 @@ export class MonteCarloModalComponent { maxDeviationClass(): string { const d = this.maxDeviation(); - if (d < 0.1) return 'text-emerald-600 dark:text-emerald-400 text-2xl font-bold'; - if (d < 1) return 'text-amber-600 dark:text-amber-400 text-2xl font-bold'; + if (d < 0.1) return 'text-emerald-700 dark:text-emerald-400 text-2xl font-bold'; + if (d < 1) return 'text-amber-700 dark:text-amber-400 text-2xl font-bold'; return 'text-red-600 dark:text-rose-400 text-2xl font-bold'; } } diff --git a/src/app/domain/randomization-engine/core/randomization-algorithm.ts b/src/app/domain/randomization-engine/core/randomization-algorithm.ts index 757f49a4..af9d6b83 100644 --- a/src/app/domain/randomization-engine/core/randomization-algorithm.ts +++ b/src/app/domain/randomization-engine/core/randomization-algorithm.ts @@ -9,6 +9,16 @@ import { import { generateSubjectId } from './subject-id-engine'; import { generateMinimization } from './minimization-algorithm'; +// --------------------------------------------------------------------------- +// Crypto seed helper (shared with the Web Worker) +// --------------------------------------------------------------------------- + +export function generateCryptoSeed(): string { + const array = new Uint32Array(4); + crypto.getRandomValues(array); + return Array.from(array, n => n.toString(16).padStart(8, '0')).join(''); +} + // --------------------------------------------------------------------------- // Shared block-generation helpers // --------------------------------------------------------------------------- @@ -345,7 +355,7 @@ function generateMarginalOnly( export function generateRandomizationSchema(config: RandomizationConfig): RandomizationResult { const resolvedConfig = config.seed ? config - : { ...config, seed: Math.random().toString(36).substring(2, 15) }; + : { ...config, seed: generateCryptoSeed() }; const rng = seedrandom(resolvedConfig.seed); diff --git a/src/app/domain/randomization-engine/randomization-engine.facade.ts b/src/app/domain/randomization-engine/randomization-engine.facade.ts index 2fb09444..fb4b331a 100644 --- a/src/app/domain/randomization-engine/randomization-engine.facade.ts +++ b/src/app/domain/randomization-engine/randomization-engine.facade.ts @@ -134,7 +134,7 @@ export class RandomizationEngineFacade { return; } - const id = Math.random().toString(36).substring(2); + const id = crypto.randomUUID(); this.pendingMonteCarloCallbacks.set(id, { onProgress: (p: MonteCarloProgressPayload) => { @@ -224,7 +224,7 @@ export class RandomizationEngineFacade { } private dispatchToWorker(config: RandomizationConfig): void { - const id = Math.random().toString(36).substring(2); + const id = crypto.randomUUID(); this.pendingCallbacks.set(id, { resolve: async result => { diff --git a/src/app/domain/randomization-engine/worker/randomization-engine.worker.ts b/src/app/domain/randomization-engine/worker/randomization-engine.worker.ts index ceb62618..02947e66 100644 --- a/src/app/domain/randomization-engine/worker/randomization-engine.worker.ts +++ b/src/app/domain/randomization-engine/worker/randomization-engine.worker.ts @@ -1,6 +1,6 @@ /// -import { generateRandomizationSchema } from '../core/randomization-algorithm'; +import { generateRandomizationSchema, generateCryptoSeed } from '../core/randomization-algorithm'; import type { GenerationCommand, MonteCarloProgressPayload, @@ -38,12 +38,6 @@ addEventListener('message', (event: MessageEvent) => { } }); -function generateCryptoSeed(): string { - const array = new Uint32Array(4); - crypto.getRandomValues(array); - return Array.from(array, n => n.toString(16).padStart(8, '0')).join(''); -} - function runMonteCarlo(id: string, config: RandomizationConfig): void { const TOTAL_ITERATIONS = 10_000; const PROGRESS_INTERVAL = 500; diff --git a/src/app/domain/schema-management/components/balance-verification.component.ts b/src/app/domain/schema-management/components/balance-verification.component.ts index 755a2b58..6be93fb9 100644 --- a/src/app/domain/schema-management/components/balance-verification.component.ts +++ b/src/app/domain/schema-management/components/balance-verification.component.ts @@ -41,7 +41,7 @@ export interface MarginalBalanceRow {
-
+
Legend: @@ -55,7 +55,7 @@ export interface MarginalBalanceRow { Critical error - investigate - + Cells show: Actual / Target
@@ -64,7 +64,7 @@ export interface MarginalBalanceRow {

Global Balance

-

+

Aggregate distribution across the entire trial (N = {{ globalRow().total }})

@@ -104,7 +104,7 @@ export interface MarginalBalanceRow {

Balance by Site

-

+

Marginal distribution per clinical site

@@ -147,7 +147,7 @@ export interface MarginalBalanceRow {

Marginal Balance by Factor Level

-

+

Arm distribution per stratification factor level (Pocock-Simon minimization target: equal marginal totals)

@@ -189,7 +189,7 @@ export interface MarginalBalanceRow {

Balance by Stratum

-

+

Marginal distribution per unique stratification-factor combination

@@ -228,7 +228,7 @@ export interface MarginalBalanceRow { } -

+

@if (isMinimization()) { ⚠ Minimization (Pocock-Simon) achieves marginal balance across factor levels rather than perfect block-level balance. Small deviations from exact equal allocation are expected due to stochastic assignment and covariate sampling. @@ -240,7 +240,7 @@ export interface MarginalBalanceRow {

} @else { -
+
Generate a schema first to view the balance verification report.
} diff --git a/src/app/domain/schema-management/components/results-grid.component.html b/src/app/domain/schema-management/components/results-grid.component.html index 336eba58..26212953 100644 --- a/src/app/domain/schema-management/components/results-grid.component.html +++ b/src/app/domain/schema-management/components/results-grid.component.html @@ -41,7 +41,7 @@

Generated Schema

-

+

Protocol: {{data.metadata.protocolId}} | Seed: {{data.metadata.seed}}

@@ -129,7 +129,7 @@

Generated Schema @for (chunk of splitSubjectId(row.subjectId); track $index) { @if ($index > 0) { - - + - } {{chunk}} } @@ -139,7 +139,7 @@

Generated Schema type="button" [cdkMenuTriggerFor]="rowMenu" (click)="openRowMenu(row)" - class="inline-flex items-center justify-center w-11 h-11 rounded-full text-gray-400 dark:text-slate-500 hover:text-gray-700 dark:hover:text-slate-200 hover:bg-gray-100 dark:hover:bg-slate-700 transition-colors focus-visible:outline-none focus:ring-2 focus:ring-indigo-500" + class="inline-flex items-center justify-center w-11 h-11 rounded-full text-gray-500 dark:text-slate-400 hover:text-gray-700 dark:hover:text-slate-200 hover:bg-gray-100 dark:hover:bg-slate-700 transition-colors focus-visible:outline-none focus:ring-2 focus:ring-indigo-500" [attr.aria-label]="'Actions for subject ' + row.subjectId" >

Generated Schema

-
Site
+
Site
{{row.site}}
@for (stratum of data.metadata.strata; track stratum.id) { -
{{stratum.name || stratum.id}}
+
{{stratum.name || stratum.id}}
{{row.stratum[stratum.id]}}
} -
Block
-
{{row.blockNumber}} (Size: {{row.blockSize}})
-
Treatment Arm
+
Block
+
{{row.blockNumber}} (Size: {{row.blockSize}})
+
Treatment Arm
@if (isUnblinded()) { {{row.treatmentArm}} } @else { - *** BLINDED *** + *** BLINDED *** }
@@ -307,18 +307,18 @@

Generated Schema @for (chunk of splitSubjectId(row.subjectId); track $index) { @if ($index > 0) { - - + - } {{chunk}} } {{row.site}} @for (stratum of data.metadata.strata; track stratum.id) { - {{row.stratum[stratum.id]}} + {{row.stratum[stratum.id]}} } {{row.blockNumber}} - (Size: {{row.blockSize}}) + (Size: {{row.blockSize}}) @if (isUnblinded()) { @@ -326,7 +326,7 @@

Generated Schema {{row.treatmentArm}} } @else { - + *** BLINDED *** } @@ -336,7 +336,7 @@

Generated Schema type="button" [cdkMenuTriggerFor]="rowMenu" (click)="openRowMenu(row)" - class="inline-flex items-center justify-center w-11 h-11 rounded-full text-gray-400 dark:text-slate-500 hover:text-gray-700 dark:hover:text-slate-200 hover:bg-gray-100 dark:hover:bg-slate-700 transition-colors focus-visible:outline-none focus:ring-2 focus:ring-indigo-500" + class="inline-flex items-center justify-center w-11 h-11 rounded-full text-gray-500 dark:text-slate-400 hover:text-gray-700 dark:hover:text-slate-200 hover:bg-gray-100 dark:hover:bg-slate-700 transition-colors focus-visible:outline-none focus:ring-2 focus:ring-indigo-500" [attr.aria-label]="'Actions for subject ' + row.subjectId" >

Generated Schema @for (chunk of splitSubjectId($any(item).data.subjectId); track $index) { @if ($index > 0) { - - + - } {{chunk}} } {{$any(item).data.site}} @for (stratum of data.metadata.strata; track stratum.id) { - {{$any(item).data.stratum[stratum.id]}} + {{$any(item).data.stratum[stratum.id]}} } {{$any(item).data.blockNumber}} - (Size: {{$any(item).data.blockSize}}) + (Size: {{$any(item).data.blockSize}}) @if (isUnblinded()) { @@ -426,7 +426,7 @@

Generated Schema {{$any(item).data.treatmentArm}} } @else { - + *** BLINDED *** } @@ -436,7 +436,7 @@

Generated Schema type="button" [cdkMenuTriggerFor]="rowMenu" (click)="openRowMenu($any(item).data)" - class="inline-flex items-center justify-center w-11 h-11 rounded-full text-gray-400 dark:text-slate-500 hover:text-gray-700 dark:hover:text-slate-200 hover:bg-gray-100 dark:hover:bg-slate-700 transition-colors focus-visible:outline-none focus:ring-2 focus:ring-indigo-500" + class="inline-flex items-center justify-center w-11 h-11 rounded-full text-gray-500 dark:text-slate-400 hover:text-gray-700 dark:hover:text-slate-200 hover:bg-gray-100 dark:hover:bg-slate-700 transition-colors focus-visible:outline-none focus:ring-2 focus:ring-indigo-500" [attr.aria-label]="'Actions for subject ' + $any(item).data.subjectId" >

Generated Schema
-

+

Filter: {{activeFilterColumn()}}

Treatment Balance @if (!viewState.isUnblinded()) { - (blinded) + (blinded) }

@@ -81,7 +81,7 @@ const BLINDED_COLOUR = '#94a3b8'; // slate-400

-

+

Click a chart segment or bar to cross-filter the results grid below.

diff --git a/src/app/domain/study-builder/components/block-preview.component.ts b/src/app/domain/study-builder/components/block-preview.component.ts index 2398860f..cb166378 100644 --- a/src/app/domain/study-builder/components/block-preview.component.ts +++ b/src/app/domain/study-builder/components/block-preview.component.ts @@ -140,7 +140,7 @@ export function buildPreviews(arms: ArmInput[], blockSizes: number[]): BlockPrev {{ arm.name || arm.id }} } - + Unallocatable @@ -156,7 +156,7 @@ export function buildPreviews(arms: ArmInput[], blockSizes: number[]): BlockPrev
}
-

Enter block sizes above to see a preview.

+

Enter block sizes above to see a preview.

} @else { @for (preview of previews(); track preview.blockSize) { @@ -164,7 +164,7 @@ export function buildPreviews(arms: ArmInput[], blockSizes: number[]): BlockPrev
Block size {{ preview.blockSize }} @if (preview.isValid) { - + diff --git a/src/app/domain/study-builder/components/config-form.component.html b/src/app/domain/study-builder/components/config-form.component.html index 12b00139..f628317b 100644 --- a/src/app/domain/study-builder/components/config-form.component.html +++ b/src/app/domain/study-builder/components/config-form.component.html @@ -70,7 +70,7 @@

Treatment Ar type="button" (click)="removeArm($index)" [disabled]="arms.length <= 2" - class="absolute top-3 right-3 text-gray-400 dark:text-slate-500 hover:text-red-500 dark:hover:text-rose-400 disabled:opacity-30 disabled:cursor-not-allowed transition-colors" + class="absolute top-3 right-3 text-gray-600 dark:text-slate-500 hover:text-red-500 dark:hover:text-rose-400 disabled:opacity-30 disabled:cursor-not-allowed transition-colors" aria-label="Remove arm" > @@ -80,7 +80,7 @@

Treatment Ar
- + Treatment Ar
- +
- of {{ totalRatio }} total + of {{ totalRatio }} total
@@ -121,7 +121,7 @@

Treatment Ar

Stratification Factors

-

Drag cards to reorder - the Cartesian product depends on factor order.

+

Drag cards to reorder - the Cartesian product depends on factor order.

@@ -338,7 +338,7 @@

Sizes are applied in order and cycle when exhausted. PRNG is bypassed for size selection.

+

Sizes are applied in order and cycle when exhausted. PRNG is bypassed for size selection.

}

@@ -356,7 +356,7 @@

No overrides defined. All sites and strata use the global block sizes above.

+

No overrides defined. All sites and strata use the global block sizes above.

}
@@ -368,7 +368,7 @@

@@ -380,7 +380,7 @@

- + Block Sizes (comma-separated) + Selection Mode +
} @if (isFactorPercentageInvalid(factor.id)) { @@ -620,7 +620,7 @@

-

+

Set hard limits per factor level. The engine randomly allocates subjects across intersections until each level's cap is reached.

@for (factor of strataWithLevels; track factor.id) { @@ -639,7 +639,7 @@

- subjects + subjects

}

@@ -695,7 +695,7 @@

Random Seed (Optional)