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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user