Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 81 additions & 0 deletions .github/workflows/e2e.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
---
name: E2E

# Runs the Playwright end-to-end + mobile/responsive suite across Chromium,
# Firefox and WebKit (desktop) plus emulated mobile devices. Unit/component
# tests (Vitest) run in the Pages workflow; this workflow focuses on real
# browser flows. See playwright.config.js for projects and the dev web server.

on:
push:
branches:
- main
paths-ignore:
- "**.md"
pull_request:
types:
- opened
- synchronize
- reopened
paths-ignore:
- "**.md"
workflow_dispatch:

permissions:
contents: read

concurrency:
group: e2e-${{ github.ref }}
cancel-in-progress: true

jobs:
e2e:
name: Playwright
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- name: Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false

- name: Set up Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
# renovate: datasource=node-version depName=node
node-version: "24"
cache: npm

- name: Install dependencies
run: npm ci

- name: Cache Playwright browsers
id: playwright-cache
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
restore-keys: |-
playwright-${{ runner.os }}-

- name: Install Playwright browsers
run: npx playwright install --with-deps

- name: Run Playwright tests
run: npm run test:e2e

- name: Upload Playwright report
if: ${{ !cancelled() }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: playwright-report
path: playwright-report/
retention-days: 30

- name: Upload Playwright traces and results
if: ${{ failure() }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: playwright-test-results
path: test-results/
retention-days: 30
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,12 @@ coverage
.DS_Store
.vite

# Playwright
/test-results/
/playwright-report/
/blob-report/
/playwright/.cache/

# Editor
*.log
npm-debug.log*
Expand Down
97 changes: 97 additions & 0 deletions e2e/app.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { test, expect } from '@playwright/test';

// Core end-to-end flows, exercised across every configured browser/device
// project (desktop Chromium/Firefox/WebKit + mobile Chrome/Safari).

test.beforeEach(async ({ page }) => {
await page.goto('/');
});

test('loads with a meaningful title and the matrix view', async ({ page }) => {
await expect(page).toHaveTitle(/GRIP Visualizer/);
await expect(page.getByRole('region', { name: 'Matrix' })).toBeVisible();
await expect(page.getByRole('heading', { name: 'GRIP Visualizer' })).toBeVisible();
});

test('switches between the four views', async ({ page }) => {
await page.getByRole('tab', { name: 'Groeitraject' }).click();
await expect(page.getByRole('region', { name: 'Groeitraject' })).toBeVisible();

await page.getByRole('tab', { name: 'A3 vs A5' }).click();
await expect(page.getByRole('tab', { name: 'A3 vs A5' })).toHaveAttribute(
'aria-selected',
'true'
);

await page.getByRole('tab', { name: 'Prioriteren' }).click();
await expect(page.getByRole('tab', { name: 'Prioriteren' })).toHaveAttribute(
'aria-selected',
'true'
);

await page.getByRole('tab', { name: 'Matrix' }).click();
await expect(page.getByRole('region', { name: 'Matrix' })).toBeVisible();
});

test('switches the UI language NL → EN → FR', async ({ page }) => {
await expect(page.getByRole('tab', { name: 'Groeitraject' })).toBeVisible();

await page.getByRole('button', { name: 'EN', exact: true }).click();
await expect(page.getByRole('tab', { name: 'Journey' })).toBeVisible();

await page.getByRole('button', { name: 'FR', exact: true }).click();
await expect(page.getByRole('tab', { name: 'Parcours' })).toBeVisible();

await page.getByRole('button', { name: 'NL', exact: true }).click();
await expect(page.getByRole('tab', { name: 'Groeitraject' })).toBeVisible();
});

test('applies the footer type and tier filters', async ({ page }) => {
const orgFilter = page.getByRole('button', { name: 'Organisatorisch' });
await orgFilter.click();
await expect(orgFilter).toHaveAttribute('aria-pressed', 'true');

const tierFilter = page.getByRole('button', { name: 'A5', exact: true });
await tierFilter.click();
await expect(tierFilter).toHaveAttribute('aria-pressed', 'true');

// Clearing the filters resets the pressed state.
await page.getByRole('button', { name: /Wissen/ }).click();
await expect(orgFilter).toHaveAttribute('aria-pressed', 'false');
});

test('opens the detail panel with the Microsoft mapping and references', async ({
page,
}) => {
await page.getByRole('button', { name: /O7/ }).first().click();

const panel = page.locator('aside.detail');
await expect(panel.getByRole('heading', { name: 'Microsoft-koppeling' })).toBeVisible();
await expect(panel.getByText('Conditional Access', { exact: true })).toBeVisible();

// References / further-reading section (issue #63).
await expect(panel.getByRole('heading', { name: 'Meer lezen' })).toBeVisible();
const reference = panel.getByRole('link', { name: /leerlingen/ });
await expect(reference).toHaveAttribute('target', '_blank');
await expect(reference).toHaveAttribute(
'href',
/learn\.microsoft\.com.*protect-passwordless-students/
);
});

test('external documentation links open safely in a new tab', async ({ page }) => {
await page.getByRole('button', { name: /O7/ }).first().click();

const docsLink = page
.locator('aside.detail')
.getByRole('link', { name: /Documentatie openen/ })
.first();
await expect(docsLink).toHaveAttribute('target', '_blank');
await expect(docsLink).toHaveAttribute('rel', /noopener/);
});

test('exposes the M365 Maps reference link in the footer', async ({ page }) => {
const mapsLink = page.getByRole('link', { name: /M365 Maps/ });
await expect(mapsLink).toHaveAttribute('href', 'https://m365maps.com/');
await expect(mapsLink).toHaveAttribute('target', '_blank');
});
45 changes: 45 additions & 0 deletions e2e/mobile.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { test, expect } from '@playwright/test';

// Mobile / responsive checks. These only run on the emulated mobile device
// projects (Pixel 5, iPhone 12); they are skipped on desktop browsers.
test.describe('mobile / responsive', () => {
test.skip(({ isMobile }) => !isMobile, 'mobile-only assertions');

test.beforeEach(async ({ page }) => {
await page.goto('/');
});

test('renders the header and view tabs on a small screen', async ({ page }) => {
await expect(page.getByRole('heading', { name: 'GRIP Visualizer' })).toBeVisible();
await expect(page.getByRole('tab', { name: 'Matrix' })).toBeVisible();

const viewport = page.viewportSize();
expect(viewport && viewport.width).toBeLessThanOrEqual(500);
});

test('exposes tappable footer filters on a small screen', async ({ page }) => {
const orgFilter = page.getByRole('button', { name: 'Organisatorisch' });
await expect(orgFilter).toBeVisible();
await orgFilter.tap();
await expect(orgFilter).toHaveAttribute('aria-pressed', 'true');
});

test('can tap a measure and open the detail panel', async ({ page }) => {
await page.getByRole('button', { name: /O7/ }).first().tap();

const panel = page.locator('aside.detail');
await expect(
panel.getByRole('heading', { name: 'Microsoft-koppeling' })
).toBeVisible();

// The close control is a usable tap target.
const close = panel.getByRole('button', { name: 'Sluiten' });
await expect(close).toBeVisible();
await close.tap();
});

test('can switch language on mobile', async ({ page }) => {
await page.getByRole('button', { name: 'EN', exact: true }).tap();
await expect(page.getByRole('tab', { name: 'Journey' })).toBeVisible();
});
});
11 changes: 10 additions & 1 deletion eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,16 @@ import reactHooks from 'eslint-plugin-react-hooks';
import reactRefresh from 'eslint-plugin-react-refresh';

export default [
{ ignores: ['dist', 'node_modules', 'coverage', '.vite'] },
{
ignores: [
'dist',
'node_modules',
'coverage',
'.vite',
'playwright-report',
'test-results',
],
},
js.configs.recommended,
{
files: ['**/*.{js,jsx}'],
Expand Down
70 changes: 67 additions & 3 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,77 @@
<html lang="nl">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />

<title>GRIP Visualizer — GRIP × Microsoft A3/A5</title>
<meta
name="description"
content="Interactieve GRIP-matrix: koppel de Vlaamse GRIP-maatregelen aan de Microsoft A3/A5-stack."
content="Interactieve GRIP-matrix: koppel de Vlaamse GRIP-maatregelen voor informatieveiligheid en privacy aan de Microsoft A3/A5-stack."
/>
<link rel="canonical" href="https://grip.ravensberg.org/" />
<meta name="theme-color" content="#2f6df6" />
<meta name="color-scheme" content="light dark" />

<!-- Icons & PWA manifest -->
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="icon" type="image/png" sizes="192x192" href="/icon-192.png" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<link rel="manifest" href="/site.webmanifest" />

<!-- Open Graph -->
<meta property="og:type" content="website" />
<meta property="og:site_name" content="GRIP Visualizer" />
<meta property="og:title" content="GRIP Visualizer — GRIP × Microsoft A3/A5" />
<meta
property="og:description"
content="Map the Flemish GRIP information-security & privacy growth path to the Microsoft A3/A5 stack."
/>
<meta property="og:url" content="https://grip.ravensberg.org/" />
<meta property="og:image" content="https://grip.ravensberg.org/og-image.png" />
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />
<meta
property="og:image:alt"
content="GRIP Visualizer — mapping the Flemish GRIP growth path to the Microsoft A3/A5 stack."
/>
<meta property="og:locale" content="nl_BE" />
<meta property="og:locale:alternate" content="en_US" />
<meta property="og:locale:alternate" content="fr_FR" />

<!-- Twitter / X card -->
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="GRIP Visualizer — GRIP × Microsoft A3/A5" />
<meta
name="twitter:description"
content="Map the Flemish GRIP information-security & privacy growth path to the Microsoft A3/A5 stack."
/>
<meta name="twitter:image" content="https://grip.ravensberg.org/og-image.png" />
<meta
name="twitter:image:alt"
content="GRIP Visualizer — mapping the Flemish GRIP growth path to the Microsoft A3/A5 stack."
/>
<title>GRIP Visualizer — GRIP × Microsoft</title>

<!-- Structured data -->
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "WebApplication",
"name": "GRIP Visualizer",
"url": "https://grip.ravensberg.org/",
"description": "Interactive visualizer mapping the Flemish GRIP information-security & privacy growth path to the Microsoft A3/A5 stack.",
"applicationCategory": "EducationalApplication",
"operatingSystem": "Any",
"browserRequirements": "Requires JavaScript and a modern web browser.",
"inLanguage": ["nl", "en", "fr"],
"isAccessibleForFree": true,
"offers": {
"@type": "Offer",
"price": "0",
"priceCurrency": "EUR"
},
"license": "https://github.com/DevSecNinja/grip-visualizer/blob/main/LICENSE"
}
</script>
</head>
<body>
<div id="root"></div>
Expand Down
Loading
Loading