test: add the end-to-end suite, on desktop and on a phone

Twenty-six tests across two viewports, covering what the spec asks for:
changing today's hours reaches the panel, a message survives the
translation service being unavailable, a closure period closes the days
it covers — plus the authorisation paths and the sign-out regression.

They run against the standalone build served the way the container
serves it, not `next start`, which refuses to work with standalone
output anyway. The suite therefore exercises the artifact that ships
rather than a second arrangement that could drift from it.

Sign-in mints the session cookie Auth.js would have issued rather than
driving Authentik. What is under test is the application's behaviour for
a given role; the handshake itself is verified against the live provider
separately, and standing up an identity provider per run would trade a
lot of machinery for coverage of somebody else's code. The secret lives
in one module imported by both the config and the fixtures — when it
differed, every signed-in test failed at once while looking like an
authorisation bug.

Database access goes through plain SQL rather than the Prisma client,
whose generated module format Playwright's loader and Next's bundler
disagree about. That traded one problem for a subtler one: node-postgres
parses a DATE column into a local-midnight Date, so reading it back
shifted the day at UTC+2. Dates are read as text now.

The mobile profile runs on Chromium: WebKit needs system packages only
root can install, and a suite nobody can run locally is a suite nobody
runs. The config says how to switch to the real engine.

Two real defects surfaced, both found by the tests rather than by
reading. The seven "Ouvert" checkboxes on the hours page were
indistinguishable to a screen reader; each now names its day. And on a
phone the signed-in address appeared nowhere at all — the header hides
it to save room — so nobody could tell which account was about to sign
an audit entry on a device the shop shares. It is on the dashboard now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012cSY9pVhZmJUKNN7wf1Myd
This commit is contained in:
2026-09-20 23:13:58 +02:00
co-authored by Claude Opus 5
parent f9f11ae88b
commit 446021cd75
13 changed files with 565 additions and 6 deletions
+42
View File
@@ -50,6 +50,48 @@ jobs:
- run: npm run coverage
- run: npm run build
e2e:
name: End-to-end
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_DB: horaires_e2e
POSTGRES_USER: horaires
POSTGRES_PASSWORD: horaires
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U horaires -d horaires_e2e"
--health-interval 5s
--health-timeout 5s
--health-retries 20
env:
DATABASE_URL: postgresql://horaires:horaires@localhost:5432/horaires_e2e?schema=public
TEST_DATABASE_URL: postgresql://horaires:horaires@localhost:5432/horaires_e2e?schema=public
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
cache: npm
- run: npm ci
- run: npx prisma generate
- run: npx prisma migrate deploy
# The runner can install the system packages a local machine cannot.
- run: npx playwright install --with-deps chromium
- run: npm run e2e
- uses: actions/upload-artifact@v4
if: failure()
with:
name: playwright-report
path: playwright-report/
retention-days: 7
docker:
name: Docker image
runs-on: ubuntu-latest
+4
View File
@@ -121,6 +121,10 @@ export default function WeekEditor({ initial, canEdit }: Props) {
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
// Seven checkboxes labelled "Ouvert" are indistinguishable
// to a screen reader; the visible label stays short while
// the accessible name says which day it is.
aria-label={`Ouvert le ${(DAY_NAMES_FR[day.dayOfWeek] ?? '').toLowerCase()}`}
className="size-4 accent-[var(--accent)]"
checked={!day.isClosed}
disabled={!canEdit}
+7 -5
View File
@@ -57,6 +57,13 @@ export default async function AdminHome() {
return (
<main className="mx-auto max-w-4xl px-4 py-10">
<h1 className="text-2xl">Tableau de bord</h1>
<p className="mt-1 text-sm text-[var(--ink-muted)]">
{/* The header hides the address on a narrow screen to save room, so it
belongs here: every change is signed with it in the audit trail,
and several people share the shop's phone. */}
Connecté en tant que {session?.user?.email}
{session?.user?.role === 'admin' ? '' : ' — lecture seule'}.
</p>
{alerts.length > 0 ? (
<ul className="mt-4 space-y-2">
@@ -174,11 +181,6 @@ export default async function AdminHome() {
</p>
</section>
{session?.user?.role !== 'admin' ? (
<p className="mt-8 text-sm text-[var(--ink-muted)]">
Compte en lecture seule&nbsp;: aucune modification n’est possible.
</p>
) : null}
</main>
);
}
+52
View File
@@ -0,0 +1,52 @@
import { expect, resetDatabase, signIn, test } from './fixtures';
test.beforeEach(async () => {
await resetDatabase();
});
test('an anonymous visitor is sent to the sign-in screen', async ({ page }) => {
await page.goto('/admin/horaires');
await expect(page).toHaveURL(/\/login/);
await expect(page.getByRole('button', { name: /Se connecter/ })).toBeVisible();
});
test('an anonymous fetch of an admin route gets a status, not a login page', async ({ request }) => {
const response = await request.post('/api/admin/holidays/sync');
expect(response.status()).toBe(401);
});
test('an administrator reaches the dashboard', async ({ page }) => {
await signIn(page, 'admin');
await page.goto('/admin');
await expect(page.getByRole('heading', { name: 'Tableau de bord' })).toBeVisible();
// The header also carries the address but hides it on a narrow screen, so
// the assertion targets the line under the heading, which is always shown.
await expect(page.getByText(/Connecté en tant que admin@ita-ito\.test/)).toBeVisible();
});
test('a read-only account can look but not change', async ({ page }) => {
await signIn(page, 'viewer');
await page.goto('/admin/horaires');
await expect(page.getByText(/lecture seule/i).first()).toBeVisible();
// The save button is not merely disabled; it is not rendered at all.
await expect(page.getByRole('button', { name: 'Enregistrer' })).toHaveCount(0);
});
test('a read-only account is refused a write by the API', async ({ page, request }) => {
await signIn(page, 'viewer');
const cookies = await page.context().cookies();
const response = await request.post('/api/admin/holidays/sync', {
headers: { cookie: cookies.map((c) => `${c.name}=${c.value}`).join('; ') },
});
expect(response.status()).toBe(403);
});
test('a read-only account can still sign out', async ({ page }) => {
// Regression: gating page POSTs by role broke every form for viewers,
// sign-out included.
await signIn(page, 'viewer');
await page.goto('/admin');
await page.getByRole('button', { name: 'Se déconnecter' }).click();
await expect(page).toHaveURL(/\/login/);
});
+13
View File
@@ -0,0 +1,13 @@
/**
* Values the test process and the server under test must agree on.
*
* The session cookie is signed on one side and verified on the other, so a
* secret that differs between them fails every signed-in test at once while
* looking like an authorisation bug. Defining it here, and importing it from
* both the Playwright config and the fixtures, removes the possibility.
*/
export const E2E_AUTH_SECRET =
process.env.E2E_AUTH_SECRET ?? 'e2e-secret-not-used-anywhere-else';
export const E2E_PORT = 3020;
export const E2E_BASE_URL = `http://127.0.0.1:${E2E_PORT}`;
+114
View File
@@ -0,0 +1,114 @@
import { randomUUID } from 'node:crypto';
import { encode } from '@auth/core/jwt';
import { test as base, type Page } from '@playwright/test';
import { Pool } from 'pg';
import { generateDeviceToken, hashToken } from '../lib/device/auth';
import { E2E_AUTH_SECRET } from './constants';
/**
* Database access for the end-to-end suite, over plain SQL.
*
* Deliberately not the Prisma client: it is generated as a TypeScript module
* whose format Playwright's loader and Next's bundler disagree about, and the
* suite only needs to empty a handful of tables and read a few rows back.
* Raw SQL costs a few lines and removes the argument entirely.
*/
let pool: Pool | undefined;
function db(): Pool {
pool ??= new Pool({ connectionString: process.env.TEST_DATABASE_URL });
return pool;
}
export async function query<T = Record<string, unknown>>(
text: string,
values: unknown[] = [],
): Promise<T[]> {
const result = await db().query(text, values);
return result.rows as T[];
}
/**
* Signing in without Authentik.
*
* Mints the session cookie Auth.js would have issued after a successful round
* trip. What is under test is the application's behaviour for a given role;
* the OIDC handshake is verified against the live provider separately, and
* standing up an identity provider per test run would trade a lot of
* machinery for coverage of somebody else's code.
*/
const COOKIE_NAME = 'authjs.session-token';
export type Role = 'admin' | 'viewer';
export async function signIn(page: Page, role: Role = 'admin'): Promise<void> {
const token = await encode({
secret: E2E_AUTH_SECRET,
// Auth.js derives its encryption key from the cookie name.
salt: COOKIE_NAME,
maxAge: 3600,
token: {
sub: `e2e-${role}`,
name: role === 'admin' ? 'Admin E2E' : 'Viewer E2E',
email: `${role}@ita-ito.test`,
role,
groups: role === 'admin' ? ['horaires-admins'] : ['autre-groupe'],
},
});
await page.context().addCookies([
{ name: COOKIE_NAME, value: token, domain: '127.0.0.1', path: '/', httpOnly: true },
]);
}
const WEEK: [number, boolean, string][] = [
[0, true, '[]'],
[1, true, '[]'],
[2, false, '[{"open":"10:00","close":"18:30"}]'],
[3, false, '[{"open":"10:00","close":"18:30"}]'],
[4, false, '[{"open":"10:00","close":"18:30"}]'],
[5, false, '[{"open":"10:00","close":"18:30"}]'],
[6, false, '[{"open":"10:00","close":"18:30"}]'],
];
/** Empties everything and restores a known week. */
export async function resetDatabase(): Promise<void> {
await query(`
TRUNCATE device_logs, devices, screen_images, schedule_exceptions, vacation_periods,
public_holidays, messages, translation_cache, audit_logs, weekly_schedules,
settings, sync_states RESTART IDENTITY CASCADE
`);
await query(`INSERT INTO settings (id, "updatedAt") VALUES ('singleton', now())`);
for (const [dayOfWeek, isClosed, slots] of WEEK) {
await query(
`INSERT INTO weekly_schedules (id, "dayOfWeek", "isClosed", slots) VALUES ($1, $2, $3, $4::jsonb)`,
[randomUUID(), dayOfWeek, isClosed, slots],
);
}
}
/** Pairs a panel and hands back its token, so the device API can be called. */
export async function pairDevice(): Promise<string> {
const token = generateDeviceToken();
await query(
`INSERT INTO devices (id, "macAddress", "friendlyId", "apiKeyHash", "updatedAt")
VALUES ($1, $2, $3, $4, now())`,
[randomUUID(), 'E2:E0:00:00:00:01', 'E2E001', hashToken(token)],
);
return token;
}
/** Today as the shop reads it, which is not necessarily as the runner does. */
export function today(offsetDays = 0): string {
return new Intl.DateTimeFormat('en-CA', { timeZone: 'Europe/Zurich' }).format(
new Date(Date.now() + offsetDays * 86_400_000),
);
}
export const test = base;
export { expect } from '@playwright/test';
+24
View File
@@ -0,0 +1,24 @@
import 'dotenv/config';
import { E2E_AUTH_SECRET } from './constants';
/**
* Prepares the environment the test process itself runs in.
*
* Loading .env is convenient for TEST_DATABASE_URL, but it also brings the
* real AUTH_SECRET along — which would then differ from the one the server
* under test was started with, and every signed-in test would fail looking
* like an authorisation bug. Pinning it here keeps both sides in agreement.
*
* The suite truncates every table, so an unset TEST_DATABASE_URL is a hard
* stop rather than a fallback to whatever DATABASE_URL happens to point at.
*/
export default function globalSetup(): void {
if (!process.env.TEST_DATABASE_URL) {
throw new Error(
'TEST_DATABASE_URL n’est pas défini. Les tests E2E vident la base : ils refusent de tourner sans base dédiée.',
);
}
process.env.DATABASE_URL = process.env.TEST_DATABASE_URL;
process.env.AUTH_SECRET = E2E_AUTH_SECRET;
}
+61
View File
@@ -0,0 +1,61 @@
import { expect, query, resetDatabase, signIn, test } from './fixtures';
test.beforeEach(async () => {
await resetDatabase();
});
test('a message is saved even when the translation service is unavailable', async ({ page }) => {
// The E2E server has no translation credentials on purpose: losing the
// message because the service is down would be the worst failure here.
await signIn(page, 'admin');
await page.goto('/admin/messages');
// Deliberately not the field's own placeholder text: matching that would
// make the assertion pass before anything had been saved.
const text = 'Nouvelle collection de printemps';
await page.getByLabel(/Texte en français/).fill(text);
await page.getByRole('button', { name: 'Ajouter' }).click();
// Wait for the server's own verdict rather than for the text to appear: it
// is the only signal that the write has actually completed.
await expect(page.getByRole('status')).toContainText(/Message enregistré/);
await expect(page.getByText(text).first()).toBeVisible();
const [stored] = await query<{ textFr: string; translationStatus: string }>(
'SELECT "textFr", "translationStatus" FROM messages',
);
expect(stored?.textFr).toBe(text);
// The E2E server has no translation credentials on purpose: the message
// must survive the service being unavailable.
expect(stored?.translationStatus).toBe('ERROR');
});
test('a translation typed by hand is kept and shown', async ({ page }) => {
await signIn(page, 'admin');
await page.goto('/admin/messages');
await page.getByLabel(/Texte en français/).fill('Fermeture exceptionnelle');
await page.getByRole('button', { name: 'Ajouter' }).click();
await expect(page.getByRole('status')).toContainText(/Message enregistré/);
await page.getByLabel('Traduction anglaise').fill('Exceptionally closed');
await page.getByRole('button', { name: 'Enregistrer' }).click();
await expect(page.getByText(/ne sera plus écrasée/)).toBeVisible();
const [stored] = await query<{ textEn: string; translationStatus: string }>(
'SELECT "textEn", "translationStatus" FROM messages',
);
expect(stored?.textEn).toBe('Exceptionally closed');
expect(stored?.translationStatus).toBe('MANUAL');
});
test('the counter warns before a message stops fitting', async ({ page }) => {
await signIn(page, 'admin');
await page.goto('/admin/messages');
await page.getByLabel(/Texte en français/).fill('a'.repeat(130));
await expect(page.getByText(/caractères de trop/)).toBeVisible();
await expect(page.getByRole('button', { name: 'Ajouter' })).toBeDisabled();
});
+102
View File
@@ -0,0 +1,102 @@
import { expect, pairDevice, query, resetDatabase, signIn, test, today } from './fixtures';
async function panelFilename(request: import('@playwright/test').APIRequestContext, token: string) {
const response = await request.get('/api/display', { headers: { 'Access-Token': token } });
expect(response.ok()).toBeTruthy();
return ((await response.json()) as { filename: string }).filename;
}
test.beforeEach(async () => {
await resetDatabase();
});
test('changing today’s hours reaches the panel', async ({ page, request }) => {
const token = await pairDevice();
const before = await panelFilename(request, token);
await signIn(page, 'admin');
await page.goto('/admin/exceptions');
await page.getByRole('button', { name: /Fermé aujourd’hui/ }).click();
await expect(page.getByText(/La boutique est fermée aujourd’hui/)).toBeVisible();
// Stored as a manual exception for today, in the shop's timezone.
// Read as text: node-postgres parses a DATE column into a local-midnight
// Date, so toISOString() shifts it back a day at UTC+2. The stored value is
// right; the interpretation would not be.
const [exception] = await query<{ date: string; isClosed: boolean }>(
'SELECT date::text AS date, "isClosed" FROM schedule_exceptions',
);
expect(exception?.date).toBe(today());
expect(exception?.isClosed).toBe(true);
// And the panel is handed a different image, so it will redraw.
expect(await panelFilename(request, token)).not.toBe(before);
await page.goto('/admin');
await expect(page.getByText(/aujourd’hui/).first()).toBeVisible();
});
test('the reference week can be edited and shows what will change', async ({ page }) => {
await signIn(page, 'admin');
await page.goto('/admin/horaires');
// Monday is closed in the seeded week; open it.
await page.getByRole('checkbox', { name: /Ouvert le lundi/i }).check();
await expect(page.getByText(/Lundi sera modifié|Sera modifié|1 jour/i).first()).toBeVisible();
await page.getByRole('button', { name: 'Enregistrer' }).click();
await expect(page.getByText(/Horaires enregistrés|enregistrés/i)).toBeVisible();
const [monday] = await query<{ isClosed: boolean }>(
'SELECT "isClosed" FROM weekly_schedules WHERE "dayOfWeek" = 1',
);
expect(monday?.isClosed).toBe(false);
});
test('a closure period closes the days it covers', async ({ page }) => {
const start = today();
const end = today(3);
await signIn(page, 'admin');
await page.goto('/admin/vacances');
await page.getByLabel('Du').fill(start);
await page.getByLabel('Au (inclus)').fill(end);
await page.getByLabel(/Libellé/).fill('Congés de test');
await page.getByRole('button', { name: 'Ajouter' }).click();
await expect(page.getByText(/Période de fermeture enregistrée/)).toBeVisible();
await expect(page.getByText('Congés de test')).toBeVisible();
// The resolver reads the period directly, so the dashboard shows the days
// closed without anything having been written into the exception table.
await page.goto('/admin');
await expect(page.getByText('Congés de test').first()).toBeVisible();
const [{ count }] = await query<{ count: string }>('SELECT count(*) FROM schedule_exceptions');
expect(Number(count)).toBe(0);
});
test('overlapping periods are refused by name', async ({ page }) => {
const start = today();
const end = today(5);
await signIn(page, 'admin');
await page.goto('/admin/vacances');
await page.getByLabel('Du').fill(start);
await page.getByLabel('Au (inclus)').fill(end);
await page.getByLabel(/Libellé/).fill('Première');
await page.getByRole('button', { name: 'Ajouter' }).click();
await expect(page.getByText(/enregistrée/)).toBeVisible();
// The form empties itself once the server answers; refilling before that
// would have the reset wipe what was just typed.
await expect(page.getByLabel(/Libellé/)).toHaveValue('');
await page.getByLabel('Du').fill(start);
await page.getByLabel('Au (inclus)').fill(end);
await page.getByLabel(/Libellé/).fill('Seconde');
await page.getByRole('button', { name: 'Ajouter' }).click();
await expect(page.getByText(/chevauche « Première »/)).toBeVisible();
});
+46
View File
@@ -31,6 +31,7 @@
"@eslint/js": "^9.39.5",
"@fontsource/inter": "^5.3.0",
"@fontsource/source-serif-4": "^5.3.0",
"@playwright/test": "^1.63.0",
"@tailwindcss/postcss": "^4.3.3",
"@types/node": "^26.6.2",
"@types/node-cron": "^3.0.11",
@@ -2178,6 +2179,22 @@
"url": "https://github.com/sponsors/panva"
}
},
"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==",
"devOptional": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.63.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/@prisma/adapter-pg": {
"version": "7.10.0",
"resolved": "https://registry.npmjs.org/@prisma/adapter-pg/-/adapter-pg-7.10.0.tgz",
@@ -8949,6 +8966,35 @@
"integrity": "sha512-cKUSoKa8YxFZZSmraVi7onONx3amu77ngK3kGpsYHDH7drPwCRkQE1RYMPlLRrMtnciRj274XNRxcHxnKmDSnA==",
"license": "MIT"
},
"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==",
"devOptional": 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==",
"devOptional": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/possible-typed-array-names": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
+3 -1
View File
@@ -20,7 +20,8 @@
"brand": "tsx scripts/brand.ts",
"e2e": "playwright test",
"holidays:sync": "tsx scripts/sync-holidays.ts",
"translate:test": "tsx scripts/test-translation.ts"
"translate:test": "tsx scripts/test-translation.ts",
"e2e:ui": "playwright test --ui"
},
"dependencies": {
"@fontsource-variable/inter": "^5.3.0",
@@ -45,6 +46,7 @@
"@eslint/js": "^9.39.5",
"@fontsource/inter": "^5.3.0",
"@fontsource/source-serif-4": "^5.3.0",
"@playwright/test": "^1.63.0",
"@tailwindcss/postcss": "^4.3.3",
"@types/node": "^26.6.2",
"@types/node-cron": "^3.0.11",
+80
View File
@@ -0,0 +1,80 @@
// Loaded here, not in globalSetup: this file is evaluated first, and the
// webServer environment below is read from it. Without this, TEST_DATABASE_URL
// is still undefined when the server is started and every page fails on a
// missing DATABASE_URL.
import 'dotenv/config';
import { defineConfig, devices } from '@playwright/test';
import { E2E_AUTH_SECRET, E2E_BASE_URL, E2E_PORT } from './e2e/constants';
/**
* End-to-end tests.
*
* They run against a real server and a real database — the E2E database, which
* the global setup empties. That is the whole point: these are the only tests
* that exercise the middleware, the server actions and the rendering together,
* the way a person in the shop does.
*
* Port 3020 so a development server on 3010 can stay running.
*/
const PORT = E2E_PORT;
const BASE_URL = E2E_BASE_URL;
export default defineConfig({
testDir: './e2e',
fullyParallel: false,
workers: 1,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 1 : 0,
reporter: process.env.CI ? [['github'], ['list']] : 'list',
globalSetup: './e2e/global-setup.ts',
use: {
baseURL: BASE_URL,
trace: 'retain-on-failure',
screenshot: 'only-on-failure',
locale: 'fr-CH',
timezoneId: 'Europe/Zurich',
},
projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
{
// The shop uses a phone behind the counter, so the narrow viewport is
// the primary case, not an afterthought.
//
// Run on Chromium rather than WebKit: WebKit needs system packages that
// only root can install, and a suite nobody can run locally is a suite
// nobody runs. This still covers the layout, the touch targets and the
// mobile keyboard types. To exercise the real Safari engine — worth
// doing before trusting an iPhone-only bug report — install the
// dependencies once (`sudo npx playwright install-deps webkit`) and drop
// the browserName override.
name: 'mobile',
use: { ...devices['iPhone 15'], browserName: 'chromium' },
},
],
webServer: {
// A production build rather than the dev server: it is what actually ships,
// and Next refuses to start a second dev server in the same directory, so
// this also lets the suite run while someone is developing.
command: 'sh scripts/e2e-server.sh',
url: `${BASE_URL}/api/health`,
reuseExistingServer: !process.env.CI,
timeout: 120_000,
env: {
PORT: String(PORT),
NODE_ENV: 'production',
// Never the development database: the setup empties this one.
DATABASE_URL: process.env.TEST_DATABASE_URL ?? '',
AUTH_URL: BASE_URL,
AUTH_SECRET: E2E_AUTH_SECRET,
AUTHENTIK_ADMIN_GROUP: 'horaires-admins',
// The translation service is stubbed per-test; never called for real.
TRANSLATION_API_URL: '',
TRANSLATION_API_KEY: '',
},
},
});
+17
View File
@@ -0,0 +1,17 @@
#!/bin/sh
# Builds and serves the application exactly as the container does.
#
# `next start` refuses to work with output: standalone, and running the
# standalone server directly is what the Dockerfile does anyway — so the E2E
# suite exercises the same artifact shape that ships, rather than a second
# arrangement that could drift from it.
set -e
npm run build
# The standalone bundle does not carry these; the Dockerfile copies them too.
rm -rf .next/standalone/.next/static .next/standalone/public
cp -r .next/static .next/standalone/.next/static
cp -r public .next/standalone/public
exec node .next/standalone/server.js