first commit

This commit is contained in:
2026-07-21 16:50:58 +02:00
commit 256599626e
407 changed files with 30489 additions and 0 deletions
+95
View File
@@ -0,0 +1,95 @@
import { mkdir, writeFile } from "node:fs/promises";
import path from "node:path";
import { expect, test } from "@playwright/test";
import AxeBuilder from "@axe-core/playwright";
const qualityOutDir = path.resolve(process.cwd(), "quality-out");
async function writeA11ySummary(violationCount: number, violationIds: string[]): Promise<void> {
await mkdir(qualityOutDir, { recursive: true });
const payload = {
axeViolationCount: violationCount,
axeViolationIds: violationIds,
recordedAt: new Date().toISOString()
};
await writeFile(path.join(qualityOutDir, "a11y-summary.json"), JSON.stringify(payload, null, 2), "utf8");
}
test.describe("Construire un bento en 3 étapes", () => {
test("builder flow and accessibility baseline", async ({ page }) => {
await page.goto("/");
const firstBase = page.locator(".cards button.card").first();
await firstBase.waitFor({ state: "visible" });
await firstBase.click();
await page.getByRole("button", { name: /Continuer|Continue|계속|继续/i }).click();
const firstVariant = page.locator(".cards button.card").first();
await firstVariant.waitFor({ state: "visible" });
await expect(page.locator(".builder-variant-base-reminder")).toBeVisible();
await firstVariant.click();
await page.getByRole("button", { name: /Continuer|Continue|계속|继续/i }).click();
await expect(page.locator("#recipe-sheet-title")).toBeVisible();
await expect(page.getByRole("heading", { name: /Ingrédients|Ingredients|재료|食材/i })).toBeVisible();
const firstSpriteImg = page.locator(".recipe-sheet__ingredient-sprite img").first();
await expect(firstSpriteImg).toBeVisible();
await expect
.poll(async () => firstSpriteImg.evaluate((el: HTMLImageElement) => el.naturalWidth > 0 && el.naturalHeight > 0), {
timeout: 15_000
})
.toBeTruthy();
const accessibilityScanResults = await new AxeBuilder({ page }).analyze();
await writeA11ySummary(
accessibilityScanResults.violations.length,
accessibilityScanResults.violations.map((v) => v.id)
);
expect(accessibilityScanResults.violations).toEqual([]);
});
});
test.describe("Partage et URL recette", () => {
test("recipe share URL opens recipe view", async ({ page }) => {
await page.goto("/r/base%3Aonigiri/variant%3Aonigiri-kimchi-mozza");
await expect(page.locator("#recipe-sheet-title")).toBeVisible({ timeout: 20_000 });
await expect(page.getByRole("heading", { name: /Ingrédients|Ingredients|재료|食材/i })).toBeVisible();
});
test("HTML initial contient les métadonnées Open Graph recette", async ({ page }) => {
await page.goto("/r/base%3Aonigiri/variant%3Aonigiri-kimchi-mozza");
await expect(page.locator('meta[property="og:type"]')).toHaveAttribute("content", "article");
await expect(page.locator('meta[property="og:title"]')).toHaveAttribute("content", /Onigiri/);
await expect(page.locator('meta[property="og:image"]')).toHaveAttribute("content", /^https?:\/\//);
await expect(page.locator('meta[property="og:url"]')).toHaveAttribute(
"content",
/\/r\/base%3Aonigiri\/variant%3Aonigiri-kimchi-mozza$/
);
await expect(page.locator('meta[name="twitter:card"]')).toHaveAttribute(
"content",
"summary_large_image"
);
});
});
test.describe("Performance (sanity)", () => {
test("navigation home produit des métriques de timing", async ({ page }) => {
await page.goto("/");
await page.waitForLoadState("domcontentloaded");
const timing = await page.evaluate(() => {
const nav = performance.getEntriesByType("navigation")[0] as PerformanceNavigationTiming | undefined;
return nav
? {
domContentLoaded: nav.domContentLoadedEventEnd - nav.startTime,
loadEventEnd: nav.loadEventEnd - nav.startTime
}
: null;
});
await mkdir(qualityOutDir, { recursive: true });
await writeFile(
path.join(qualityOutDir, "perf-summary.json"),
JSON.stringify({ navigationTiming: timing, recordedAt: new Date().toISOString() }, null, 2),
"utf8"
);
expect(timing).not.toBeNull();
});
});
+24
View File
@@ -0,0 +1,24 @@
import { expect, test } from "@playwright/test";
const CONSENT_STORAGE_KEY = "ben-to-cookie-consent-v1";
const WELCOME_TOUR_STORAGE_KEY = "ben-to.welcome-tour-dismissed";
test.describe("Bandeau cookies / analytics", () => {
test.beforeEach(async ({ context }) => {
await context.addInitScript(
(keys: { consent: string; tour: string }) => {
localStorage.removeItem(keys.consent);
localStorage.setItem(keys.tour, "v1");
},
{ consent: CONSENT_STORAGE_KEY, tour: WELCOME_TOUR_STORAGE_KEY }
);
});
test("le bandeau saffiche puis disparaît après acceptation", async ({ page }) => {
await page.goto("/");
const banner = page.locator(".cookie-consent");
await expect(banner).toBeVisible();
await page.getByRole("button", { name: /Accepter|Accept|동의|同意/i }).click();
await expect(banner).toBeHidden();
});
});
+53
View File
@@ -0,0 +1,53 @@
import { expect, test } from "@playwright/test";
/**
* En-têtes de sécurité émis par le dev server Vite via `cspPlugin` (middleware).
* Vérifie qu'au moins un GET racine et qu'une URL recette partagent les en-têtes durcis.
*/
test.describe("En-têtes de sécurité (dev server)", () => {
test("/ renvoie CSP + en-têtes de sécurité", async ({ request }) => {
const res = await request.get("/", {
headers: { Accept: "text/html,*/*" }
});
expect(res.status()).toBe(200);
const headers = res.headers();
expect(headers["content-security-policy"]).toBeDefined();
expect(headers["content-security-policy"]).toContain("default-src 'self'");
expect(headers["content-security-policy"]).toContain("frame-ancestors 'none'");
expect(headers["x-content-type-options"]).toBe("nosniff");
expect(headers["x-frame-options"]).toBe("DENY");
expect(headers["referrer-policy"]).toBe("strict-origin-when-cross-origin");
expect(headers["permissions-policy"]).toContain("camera=()");
});
test("/r/:base/:variant renvoie les mêmes en-têtes", async ({ request }) => {
const res = await request.get("/r/base%3Aonigiri/variant%3Aonigiri-kimchi-mozza", {
headers: { Accept: "text/html,*/*" }
});
expect(res.status()).toBe(200);
const headers = res.headers();
expect(headers["content-security-policy"]).toBeDefined();
expect(headers["content-security-policy"]).toContain("frame-ancestors 'none'");
expect(headers["x-frame-options"]).toBe("DENY");
});
test("la meta CSP est présente dans le HTML initial", async ({ page }) => {
await page.goto("/");
const cspContent = await page
.locator('meta[http-equiv="Content-Security-Policy"]')
.getAttribute("content");
expect(cspContent).toBeTruthy();
expect(cspContent!).toContain("default-src 'self'");
expect(cspContent!).toContain("frame-ancestors 'none'");
expect(cspContent!).toContain("object-src 'none'");
});
test("la meta CSP est présente sur une page recette", async ({ page }) => {
await page.goto("/r/base%3Aonigiri/variant%3Aonigiri-kimchi-mozza");
const cspContent = await page
.locator('meta[http-equiv="Content-Security-Policy"]')
.getAttribute("content");
expect(cspContent).toBeTruthy();
expect(cspContent!).toContain("frame-ancestors 'none'");
});
});
+29
View File
@@ -0,0 +1,29 @@
import { expect, test } from "@playwright/test";
const CONSENT_STORAGE_KEY = "ben-to-cookie-consent-v1";
const WELCOME_TOUR_STORAGE_KEY = "ben-to.welcome-tour-dismissed";
test.describe("Tour d'accueil première visite", () => {
test.beforeEach(async ({ context }) => {
await context.addInitScript(
(keys: { consent: string; tour: string }) => {
localStorage.setItem(keys.consent, "analytics-rejected");
localStorage.removeItem(keys.tour);
},
{ consent: CONSENT_STORAGE_KEY, tour: WELCOME_TOUR_STORAGE_KEY }
);
});
test("modale visible sur / puis fermeture via bouton principal", async ({ page }) => {
await page.goto("/");
const dialog = page.locator("#welcome-tour-dialog");
await expect(dialog).toBeVisible({ timeout: 15_000 });
await page.getByRole("button", { name: /Commencer|Get started|시작하기|开始使用/i }).click();
await expect(dialog).toBeHidden();
});
test("pas de modale sur une URL recette partagée", async ({ page }) => {
await page.goto("/r/base%3Aonigiri/variant%3Aonigiri-kimchi-mozza");
await expect(page.locator("#welcome-tour-dialog")).toHaveCount(0);
});
});
+110
View File
@@ -0,0 +1,110 @@
import { execFileSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import { expect, test } from "@playwright/test";
const repoRoot = process.cwd();
const gifFlow = path.join(repoRoot, "apps/web/public/tour/flow-builder.gif");
const gifRecipe = path.join(repoRoot, "apps/web/public/tour/recipe-sheet.gif");
const assertFfmpeg = () => {
try {
execFileSync("ffmpeg", ["-version"], { stdio: "ignore" });
} catch {
throw new Error("ffmpeg est requis pour npm run tour:capture (ex. apt install ffmpeg)");
}
};
const webmToGif = (webm: string, gif: string) => {
execFileSync(
"ffmpeg",
[
"-y",
"-i",
webm,
"-vf",
"fps=8,scale=360:-1:flags=lanczos,split[s0][s1];[s0]palettegen=max_colors=128:stats_mode=single[p];[s1][p]paletteuse=dither=bayer",
gif
],
{ stdio: "inherit" }
);
};
const seedStableSession = async (context: import("@playwright/test").BrowserContext) => {
await context.addInitScript(() => {
localStorage.setItem("ben-to-cookie-consent-v1", "analytics-rejected");
localStorage.setItem("ben-to.welcome-tour-dismissed", "v1");
});
};
test.describe.configure({ mode: "serial" });
test.describe("GIFs public/tour (Playwright + ffmpeg)", () => {
test("flow-builder.gif", async ({ browser }, testInfo) => {
assertFfmpeg();
fs.mkdirSync(path.dirname(gifFlow), { recursive: true });
const videoDir = testInfo.outputPath("flow-video");
fs.rmSync(videoDir, { recursive: true, force: true });
fs.mkdirSync(videoDir, { recursive: true });
const context = await browser.newContext({
recordVideo: { dir: videoDir, size: { width: 390, height: 760 } },
viewport: { width: 390, height: 760 }
});
await seedStableSession(context);
const page = await context.newPage();
await page.goto("/");
const firstBase = page.locator(".cards button.card").first();
await firstBase.waitFor({ state: "visible" });
await firstBase.click();
await page.getByRole("button", { name: /Continuer|Continue|계속|继续/i }).click();
await expect(page.locator(".builder-variant-base-reminder")).toBeVisible({ timeout: 25_000 });
const firstVariant = page.locator(".cards button.card").first();
await firstVariant.waitFor({ state: "visible" });
await firstVariant.click();
await page.waitForTimeout(500);
/* Sarrête ici (étape garniture) pour ne pas dupliquer la fiche recette du second GIF. */
const video = page.video();
await page.close();
const webm = await video?.path();
await context.close();
if (!webm) throw new Error("Aucun fichier vidéo Playwright");
webmToGif(webm, gifFlow);
});
test("recipe-sheet.gif", async ({ browser }, testInfo) => {
assertFfmpeg();
fs.mkdirSync(path.dirname(gifRecipe), { recursive: true });
const videoDir = testInfo.outputPath("recipe-video");
fs.rmSync(videoDir, { recursive: true, force: true });
fs.mkdirSync(videoDir, { recursive: true });
const context = await browser.newContext({
recordVideo: { dir: videoDir, size: { width: 390, height: 760 } },
viewport: { width: 390, height: 760 }
});
await seedStableSession(context);
const page = await context.newPage();
await page.goto("/r/base%3Aonigiri/variant%3Aonigiri-kimchi-mozza");
await expect(page.locator("#recipe-sheet-title")).toBeVisible({ timeout: 25_000 });
const sheet = page.locator("section.recipe-sheet");
await page.waitForTimeout(400);
await sheet.evaluate((el) => {
el.scrollTo({ top: 0, behavior: "auto" });
});
await page.waitForTimeout(450);
await sheet.evaluate((el) => {
el.scrollTo({ top: 400, behavior: "auto" });
});
await page.waitForTimeout(450);
await sheet.evaluate((el) => {
el.scrollTo({ top: 900, behavior: "auto" });
});
await page.waitForTimeout(450);
const video = page.video();
await page.close();
const webm = await video?.path();
await context.close();
if (!webm) throw new Error("Aucun fichier vidéo Playwright");
webmToGif(webm, gifRecipe);
});
});