diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ff6195483..2fcf872b7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,6 +33,20 @@ jobs: - name: Verify production build run: npm run build -- --configuration production --aot=false + - name: Install Chromium + run: ./node_modules/.bin/playwright install --with-deps chromium --only-shell + + - name: Run E2E tests + run: npm run test:e2e + + - name: Upload E2E test results + if: failure() + uses: actions/upload-artifact@v4 + with: + name: e2e-test-results + path: nav-app/tmp/playwright/test-results/ + retention-days: 7 + - name: Archive code coverage results uses: actions/upload-artifact@v4 if: always() diff --git a/nav-app/e2e/Dockerfile b/nav-app/e2e/Dockerfile new file mode 100644 index 000000000..a3867e3a0 --- /dev/null +++ b/nav-app/e2e/Dockerfile @@ -0,0 +1,21 @@ +FROM node:22.22.2-bookworm + +WORKDIR /app/nav-app +ENV PLAYWRIGHT_BROWSERS_PATH=/ms-playwright +COPY nav-app/package.json nav-app/package-lock.json ./ +RUN npm ci --ignore-scripts +RUN --mount=type=secret,id=extra_ca \ + if [ -f /run/secrets/extra_ca ]; then export NODE_EXTRA_CA_CERTS=/run/secrets/extra_ca; fi; \ + ./node_modules/.bin/playwright install --with-deps chromium --only-shell + +COPY nav-app/angular.json nav-app/tsconfig*.json nav-app/playwright.config.ts ./ +COPY nav-app/src ./src +COPY nav-app/e2e ./e2e +COPY layers /app/layers +COPY *.md /app/ +RUN npm run build -- --configuration production --aot=false + +RUN mkdir -p tmp && chown node:node tmp +USER node + +CMD ["npm", "run", "test:e2e"] diff --git a/nav-app/e2e/Dockerfile.dockerignore b/nav-app/e2e/Dockerfile.dockerignore new file mode 100644 index 000000000..0b4c662e3 --- /dev/null +++ b/nav-app/e2e/Dockerfile.dockerignore @@ -0,0 +1,14 @@ +** +!*.md +!layers/ +!layers/** +!nav-app/ +!nav-app/package.json +!nav-app/package-lock.json +!nav-app/angular.json +!nav-app/tsconfig*.json +!nav-app/src/ +!nav-app/src/** +!nav-app/playwright.config.ts +!nav-app/e2e/ +!nav-app/e2e/** diff --git a/nav-app/e2e/README.md b/nav-app/e2e/README.md new file mode 100644 index 000000000..f9387cae1 --- /dev/null +++ b/nav-app/e2e/README.md @@ -0,0 +1,62 @@ +# End-to-end tests + +Playwright runs the E2E suite in Chromium. + +Run from `nav-app` with Node 22, npm, and Python 3 available: + +```sh +npm ci --ignore-scripts +npx playwright install chromium --only-shell +npm run build -- --configuration production --aot=false +npm run test:e2e +``` + +Playwright starts and stops a local static server on port 4173. The suite uses the +production build in `dist/browser`; rebuild after application changes. + +Downloaded JSON and failure traces are saved under `tmp/playwright/test-results`. +There are no retries. The CI quality gate runs the suite after the production +build on PRs targeting `develop` or `master`, and on pushes to `develop`. Failed runs upload +`tmp/playwright/test-results` as an artifact retained for seven days. + +## Adding tests + +Tag smoke tests with `{ tag: '@smoke' }`; run only those with `npm run test:e2e -- --grep @smoke`. + +Add Playwright tests as `*.spec.ts` files in this directory. +Protractor `*.e2e-spec.ts` files are excluded from Playwright runs. + +For tests that do not check font rendering, stub remote font stylesheet requests +with an empty CSS response so external font-service availability cannot affect +the result. See `layer.spec.ts` for an example. + +## Docker + +Run from the repository root: + +```sh +docker build -f nav-app/e2e/Dockerfile -t attack-navigator-e2e:local . +docker run --rm --init attack-navigator-e2e:local +``` + +The image installs dependencies and Chromium, builds Navigator, and runs the same +suite. Source files are copied into the image; no host directories +are mounted. The local build and `node_modules` are not used. Test artifacts are +inside the container and are discarded by `--rm`. + +Optional troubleshooting flags: + +- Add `--platform linux/amd64` to both commands to match the GitHub runner's CPU + architecture when investigating differences between local and CI runs. +- Add `--shm-size=1g` to `docker run` to increase shared memory if Chromium crashes + because the container's default shared memory is insufficient. + +If browser downloads fail with `SELF_SIGNED_CERT_IN_CHAIN`, set `CA_BUNDLE` to a +trusted PEM certificate bundle and supply it when building: + +```sh +docker build --secret "id=extra_ca,src=$CA_BUNDLE" -f nav-app/e2e/Dockerfile -t attack-navigator-e2e:local . +``` + +The optional build secret sets `NODE_EXTRA_CA_CERTS` only for browser installation. +The bundle is not stored in the image, and TLS verification is enabled. diff --git a/nav-app/e2e/layer.spec.ts b/nav-app/e2e/layer.spec.ts new file mode 100644 index 000000000..434250b17 --- /dev/null +++ b/nav-app/e2e/layer.spec.ts @@ -0,0 +1,54 @@ +import { test, expect } from '@playwright/test'; +import { readFile } from 'node:fs/promises'; +import config from '../src/assets/config.json'; +import { configData, matrixSDO, TA0000, T0001, T0003 } from '../src/tests/utils/mock-data'; + +test('create, score, rename, and export an Enterprise layer', { tag: '@smoke' }, async ({ page }, testInfo) => { + const errors: string[] = []; + page.on('pageerror', error => errors.push(error.message)); + + // Reuse existing synthetic STIX fixtures; only data responses are replaced. + await page.route('**/assets/config.json', route => route.fulfill({ + json: { ...config, collection_index_url: '', versions: configData }, + })); + await page.route(configData.entries[0].domains[0].data[0], route => route.fulfill({ + json: { + type: 'bundle', + id: 'bundle-smoke', + objects: [matrixSDO, TA0000, { ...T0001, name: 'Smoke technique' }, { ...T0003, name: 'Untouched technique' }], + }, + })); + // Avoid an external font dependency; assertions use text and attributes, not icon appearance. + await page.route('https://fonts.googleapis.com/**', route => route.fulfill({ body: '', contentType: 'text/css' })); + + await page.goto('/'); + await page.getByText('Create New Layer', { exact: true }).click(); + await page.getByRole('button', { name: 'Enterprise', exact: true }).click(); + await expect(page.locator('technique-cell')).toHaveCount(2); + await page.locator('technique-cell').getByText('Smoke technique', { exact: true }).click(); + + await page.getByText('Technique Controls', { exact: true }).click(); + await page.locator('span[alt="score"]').click(); + await page.getByLabel('score', { exact: true }).fill('1'); + + await page.getByText('Layer Controls', { exact: true }).click(); + await page.locator('span[alt="layer information"]').click(); + await page.getByLabel('Name', { exact: true }).fill('Smoke layer'); + await expect(page.locator('.tab-title.active')).toContainText('Smoke layer'); + + await page.locator('span[alt="export"]').click(); + const downloadPromise = page.waitForEvent('download'); + await page.locator('span[alt="save layer"]').filter({ hasText: /^code$/ }).click(); + const download = await downloadPromise; + const file = testInfo.outputPath('layer.json'); + await download.saveAs(file); + const layer = JSON.parse(await readFile(file, 'utf8')); + expect(layer).toMatchObject({ + name: 'Smoke layer', + domain: 'enterprise-attack', + versions: { attack: '13', layer: '4.5', navigator: expect.any(String) }, + }); + expect(layer.techniques).toHaveLength(1); + expect(layer.techniques[0]).toMatchObject({ techniqueID: 'T0001', tactic: 'tactic-name', score: 1 }); + expect(errors).toEqual([]); +}); diff --git a/nav-app/package-lock.json b/nav-app/package-lock.json index fac5ab5db..49a092322 100644 --- a/nav-app/package-lock.json +++ b/nav-app/package-lock.json @@ -47,6 +47,7 @@ "@angular/cli": "^19.2.23", "@angular/compiler-cli": "^19.2.20", "@angular/language-service": "^19.2.20", + "@playwright/test": "1.63.0", "@types/jasmine": "^6.0.0", "@types/jasminewd2": "^2.0.8", "@types/node": "^22.19.17", @@ -5227,6 +5228,22 @@ "node": ">=14" } }, + "node_modules/@playwright/test": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.63.0.tgz", + "integrity": "sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.63.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.59.0", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", @@ -14457,6 +14474,35 @@ "pathe": "^2.0.1" } }, + "node_modules/playwright": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz", + "integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.63.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright-core": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz", + "integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/points-on-curve": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/points-on-curve/-/points-on-curve-0.2.0.tgz", diff --git a/nav-app/package.json b/nav-app/package.json index 597472897..df1714841 100644 --- a/nav-app/package.json +++ b/nav-app/package.json @@ -12,6 +12,7 @@ "start": "ng serve --host 0.0.0.0", "build": "ng build", "test": "ng test", + "test:e2e": "playwright test", "lint": "ng lint", "prettier": "npx prettier src --check", "prettier:fix": "npm run prettier -- --write" @@ -55,6 +56,7 @@ "@angular/cli": "^19.2.23", "@angular/compiler-cli": "^19.2.20", "@angular/language-service": "^19.2.20", + "@playwright/test": "1.63.0", "@types/jasmine": "^6.0.0", "@types/jasminewd2": "^2.0.8", "@types/node": "^22.19.17", diff --git a/nav-app/playwright.config.ts b/nav-app/playwright.config.ts new file mode 100644 index 000000000..2b8a58409 --- /dev/null +++ b/nav-app/playwright.config.ts @@ -0,0 +1,18 @@ +import { defineConfig } from '@playwright/test'; + +export default defineConfig({ + testDir: './e2e', + testMatch: '**/*.spec.ts', + outputDir: './tmp/playwright/test-results', + workers: 1, + retries: 0, + use: { + browserName: 'chromium', + baseURL: 'http://127.0.0.1:4173', + trace: 'retain-on-failure', + }, + webServer: { + command: 'python3 -m http.server 4173 --bind 127.0.0.1 --directory dist/browser', + url: 'http://127.0.0.1:4173', + }, +});