import path from "node:path"; import { pathToFileURL } from "node:url"; import { expect, test } from "@playwright/test"; const PERSONA_CODES = ["p4", "p5", "p6", "p7"] as const; test.describe("first-party Live2D assets", () => { test("serves model3 manifests and exp3 expressions for every persona", async ({ request }) => { const indexResponse = await request.get("/live2d/personas/index.json"); expect(indexResponse.ok(), await indexResponse.text()).toBeTruthy(); const index = (await indexResponse.json()) as { personas: { code: string; model3: string; expressionCount: number }[]; }; expect(index.personas).toHaveLength(4); for (const code of PERSONA_CODES) { const modelResponse = await request.get(`/live2d/personas/${code}/${code}.model3.json`); expect(modelResponse.ok(), await modelResponse.text()).toBeTruthy(); const model = (await modelResponse.json()) as { Version: number; Vignette: { ModelId: string; PersonaCode: string }; FileReferences: { Expressions: { Name: string; File: string }[] }; }; expect(model.Version).toBe(3); expect(model.Vignette.ModelId).toBe(`vignette-${code}-live2d`); expect(model.Vignette.PersonaCode.toLowerCase()).toBe(code); expect(model.FileReferences.Expressions.length).toBeGreaterThanOrEqual(20); expect(model.FileReferences.Expressions.map((expression) => expression.Name)).toEqual( expect.arrayContaining(["joy", "sad", "angry", "rage"]), ); const rage = model.FileReferences.Expressions.find((expression) => expression.Name === "rage"); expect(rage, `${code} rage expression`).toBeTruthy(); const expressionResponse = await request.get(`/live2d/personas/${code}/${rage!.File}`); expect(expressionResponse.ok(), await expressionResponse.text()).toBeTruthy(); const expression = (await expressionResponse.json()) as { Type: string; Version: number; FadeInTime: number; FadeOutTime: number; Parameters: { Id: string; Value: number; Blend: string }[]; }; expect(expression.Type).toBe("Live2D Expression"); expect(expression.Version).toBe(3); expect(expression.FadeInTime).toBe(0.26); expect(expression.FadeOutTime).toBe(0.32); expect(expression.Parameters.length).toBeGreaterThanOrEqual(12); expect(expression.Parameters.map((parameter) => parameter.Id)).toEqual( expect.arrayContaining(["ParamMouthOpenY", "ParamMouthForm", "ParamEyeLOpen"]), ); } }); test("keeps legacy demo paths blocked while allowing first-party persona assets at the Pages function", async () => { const functionPath = path.resolve("functions", "live2d", "[[path]].js"); const { onRequest } = (await import(pathToFileURL(functionPath).href)) as { onRequest: (context: { request: Request; params: { path: string[] }; env: { ASSETS: { fetch: (request: Request) => Promise } }; }) => Promise; }; const allowed = await onRequest({ request: new Request("https://vignette.example/live2d/personas/p4/p4.model3.json"), params: { path: ["personas", "p4", "p4.model3.json"] }, env: { ASSETS: { fetch: async () => new Response("{}", { status: 200, headers: { "content-type": "application/json" } }), }, }, }); expect(allowed.status).toBe(200); expect(allowed.headers.get("x-robots-tag")).toBe("noindex"); const legacy = await onRequest({ request: new Request("https://vignette.example/live2d/mao/Mao.model3.json"), params: { path: ["mao", "Mao.model3.json"] }, env: { ASSETS: { fetch: async () => new Response("should not be called", { status: 200 }), }, }, }); expect(legacy.status).toBe(404); const traversal = await onRequest({ request: new Request("https://vignette.example/live2d/personas/p4/../mao/Mao.model3.json"), params: { path: ["personas", "p4", "..", "mao", "Mao.model3.json"] }, env: { ASSETS: { fetch: async () => new Response("should not be called", { status: 200 }), }, }, }); expect(traversal.status).toBe(404); }); });