first commit
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 s’affiche 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();
|
||||
});
|
||||
});
|
||||
@@ -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'");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
/* S’arrê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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user