1504 lines
46 KiB
JavaScript
1504 lines
46 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
import { spawn } from "node:child_process";
|
|
import fs from "node:fs/promises";
|
|
import fsSync from "node:fs";
|
|
import http from "node:http";
|
|
import path from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import { chromium } from "playwright";
|
|
import { mockApi, primeMockSession, createPosFixture } from "../../tests/e2e/support/network.js";
|
|
|
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
const projectRoot = path.resolve(__dirname, "../..");
|
|
const workspaceRoot = fsSync.existsSync("/home/jeppe/pleno-workspace/frontend/package.json")
|
|
? "/home/jeppe/pleno-workspace"
|
|
: path.resolve(projectRoot, "..");
|
|
const outputRoot = path.join(workspaceRoot, "playstoregraphics");
|
|
const baseUrl = process.env.PLAYSTORE_GRAPHICS_BASE_URL || "http://127.0.0.1:5179";
|
|
const devPort = new URL(baseUrl).port || "5179";
|
|
const devHost = new URL(baseUrl).hostname || "127.0.0.1";
|
|
const generatedAt = new Date().toISOString();
|
|
|
|
const disallowedRoutePatterns = [
|
|
/^\/admin(?:\/|$)/i,
|
|
/^\/superuser(?:\/|$)/i,
|
|
/^\/__e2e(?:\/|$)/i,
|
|
/^\/search\/system(?:\/|$)/i,
|
|
/edge-?gateway/i,
|
|
/terminal/i,
|
|
/\/pos(?:\/|$)/i,
|
|
/configuration/i,
|
|
/backoffice/i,
|
|
];
|
|
|
|
const requiredDirectories = [
|
|
"universal/app-icon",
|
|
"universal/feature-graphic",
|
|
"phone/screenshots",
|
|
"tablet-7/screenshots",
|
|
"tablet-10/screenshots",
|
|
"chromebook/screenshots",
|
|
"metadata",
|
|
];
|
|
|
|
const deviceProfiles = [
|
|
{
|
|
key: "phone",
|
|
screenshotDir: "phone/screenshots",
|
|
viewport: { width: 360, height: 640 },
|
|
deviceScaleFactor: 3,
|
|
isMobile: true,
|
|
hasTouch: true,
|
|
expected: { width: 1080, height: 1920 },
|
|
},
|
|
{
|
|
key: "tablet-7",
|
|
screenshotDir: "tablet-7/screenshots",
|
|
viewport: { width: 720, height: 1280 },
|
|
deviceScaleFactor: 2,
|
|
isMobile: true,
|
|
hasTouch: true,
|
|
expected: { width: 1440, height: 2560 },
|
|
},
|
|
{
|
|
key: "tablet-10",
|
|
screenshotDir: "tablet-10/screenshots",
|
|
viewport: { width: 720, height: 1280 },
|
|
deviceScaleFactor: 2.5,
|
|
isMobile: true,
|
|
hasTouch: true,
|
|
expected: { width: 1800, height: 3200 },
|
|
},
|
|
{
|
|
key: "chromebook",
|
|
screenshotDir: "chromebook/screenshots",
|
|
viewport: { width: 1920, height: 1080 },
|
|
deviceScaleFactor: 1,
|
|
isMobile: false,
|
|
hasTouch: false,
|
|
expected: { width: 1920, height: 1080 },
|
|
},
|
|
];
|
|
|
|
const screenshotScenes = [
|
|
{
|
|
key: "dashboard",
|
|
fileName: "01-dashboard.jpg",
|
|
path: "/user",
|
|
altText: "Truck Wash customer dashboard with shortcuts for booking, vehicles, wash certificates, and invoices.",
|
|
mode: "pos",
|
|
waitFor: async (page) => {
|
|
await page.locator("a#book-wash-button").waitFor({ state: "visible", timeout: 20_000 });
|
|
},
|
|
},
|
|
{
|
|
key: "wash-setup",
|
|
fileName: "02-wash-setup.jpg",
|
|
path: "/user/wash/start",
|
|
altText: "Self-service wash start screen with department, vehicle registration, and vehicle type selection.",
|
|
mode: "selfServe",
|
|
waitFor: async (page) => {
|
|
await page.getByTestId("self-serve-department-name").waitFor({ state: "visible", timeout: 20_000 });
|
|
await page.getByTestId("self-serve-registration").fill("TW12345");
|
|
await page.getByTestId("self-serve-registration").press("Escape");
|
|
await page.getByTestId("self-serve-vehicle-type-2").click();
|
|
await page.waitForTimeout(350);
|
|
},
|
|
},
|
|
{
|
|
key: "wash-lane",
|
|
fileName: "03-wash-lane.jpg",
|
|
path: "/user/wash/start",
|
|
altText: "Self-service wash lane screen with available wash lane and manual or machine wash choices.",
|
|
mode: "selfServe",
|
|
waitFor: async (page) => {
|
|
await page.getByTestId("self-serve-department-name").waitFor({ state: "visible", timeout: 20_000 });
|
|
await page.getByTestId("self-serve-registration").fill("TW12345");
|
|
await page.getByTestId("self-serve-registration").press("Escape");
|
|
await page.getByTestId("self-serve-vehicle-type-2").click();
|
|
await page.getByTestId("self-serve-nav-next").click();
|
|
await page.getByTestId("self-serve-question-11").waitFor({ state: "visible", timeout: 20_000 });
|
|
await page.getByTestId("self-serve-question-11-yes").click();
|
|
await page.getByTestId("self-serve-nav-confirm").click();
|
|
await page.getByTestId("self-serve-lane-step").waitFor({ state: "visible", timeout: 20_000 });
|
|
await page.getByTestId("self-serve-lane-option-7").click();
|
|
await page.getByTestId("self-serve-wash-type-machine").click();
|
|
await page.waitForTimeout(350);
|
|
},
|
|
},
|
|
{
|
|
key: "booking",
|
|
fileName: "04-booking.jpg",
|
|
path: "/user/bookings/book",
|
|
altText: "Truck Wash booking flow for choosing wash hall, vehicle, service, and time.",
|
|
mode: "pos",
|
|
deviceOverrides: {
|
|
"tablet-7": {
|
|
viewport: { width: 720, height: 1280 },
|
|
deviceScaleFactor: 2,
|
|
isMobile: true,
|
|
hasTouch: true,
|
|
},
|
|
"tablet-10": {
|
|
viewport: { width: 720, height: 1280 },
|
|
deviceScaleFactor: 2.5,
|
|
isMobile: true,
|
|
hasTouch: true,
|
|
},
|
|
},
|
|
waitFor: async (page, device) => {
|
|
await page
|
|
.locator('[data-testid="booking-mobile-department-trigger"], #department-option-12')
|
|
.first()
|
|
.waitFor({ state: "visible", timeout: 20_000 });
|
|
|
|
const mobileDepartmentTrigger = page.getByTestId("booking-mobile-department-trigger");
|
|
if (await mobileDepartmentTrigger.isVisible().catch(() => false)) {
|
|
await mobileDepartmentTrigger.click();
|
|
await page.getByTestId("booking-mobile-department-option-12").click();
|
|
await page.getByTestId("booking-mobile-next").click();
|
|
} else {
|
|
const desktopDepartmentOption = page.locator("#department-option-12");
|
|
if (await desktopDepartmentOption.isVisible().catch(() => false)) {
|
|
await desktopDepartmentOption.click();
|
|
}
|
|
}
|
|
|
|
const vehicleButton = page.locator('button[id="reg1-button"]:visible').first();
|
|
if (await vehicleButton.isVisible().catch(() => false)) {
|
|
await vehicleButton.click();
|
|
}
|
|
const vehicleInput = page.locator('input[id="reg1-input"]:visible').first();
|
|
if (await vehicleInput.isVisible().catch(() => false)) {
|
|
await vehicleInput.fill("TW12345");
|
|
await vehicleInput.press("Enter");
|
|
}
|
|
|
|
const mobileNextButton = page.getByTestId("booking-mobile-next");
|
|
if (await mobileNextButton.isVisible().catch(() => false)) {
|
|
await page.waitForFunction(
|
|
() => {
|
|
const nextButton = document.querySelector('[data-testid="booking-mobile-next"]');
|
|
return /TW12345/i.test(document.body?.innerText || "") && nextButton && nextButton.disabled === false;
|
|
},
|
|
null,
|
|
{ timeout: 20_000 }
|
|
);
|
|
await mobileNextButton.click();
|
|
|
|
const productCategorySelect = page.getByTestId("pos-product-category-select");
|
|
if (await productCategorySelect.isVisible().catch(() => false)) {
|
|
await productCategorySelect.selectOption("4");
|
|
}
|
|
await page.getByTestId("pos-product-card-53").waitFor({ state: "visible", timeout: 20_000 });
|
|
await page.getByTestId("pos-product-card-53").click();
|
|
await page.waitForFunction(() => /Udvendig vask|Vaskecertifikat|649/.test(document.body?.innerText || ""), null, {
|
|
timeout: 20_000,
|
|
});
|
|
|
|
if (device?.key === "phone") {
|
|
await page.waitForFunction(
|
|
() => {
|
|
const nextButton = document.querySelector('[data-testid="booking-mobile-next"]');
|
|
return /Trin 3 af 4/i.test(document.body?.innerText || "") && nextButton && nextButton.disabled === false;
|
|
},
|
|
null,
|
|
{ timeout: 20_000 }
|
|
);
|
|
await page.getByTestId("booking-mobile-next").click();
|
|
await page.getByTestId("booking-date-time-selector-mobile").waitFor({ state: "visible", timeout: 20_000 });
|
|
await page.getByTestId("booking-date-time-slot-09-00").waitFor({ state: "visible", timeout: 20_000 });
|
|
}
|
|
} else {
|
|
await page.waitForFunction(
|
|
() => /TW12345|Tilføj registreringsnummer|vælg køretøj|select vehicle/i.test(document.body?.innerText || ""),
|
|
null,
|
|
{ timeout: 20_000 }
|
|
);
|
|
}
|
|
await page.waitForTimeout(800);
|
|
},
|
|
},
|
|
{
|
|
key: "vehicles",
|
|
fileName: "05-vehicles.jpg",
|
|
path: "/user/vehicles",
|
|
altText: "Customer vehicle overview showing registered vehicles for Truck Wash services.",
|
|
mode: "pos",
|
|
waitFor: async (page) => {
|
|
await page.getByTestId("user-vehicles-table").waitFor({ state: "visible", timeout: 20_000 });
|
|
},
|
|
},
|
|
{
|
|
key: "wash-history",
|
|
fileName: "06-wash-history.jpg",
|
|
path: "/user/orders",
|
|
altText: "Wash history overview with recent washes and certificate access.",
|
|
mode: "pos",
|
|
waitFor: async (page) => {
|
|
await page.getByText(/Historiske vaske/i).first().waitFor({ state: "visible", timeout: 20_000 });
|
|
await page.waitForFunction(() => /TW12345|TW67890/.test(document.body?.innerText || ""), null, {
|
|
timeout: 20_000,
|
|
});
|
|
await page.waitForTimeout(500);
|
|
},
|
|
},
|
|
];
|
|
|
|
const appIconOutput = path.join(outputRoot, "universal/app-icon/truck-wash-icon-512.png");
|
|
const featureGraphicOutput = path.join(outputRoot, "universal/feature-graphic/truck-wash-feature-1024x500.jpg");
|
|
const iconSource = path.join(projectRoot, "public/icons/icon-512x512.png");
|
|
const wordmarkSource = path.join(projectRoot, "src/assets/branding/truckwash-banner-white-compressed.png");
|
|
|
|
const featureGraphicVariants = [
|
|
{
|
|
key: "self-serve",
|
|
fileName: "truck-wash-feature-self-serve-1024x500.jpg",
|
|
title: "Selvvask på få trin",
|
|
body: "Vælg køretøj, vaskeprogram og bane direkte fra mobilen.",
|
|
eyebrow: "Selvvask",
|
|
accent: "#ffb833",
|
|
background:
|
|
"linear-gradient(135deg, rgba(5, 123, 160, 0.94), rgba(5, 50, 75, 0.98) 54%, rgba(4, 29, 44, 1)), #063651",
|
|
screenshots: ["phone/screenshots/02-wash-setup.jpg", "phone/screenshots/03-wash-lane.jpg"],
|
|
altText: "Truck Wash self-service wash flow shown with vehicle and lane selection screens.",
|
|
},
|
|
{
|
|
key: "booking",
|
|
fileName: "truck-wash-feature-booking-1024x500.jpg",
|
|
title: "Book truckvask",
|
|
body: "Vælg vask og tidspunkt direkte fra mobilen.",
|
|
eyebrow: "Booking",
|
|
accent: "#2dd4bf",
|
|
background:
|
|
"linear-gradient(135deg, rgba(10, 92, 118, 0.96), rgba(7, 58, 86, 0.98) 50%, rgba(5, 38, 58, 1)), #073a56",
|
|
screenshots: ["phone/screenshots/04-booking.jpg", "phone/screenshots/01-dashboard.jpg"],
|
|
altText: "Truck Wash booking flow shown with date and time selection and dashboard screens.",
|
|
},
|
|
{
|
|
key: "overview",
|
|
fileName: "truck-wash-feature-overview-1024x500.jpg",
|
|
title: "Overblik og dokumentation",
|
|
body: "Find tidligere vaske, køretøjer og vaskedokumentation samlet ét sted.",
|
|
eyebrow: "Dokumentation",
|
|
accent: "#8fd14f",
|
|
background:
|
|
"linear-gradient(135deg, rgba(5, 106, 102, 0.95), rgba(7, 56, 83, 0.98) 52%, rgba(6, 36, 55, 1)), #073853",
|
|
screenshots: ["phone/screenshots/06-wash-history.jpg", "phone/screenshots/05-vehicles.jpg"],
|
|
altText: "Truck Wash overview screens showing wash history and registered vehicles.",
|
|
},
|
|
];
|
|
|
|
const demoCustomerNumber = 12345679;
|
|
const demoSession = {
|
|
id: 101,
|
|
customer_number: demoCustomerNumber,
|
|
display_name: "Demo Driver",
|
|
email: "demo.driver@example.test",
|
|
phone: {
|
|
number: "00000000",
|
|
country_code: 45,
|
|
},
|
|
permissions: ["user"],
|
|
economic_customer: [],
|
|
two_factor_enabled: false,
|
|
};
|
|
|
|
const today = new Date();
|
|
const datePart = today.toISOString().slice(0, 10);
|
|
const nextWeek = new Date(today);
|
|
nextWeek.setDate(today.getDate() + 7);
|
|
const nextWeekDatePart = nextWeek.toISOString().slice(0, 10);
|
|
|
|
const products = [
|
|
{
|
|
id: 53,
|
|
name: "Udvendig vask",
|
|
description: "Exterior truck wash",
|
|
price: 649,
|
|
subscription_allowed: true,
|
|
category: 4,
|
|
piktogram: "3",
|
|
apply_category_discount: true,
|
|
requires_note: false,
|
|
is_wash: true,
|
|
display_in_booking_form: true,
|
|
order_priority: 1,
|
|
addons: [],
|
|
},
|
|
{
|
|
id: 63,
|
|
name: "Indvendig vask",
|
|
description: "Interior wash service",
|
|
price: 399,
|
|
subscription_allowed: true,
|
|
category: 4,
|
|
piktogram: "49",
|
|
apply_category_discount: true,
|
|
requires_note: false,
|
|
is_wash: false,
|
|
display_in_booking_form: true,
|
|
order_priority: 2,
|
|
addons: [],
|
|
},
|
|
{
|
|
id: 64,
|
|
name: "Vaskecertifikat",
|
|
description: "Wash certificate",
|
|
price: 25,
|
|
subscription_allowed: true,
|
|
category: 8,
|
|
piktogram: "56",
|
|
apply_category_discount: false,
|
|
requires_note: false,
|
|
is_wash: false,
|
|
display_in_booking_form: true,
|
|
order_priority: 3,
|
|
addons: [],
|
|
},
|
|
];
|
|
|
|
const demoPosFixture = createPosFixture({
|
|
departments: [
|
|
{ id: 1, name: "Roskilde", exclude_from_invoicing: false },
|
|
{ id: 12, name: "Taastrup", exclude_from_invoicing: false },
|
|
],
|
|
products,
|
|
departmentCategories: [
|
|
{ id: 11, department_id: 12, category: { id: 4, name: "Vask", meta: { products: [53, 63] } } },
|
|
{ id: 12, department_id: 12, category: { id: 8, name: "Dokumenter", meta: { products: [64] } } },
|
|
],
|
|
customersByNumber: {
|
|
[demoCustomerNumber]: {
|
|
id: 101,
|
|
customerNumber: demoCustomerNumber,
|
|
name: "Demo Transport ApS",
|
|
address: "Demovej 10",
|
|
zip: "4000",
|
|
city: "Roskilde",
|
|
mobilePhone: "00000000",
|
|
email: "billing@example.test",
|
|
corporateIdentificationNumber: "00000000",
|
|
economic_customer: demoCustomerNumber,
|
|
barred: false,
|
|
},
|
|
},
|
|
vehicles: [
|
|
{
|
|
id: 7001,
|
|
reg: "TW12345",
|
|
customer_id: demoCustomerNumber,
|
|
customer_name: "Demo Transport ApS",
|
|
type: 53,
|
|
status: "verified",
|
|
barred: false,
|
|
wash_subscription: false,
|
|
addons: { enabled: 0, available: 0, list: [] },
|
|
reference: "Distribution truck",
|
|
last_order_id: 54518,
|
|
},
|
|
{
|
|
id: 7002,
|
|
reg: "TW67890",
|
|
customer_id: demoCustomerNumber,
|
|
customer_name: "Demo Transport ApS",
|
|
type: 53,
|
|
status: "verified",
|
|
barred: false,
|
|
wash_subscription: true,
|
|
addons: { enabled: 1, available: 1, list: [] },
|
|
reference: "Line haul truck",
|
|
last_order_id: 54519,
|
|
},
|
|
],
|
|
orderBookings: [
|
|
{
|
|
id: 9101,
|
|
customer_number: demoCustomerNumber,
|
|
customer_name: "Demo Transport ApS",
|
|
department: 12,
|
|
department_id: 12,
|
|
datetime: `${nextWeekDatePart} 08:30:00`,
|
|
reg_1: "TW12345",
|
|
reg_2: "",
|
|
reference: "Morning wash",
|
|
po: "PO-DEMO-1001",
|
|
pickup: false,
|
|
order_id: null,
|
|
items: [
|
|
{ id: 53, name: "Udvendig vask", quantity: 1, is_wash: true },
|
|
{ id: 64, name: "Vaskecertifikat", quantity: 1, is_wash: false },
|
|
],
|
|
},
|
|
],
|
|
ordersById: {
|
|
54518: {
|
|
id: 54518,
|
|
customer_id: demoCustomerNumber,
|
|
department_id: 12,
|
|
reference: "Morning wash",
|
|
po: "PO-DEMO-1001",
|
|
safety_seal: "TW-SEAL-001",
|
|
notes: "",
|
|
reg_1: "TW12345",
|
|
reg_2: "",
|
|
reg_3: "",
|
|
invoice_collection_id: null,
|
|
booking_id: 9101,
|
|
completed_at: `${datePart} 09:10:00`,
|
|
closed_at: null,
|
|
created_at: `${datePart} 08:44:07`,
|
|
include_in_invoice: null,
|
|
total_net_amount: 1073,
|
|
},
|
|
54519: {
|
|
id: 54519,
|
|
customer_id: demoCustomerNumber,
|
|
department_id: 1,
|
|
reference: "Line haul truck",
|
|
po: "PO-DEMO-1002",
|
|
safety_seal: "TW-SEAL-002",
|
|
notes: "",
|
|
reg_1: "TW67890",
|
|
reg_2: "",
|
|
reg_3: "",
|
|
invoice_collection_id: null,
|
|
booking_id: null,
|
|
completed_at: `${datePart} 11:35:00`,
|
|
closed_at: null,
|
|
created_at: `${datePart} 11:02:00`,
|
|
include_in_invoice: null,
|
|
total_net_amount: 649,
|
|
},
|
|
},
|
|
orderItemsByOrderId: {
|
|
54518: [
|
|
{ id: 9101, order_id: 54518, product_id: 53, product: products[0], quantity: 1, notes: "", reference: "", related_item_id: null, price: 649 },
|
|
{ id: 9102, order_id: 54518, product_id: 63, product: products[1], quantity: 1, notes: "", reference: "", related_item_id: 9101, price: 399 },
|
|
{ id: 9103, order_id: 54518, product_id: 64, product: products[2], quantity: 1, notes: "", reference: "", related_item_id: 9101, price: 25 },
|
|
],
|
|
54519: [
|
|
{ id: 9201, order_id: 54519, product_id: 53, product: products[0], quantity: 1, notes: "", reference: "", related_item_id: null, price: 649 },
|
|
],
|
|
},
|
|
attachmentsByOrderId: {
|
|
54518: [
|
|
{
|
|
id: 301,
|
|
object_type: "orders",
|
|
object_id: 54518,
|
|
content: { image: null, document: "wash_certificate_54518.pdf", relation: null, other: "WASH_CERTIFICATE", src: null },
|
|
created_at: `${datePart} 09:10:00`,
|
|
updated_at: `${datePart} 09:10:00`,
|
|
deleted_at: null,
|
|
},
|
|
],
|
|
},
|
|
collectedInvoices: [
|
|
{
|
|
id: 101,
|
|
customer_number: demoCustomerNumber,
|
|
customer_name: "Demo Transport ApS",
|
|
total_net_amount: 1722,
|
|
created_at: datePart,
|
|
closed_at: datePart,
|
|
},
|
|
],
|
|
});
|
|
|
|
const demoSelfServe = {
|
|
products: [
|
|
{
|
|
id: 2,
|
|
name: "Sættevognstræk",
|
|
price: 100,
|
|
description: "Udvendig vask til trækker med sættevogn.",
|
|
piktogram: "3",
|
|
is_wash: true,
|
|
subscription_allowed: true,
|
|
category: 4,
|
|
},
|
|
{
|
|
id: 3,
|
|
name: "Forvogn",
|
|
price: 80,
|
|
description: "Udvendig vask til forvogn.",
|
|
piktogram: "5",
|
|
is_wash: true,
|
|
subscription_allowed: true,
|
|
category: 4,
|
|
},
|
|
{
|
|
id: 4,
|
|
name: "Forvogn med hænger",
|
|
price: 120,
|
|
description: "Udvendig vask til vogntog.",
|
|
piktogram: "6",
|
|
is_wash: true,
|
|
subscription_allowed: true,
|
|
category: 4,
|
|
},
|
|
],
|
|
departments: [
|
|
{
|
|
id: 6,
|
|
name: "Roskilde",
|
|
address: "Industrivej 45, 4000 Roskilde",
|
|
latitude: 55.6415,
|
|
longitude: 12.0803,
|
|
self_serve_enabled: true,
|
|
lanes: [
|
|
{
|
|
id: 7,
|
|
department: 6,
|
|
name: "7",
|
|
status: "AVAILABLE",
|
|
selfserve_enabled: true,
|
|
machine_available: true,
|
|
products: [2, 3],
|
|
relay_in_id: "IN-7",
|
|
relay_out_id: "OUT-7",
|
|
relay_machine_id: "M-7",
|
|
relay_machine_program_picker_id: "MPP-7",
|
|
relay_machine_cleaner_id: "MC-7",
|
|
dynamic_image_id: 77,
|
|
machine_type_id: 1001,
|
|
},
|
|
{
|
|
id: 8,
|
|
department: 6,
|
|
name: "8",
|
|
status: "AVAILABLE",
|
|
selfserve_enabled: true,
|
|
machine_available: false,
|
|
products: [2, 3],
|
|
relay_in_id: "IN-8",
|
|
relay_out_id: "OUT-8",
|
|
relay_machine_id: "M-8",
|
|
relay_machine_program_picker_id: "MPP-8",
|
|
relay_machine_cleaner_id: "MC-8",
|
|
dynamic_image_id: 78,
|
|
machine_type_id: 1001,
|
|
},
|
|
],
|
|
},
|
|
{
|
|
id: 2,
|
|
name: "Taastrup",
|
|
address: "Demovej 12, 2630 Taastrup",
|
|
latitude: 55.651,
|
|
longitude: 12.292,
|
|
self_serve_enabled: true,
|
|
lanes: [
|
|
{
|
|
id: 9,
|
|
department: 2,
|
|
name: "9",
|
|
status: "AVAILABLE",
|
|
selfserve_enabled: true,
|
|
machine_available: true,
|
|
products: [2, 3],
|
|
relay_in_id: "IN-9",
|
|
relay_out_id: "OUT-9",
|
|
relay_machine_id: "M-9",
|
|
relay_machine_program_picker_id: "MPP-9",
|
|
relay_machine_cleaner_id: "MC-9",
|
|
dynamic_image_id: 79,
|
|
machine_type_id: 1002,
|
|
},
|
|
],
|
|
},
|
|
],
|
|
customerVehicles: [
|
|
{ id: 1, reg: "TW12345", type: 2 },
|
|
{ id: 2, reg: "TW67890", type: 2 },
|
|
],
|
|
vehicleTypes: [
|
|
{
|
|
id: 2,
|
|
vehicleTypeId: 2,
|
|
name: "Sættevognstræk",
|
|
price: 100,
|
|
product: {
|
|
id: 2,
|
|
name: "Sættevognstræk",
|
|
price: 100,
|
|
description: "Udvendig vask til trækker med sættevogn.",
|
|
piktogram: "3",
|
|
is_wash: true,
|
|
subscription_allowed: true,
|
|
category: 4,
|
|
},
|
|
},
|
|
{
|
|
id: 3,
|
|
vehicleTypeId: 3,
|
|
name: "Forvogn",
|
|
price: 80,
|
|
product: {
|
|
id: 3,
|
|
name: "Forvogn",
|
|
price: 80,
|
|
description: "Udvendig vask til forvogn.",
|
|
piktogram: "5",
|
|
is_wash: true,
|
|
subscription_allowed: true,
|
|
category: 4,
|
|
},
|
|
},
|
|
{
|
|
id: 4,
|
|
vehicleTypeId: 4,
|
|
name: "Forvogn med hænger",
|
|
price: 120,
|
|
product: {
|
|
id: 4,
|
|
name: "Forvogn med hænger",
|
|
price: 120,
|
|
description: "Udvendig vask til vogntog.",
|
|
piktogram: "6",
|
|
is_wash: true,
|
|
subscription_allowed: true,
|
|
category: 4,
|
|
},
|
|
},
|
|
],
|
|
previewByKey: {
|
|
"7:TW12345": {
|
|
allowed: true,
|
|
machine_available: true,
|
|
lane: { id: 7, name: "7" },
|
|
session: { id: 501, lane_id: 7, reg: "TW12345", status: "IN_PROGRESS", allowed: true, vehicle_type_id: 2 },
|
|
questions: [
|
|
{
|
|
id: 11,
|
|
question: "Er presenningen fjernet?",
|
|
description: "Bekræft at bilen er klar før maskinvask.",
|
|
answer: null,
|
|
order_priority: 1,
|
|
},
|
|
],
|
|
conditions: [],
|
|
rules: [],
|
|
tasks: [],
|
|
allowed_services: ["MACHINE"],
|
|
},
|
|
},
|
|
summaryByKey: {
|
|
"7:TW12345": {
|
|
session: { id: 501, lane_id: 7, reg: "TW12345", status: "IN_PROGRESS", allowed: true, vehicle_type_id: 2 },
|
|
lane: { id: 7, name: "7" },
|
|
questions: [
|
|
{
|
|
id: 11,
|
|
question: "Er presenningen fjernet?",
|
|
description: "Bekræft at bilen er klar før maskinvask.",
|
|
answer: null,
|
|
order_priority: 1,
|
|
},
|
|
],
|
|
conditions: [],
|
|
rules: [],
|
|
tasks: [],
|
|
events: [{ id: 2, type: "SESSION_SYNCED", created_at: `${datePart}T10:01:00.000Z` }],
|
|
},
|
|
},
|
|
answerResponseByKey: {
|
|
"7:TW12345:11:true": {
|
|
session: { id: 501, lane_id: 7, reg: "TW12345", status: "IN_PROGRESS", allowed: true, vehicle_type_id: 2 },
|
|
lane: { id: 7, name: "7" },
|
|
questions: [
|
|
{
|
|
id: 11,
|
|
question: "Er presenningen fjernet?",
|
|
description: "Bekræft at bilen er klar før maskinvask.",
|
|
answer: true,
|
|
order_priority: 1,
|
|
},
|
|
],
|
|
conditions: [],
|
|
rules: [],
|
|
tasks: [],
|
|
events: [{ id: 3, type: "QUESTION_ANSWERED", created_at: `${datePart}T10:03:00.000Z` }],
|
|
},
|
|
},
|
|
};
|
|
|
|
async function ensureDirectories() {
|
|
await Promise.all(requiredDirectories.map((directory) => fs.mkdir(path.join(outputRoot, directory), { recursive: true })));
|
|
}
|
|
|
|
function isDisallowedRoute(routePath) {
|
|
return disallowedRoutePatterns.some((pattern) => pattern.test(routePath));
|
|
}
|
|
|
|
function assertAllowedCapturePath(routePath) {
|
|
if (isDisallowedRoute(routePath)) {
|
|
throw new Error(`Refusing to capture disallowed Play Store route: ${routePath}`);
|
|
}
|
|
}
|
|
|
|
async function readImageAsDataUri(filePath) {
|
|
const ext = path.extname(filePath).toLowerCase();
|
|
const mime = ext === ".jpg" || ext === ".jpeg" ? "image/jpeg" : "image/png";
|
|
const body = await fs.readFile(filePath);
|
|
return `data:${mime};base64,${body.toString("base64")}`;
|
|
}
|
|
|
|
async function isServerReady() {
|
|
return new Promise((resolve) => {
|
|
const request = http.get(baseUrl, { timeout: 1500 }, (response) => {
|
|
response.resume();
|
|
resolve(response.statusCode >= 200 && response.statusCode < 500);
|
|
});
|
|
request.on("timeout", () => {
|
|
request.destroy();
|
|
resolve(false);
|
|
});
|
|
request.on("error", () => resolve(false));
|
|
});
|
|
}
|
|
|
|
async function waitForServer(timeoutMs = 45_000) {
|
|
const deadline = Date.now() + timeoutMs;
|
|
while (Date.now() < deadline) {
|
|
if (await isServerReady()) {
|
|
return;
|
|
}
|
|
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
}
|
|
throw new Error(`Timed out waiting for Vite server at ${baseUrl}`);
|
|
}
|
|
|
|
async function startServerIfNeeded() {
|
|
if (await isServerReady()) {
|
|
return null;
|
|
}
|
|
|
|
const child = spawn(
|
|
"npm",
|
|
["run", "dev", "--", "--host", devHost, "--port", devPort, "--strictPort"],
|
|
{
|
|
cwd: projectRoot,
|
|
stdio: ["ignore", "pipe", "pipe"],
|
|
detached: true,
|
|
env: {
|
|
...process.env,
|
|
PLAYWRIGHT: "1",
|
|
PLAYWRIGHT_DEV_PORT: devPort,
|
|
PLAYWRIGHT_DEV_HOST: devHost,
|
|
},
|
|
}
|
|
);
|
|
|
|
child.stdout.on("data", (data) => process.stdout.write(`[vite] ${data}`));
|
|
child.stderr.on("data", (data) => process.stderr.write(`[vite] ${data}`));
|
|
|
|
await waitForServer();
|
|
return child;
|
|
}
|
|
|
|
async function installCommonPageState(context, page) {
|
|
await context.grantPermissions(["geolocation"], { origin: baseUrl });
|
|
await context.setGeolocation({ latitude: 55.6415, longitude: 12.0803 });
|
|
await page.addInitScript(() => {
|
|
const style = document.createElement("style");
|
|
style.textContent = [
|
|
[
|
|
"vue-devtools",
|
|
"vue-inspector",
|
|
"#vue-devtools-anchor",
|
|
"#vue-inspector-container",
|
|
"#__vue-devtools-container__",
|
|
".vue-devtools__anchor",
|
|
".vue-devtools__anchor-btn",
|
|
".vue-devtools__panel",
|
|
".vue-devtools__panel-content",
|
|
".vue-devtools-frame",
|
|
"iframe[src*='__devtools__']",
|
|
"iframe[src*='devtools']",
|
|
].join(", ") + " { display: none !important; opacity: 0 !important; visibility: hidden !important; pointer-events: none !important; }",
|
|
".swal2-container { display: none !important; }",
|
|
".slick-scroll-layout .has-text-centered:has(a[href='/'] img[src*='truckwash-banner-white-compressed']) { display: none !important; }",
|
|
".mobile-header-section .transform-color-red, .mobile-header-section .transform-color-warning { filter: invert(100%) grayscale(100%) brightness(0) contrast(100%) !important; }",
|
|
".mobile-header-section .has-text-red { color: #111111 !important; }",
|
|
"@media screen and (max-width: 768px) { [data-testid='self-serve-prewash-bottom-actions'] { bottom: calc(3.45rem + env(safe-area-inset-bottom, 0px)) !important; } }",
|
|
].join("\n");
|
|
document.documentElement.appendChild(style);
|
|
|
|
const hideDevtools = () => {
|
|
const selectors = [
|
|
"vue-devtools",
|
|
"vue-inspector",
|
|
"#vue-devtools-anchor",
|
|
"#vue-inspector-container",
|
|
"#__vue-devtools-container__",
|
|
".vue-devtools__anchor",
|
|
".vue-devtools-frame",
|
|
"iframe[src*='__devtools__']",
|
|
"iframe[src*='devtools']",
|
|
];
|
|
for (const node of document.querySelectorAll(selectors.join(","))) {
|
|
node.style.setProperty("display", "none", "important");
|
|
node.style.setProperty("opacity", "0", "important");
|
|
node.style.setProperty("visibility", "hidden", "important");
|
|
node.style.setProperty("pointer-events", "none", "important");
|
|
}
|
|
};
|
|
|
|
hideDevtools();
|
|
window.addEventListener("DOMContentLoaded", hideDevtools, { once: true });
|
|
new MutationObserver(hideDevtools).observe(document.documentElement, { childList: true, subtree: true });
|
|
window.localStorage.setItem("lastVersionCheck", String(Date.now()));
|
|
});
|
|
}
|
|
|
|
async function setupMockApi(page, mode) {
|
|
const baseOptions = {
|
|
authenticated: true,
|
|
permissions: ["user"],
|
|
sessionData: demoSession,
|
|
edgeGateways: false,
|
|
workerVersion: "playstore-graphics",
|
|
};
|
|
|
|
if (mode === "selfServe") {
|
|
await mockApi(page, {
|
|
...baseOptions,
|
|
selfServe: demoSelfServe,
|
|
});
|
|
return;
|
|
}
|
|
|
|
await mockApi(page, {
|
|
...baseOptions,
|
|
pos: demoPosFixture,
|
|
});
|
|
}
|
|
|
|
async function applyCaptureSurfacePolish(page) {
|
|
await page.addStyleTag({
|
|
content: [
|
|
[
|
|
"vue-devtools",
|
|
"vue-inspector",
|
|
"#vue-devtools-anchor",
|
|
"#vue-inspector-container",
|
|
"#__vue-devtools-container__",
|
|
".vue-devtools__anchor",
|
|
".vue-devtools__anchor-btn",
|
|
".vue-devtools__panel",
|
|
".vue-devtools__panel-content",
|
|
".vue-devtools-frame",
|
|
"iframe[src*='__devtools__']",
|
|
"iframe[src*='devtools']",
|
|
].join(", ") + " { display: none !important; opacity: 0 !important; visibility: hidden !important; pointer-events: none !important; }",
|
|
".slick-scroll-layout .has-text-centered:has(a[href='/'] img[src*='truckwash-banner-white-compressed']) { display: none !important; }",
|
|
[
|
|
"html body .mobile-header-section .transform-color-red",
|
|
"html body .mobile-header-section .transform-color-warning",
|
|
].join(", ") +
|
|
" { filter: invert(100%) grayscale(100%) brightness(0) contrast(100%) !important; color: #111111 !important; fill: #111111 !important; }",
|
|
"html body .mobile-header-section .has-text-red { color: #111111 !important; }",
|
|
"@media screen and (max-width: 768px) { html body [data-testid='self-serve-prewash-bottom-actions'] { bottom: calc(3.45rem + env(safe-area-inset-bottom, 0px)) !important; } }",
|
|
].join("\n"),
|
|
});
|
|
|
|
await page.evaluate(() => {
|
|
for (const node of document.querySelectorAll(
|
|
".mobile-header-section .transform-color-red, .mobile-header-section .transform-color-warning"
|
|
)) {
|
|
node.classList.remove("transform-color-red", "transform-color-warning");
|
|
node.classList.add("transform-color-black");
|
|
node.style.setProperty("filter", "invert(100%) grayscale(100%) brightness(0) contrast(100%)", "important");
|
|
node.style.setProperty("color", "#111111", "important");
|
|
node.style.setProperty("fill", "#111111", "important");
|
|
}
|
|
|
|
for (const node of document.querySelectorAll(".mobile-header-section .has-text-red")) {
|
|
node.classList.remove("has-text-red");
|
|
node.classList.add("has-text-black");
|
|
node.style.setProperty("color", "#111111", "important");
|
|
}
|
|
|
|
for (const image of document.querySelectorAll("img[src*='truckwash-banner-white-compressed']")) {
|
|
const footer = image.closest(".has-text-centered");
|
|
if (footer?.textContent?.includes("Truckwash ApS")) {
|
|
footer.style.setProperty("display", "none", "important");
|
|
footer.style.setProperty("visibility", "hidden", "important");
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
function resolveSceneDevice(device, scene) {
|
|
const override = scene.deviceOverrides?.[device.key];
|
|
if (!override) {
|
|
return device;
|
|
}
|
|
|
|
return {
|
|
...device,
|
|
...override,
|
|
viewport: override.viewport || device.viewport,
|
|
expected: device.expected,
|
|
};
|
|
}
|
|
|
|
async function captureScene(browser, device, scene) {
|
|
assertAllowedCapturePath(scene.path);
|
|
const sceneDevice = resolveSceneDevice(device, scene);
|
|
|
|
const context = await browser.newContext({
|
|
baseURL: baseUrl,
|
|
viewport: sceneDevice.viewport,
|
|
deviceScaleFactor: sceneDevice.deviceScaleFactor,
|
|
isMobile: sceneDevice.isMobile,
|
|
hasTouch: sceneDevice.hasTouch,
|
|
locale: "da-DK",
|
|
timezoneId: "Europe/Copenhagen",
|
|
});
|
|
const page = await context.newPage();
|
|
|
|
try {
|
|
await installCommonPageState(context, page);
|
|
await setupMockApi(page, scene.mode);
|
|
await primeMockSession(page, {
|
|
token: `playstore-${device.key}-${scene.key}`,
|
|
bootPath: scene.path,
|
|
});
|
|
|
|
const currentPath = new URL(page.url()).pathname;
|
|
assertAllowedCapturePath(currentPath);
|
|
|
|
await scene.waitFor(page, device, sceneDevice);
|
|
await applyCaptureSurfacePolish(page);
|
|
await page.evaluate(() => window.scrollTo(0, 0));
|
|
await page.waitForTimeout(350);
|
|
|
|
const outputPath = path.join(outputRoot, device.screenshotDir, scene.fileName);
|
|
await page.screenshot({
|
|
path: outputPath,
|
|
type: "jpeg",
|
|
quality: 92,
|
|
fullPage: false,
|
|
scale: "device",
|
|
animations: "disabled",
|
|
caret: "hide",
|
|
});
|
|
|
|
return {
|
|
device: device.key,
|
|
type: "screenshot",
|
|
scene: scene.key,
|
|
route: currentPath,
|
|
path: path.relative(outputRoot, outputPath),
|
|
altText: scene.altText,
|
|
expected: device.expected,
|
|
};
|
|
} finally {
|
|
await context.close();
|
|
}
|
|
}
|
|
|
|
function parsePngDimensions(buffer) {
|
|
const signature = buffer.subarray(0, 8).toString("hex");
|
|
if (signature !== "89504e470d0a1a0a") {
|
|
throw new Error("Invalid PNG signature");
|
|
}
|
|
return {
|
|
width: buffer.readUInt32BE(16),
|
|
height: buffer.readUInt32BE(20),
|
|
colorType: buffer[25],
|
|
format: "png",
|
|
hasAlpha: buffer[25] === 4 || buffer[25] === 6,
|
|
};
|
|
}
|
|
|
|
function parseJpegDimensions(buffer) {
|
|
if (buffer[0] !== 0xff || buffer[1] !== 0xd8) {
|
|
throw new Error("Invalid JPEG signature");
|
|
}
|
|
|
|
let offset = 2;
|
|
while (offset < buffer.length) {
|
|
if (buffer[offset] !== 0xff) {
|
|
offset += 1;
|
|
continue;
|
|
}
|
|
|
|
const marker = buffer[offset + 1];
|
|
const length = buffer.readUInt16BE(offset + 2);
|
|
const isStartOfFrame =
|
|
(marker >= 0xc0 && marker <= 0xc3) ||
|
|
(marker >= 0xc5 && marker <= 0xc7) ||
|
|
(marker >= 0xc9 && marker <= 0xcb) ||
|
|
(marker >= 0xcd && marker <= 0xcf);
|
|
|
|
if (isStartOfFrame) {
|
|
return {
|
|
width: buffer.readUInt16BE(offset + 7),
|
|
height: buffer.readUInt16BE(offset + 5),
|
|
format: "jpeg",
|
|
hasAlpha: false,
|
|
};
|
|
}
|
|
|
|
offset += 2 + length;
|
|
}
|
|
|
|
throw new Error("Unable to find JPEG dimensions");
|
|
}
|
|
|
|
async function readImageMetadata(filePath) {
|
|
const buffer = await fs.readFile(filePath);
|
|
const ext = path.extname(filePath).toLowerCase();
|
|
const dimensions = ext === ".png" ? parsePngDimensions(buffer) : parseJpegDimensions(buffer);
|
|
return {
|
|
...dimensions,
|
|
bytes: buffer.length,
|
|
};
|
|
}
|
|
|
|
async function copyAndValidateIcon() {
|
|
await fs.copyFile(iconSource, appIconOutput);
|
|
const metadata = await readImageMetadata(appIconOutput);
|
|
if (metadata.format !== "png" || metadata.width !== 512 || metadata.height !== 512) {
|
|
throw new Error("App icon must be a 512x512 PNG");
|
|
}
|
|
if (!metadata.hasAlpha) {
|
|
throw new Error("App icon must be a 32-bit PNG with alpha");
|
|
}
|
|
if (metadata.bytes > 1024 * 1024) {
|
|
throw new Error("App icon exceeds 1024KB");
|
|
}
|
|
return {
|
|
type: "app-icon",
|
|
path: path.relative(outputRoot, appIconOutput),
|
|
altText: "Truck Wash app icon.",
|
|
expected: { width: 512, height: 512 },
|
|
};
|
|
}
|
|
|
|
async function createFeatureGraphic(browser) {
|
|
const [wordmark, dashboard, washLane] = await Promise.all([
|
|
readImageAsDataUri(wordmarkSource),
|
|
readImageAsDataUri(path.join(outputRoot, "phone/screenshots/01-dashboard.jpg")),
|
|
readImageAsDataUri(path.join(outputRoot, "phone/screenshots/03-wash-lane.jpg")),
|
|
]);
|
|
|
|
const html = `<!doctype html>
|
|
<html>
|
|
<head>
|
|
<meta charset="utf-8" />
|
|
<style>
|
|
* { box-sizing: border-box; }
|
|
body {
|
|
margin: 0;
|
|
width: 1024px;
|
|
height: 500px;
|
|
overflow: hidden;
|
|
background: #063651;
|
|
color: #ffffff;
|
|
font-family: Poppins, Inter, Arial, sans-serif;
|
|
}
|
|
.feature {
|
|
position: relative;
|
|
width: 1024px;
|
|
height: 500px;
|
|
display: grid;
|
|
grid-template-columns: 440px 1fr;
|
|
gap: 32px;
|
|
padding: 58px 68px 52px;
|
|
background:
|
|
linear-gradient(135deg, rgba(7, 135, 187, 0.92), rgba(6, 54, 81, 0.96) 52%, rgba(8, 33, 49, 1)),
|
|
#063651;
|
|
}
|
|
.copy {
|
|
align-self: center;
|
|
min-width: 0;
|
|
z-index: 2;
|
|
}
|
|
.wordmark {
|
|
width: 250px;
|
|
height: 50px;
|
|
object-fit: contain;
|
|
object-position: left center;
|
|
margin-bottom: 36px;
|
|
}
|
|
h1 {
|
|
margin: 0 0 18px;
|
|
font-size: 48px;
|
|
line-height: 1.04;
|
|
font-weight: 700;
|
|
letter-spacing: 0;
|
|
}
|
|
p {
|
|
margin: 0;
|
|
max-width: 380px;
|
|
color: rgba(255, 255, 255, 0.88);
|
|
font-size: 22px;
|
|
line-height: 1.34;
|
|
}
|
|
.screens {
|
|
position: relative;
|
|
height: 390px;
|
|
align-self: center;
|
|
}
|
|
.shot {
|
|
position: absolute;
|
|
width: 210px;
|
|
height: 374px;
|
|
object-fit: cover;
|
|
object-position: top center;
|
|
border-radius: 8px;
|
|
box-shadow: 0 24px 56px rgba(0, 0, 0, 0.32);
|
|
border: 1px solid rgba(255, 255, 255, 0.34);
|
|
background: #fff;
|
|
}
|
|
.shot--one {
|
|
right: 225px;
|
|
top: 22px;
|
|
transform: rotate(-4deg);
|
|
}
|
|
.shot--two {
|
|
right: 58px;
|
|
top: 0;
|
|
transform: rotate(3deg);
|
|
}
|
|
.accent {
|
|
position: absolute;
|
|
right: 45px;
|
|
bottom: 46px;
|
|
width: 330px;
|
|
height: 72px;
|
|
border: 2px solid rgba(255, 184, 51, 0.76);
|
|
border-radius: 8px;
|
|
z-index: 0;
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<main class="feature">
|
|
<section class="copy">
|
|
<img class="wordmark" src="${wordmark}" alt="" />
|
|
<h1>Kundeportal til truckvask</h1>
|
|
<p>Start selvvask, book tid og hent dokumentation fra mobilen.</p>
|
|
</section>
|
|
<section class="screens" aria-hidden="true">
|
|
<div class="accent"></div>
|
|
<img class="shot shot--one" src="${dashboard}" alt="" />
|
|
<img class="shot shot--two" src="${washLane}" alt="" />
|
|
</section>
|
|
</main>
|
|
</body>
|
|
</html>`;
|
|
|
|
const context = await browser.newContext({
|
|
viewport: { width: 1024, height: 500 },
|
|
deviceScaleFactor: 1,
|
|
});
|
|
const page = await context.newPage();
|
|
try {
|
|
await page.setContent(html, { waitUntil: "load" });
|
|
await page.screenshot({
|
|
path: featureGraphicOutput,
|
|
type: "jpeg",
|
|
quality: 94,
|
|
fullPage: false,
|
|
scale: "device",
|
|
animations: "disabled",
|
|
caret: "hide",
|
|
});
|
|
} finally {
|
|
await context.close();
|
|
}
|
|
|
|
return {
|
|
type: "feature-graphic",
|
|
path: path.relative(outputRoot, featureGraphicOutput),
|
|
altText: "Truck Wash customer portal shown with dashboard and self-service wash screens.",
|
|
expected: { width: 1024, height: 500 },
|
|
};
|
|
}
|
|
|
|
async function createFeatureGraphicVariant(browser, variant) {
|
|
const [wordmark, primaryScreenshot, secondaryScreenshot] = await Promise.all([
|
|
readImageAsDataUri(wordmarkSource),
|
|
readImageAsDataUri(path.join(outputRoot, variant.screenshots[0])),
|
|
readImageAsDataUri(path.join(outputRoot, variant.screenshots[1])),
|
|
]);
|
|
|
|
const outputPath = path.join(outputRoot, "universal/feature-graphic", variant.fileName);
|
|
const html = `<!doctype html>
|
|
<html>
|
|
<head>
|
|
<meta charset="utf-8" />
|
|
<style>
|
|
* { box-sizing: border-box; }
|
|
body {
|
|
margin: 0;
|
|
width: 1024px;
|
|
height: 500px;
|
|
overflow: hidden;
|
|
background: #063651;
|
|
color: #ffffff;
|
|
font-family: Poppins, Inter, Arial, sans-serif;
|
|
}
|
|
.feature {
|
|
position: relative;
|
|
width: 1024px;
|
|
height: 500px;
|
|
display: grid;
|
|
grid-template-columns: 410px 1fr;
|
|
gap: 42px;
|
|
padding: 52px 64px 48px;
|
|
background: ${variant.background};
|
|
}
|
|
.feature::after {
|
|
content: "";
|
|
position: absolute;
|
|
inset: auto 0 0 auto;
|
|
width: 540px;
|
|
height: 270px;
|
|
background: linear-gradient(135deg, transparent 18%, rgba(255, 255, 255, 0.09));
|
|
clip-path: polygon(32% 0, 100% 0, 100% 100%, 0 100%);
|
|
}
|
|
.copy {
|
|
align-self: center;
|
|
min-width: 0;
|
|
z-index: 2;
|
|
}
|
|
.wordmark {
|
|
width: 238px;
|
|
height: 48px;
|
|
object-fit: contain;
|
|
object-position: left center;
|
|
margin-bottom: 30px;
|
|
}
|
|
.eyebrow {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
min-height: 28px;
|
|
padding: 0 12px;
|
|
border-radius: 7px;
|
|
background: color-mix(in srgb, ${variant.accent} 24%, transparent);
|
|
border: 1px solid color-mix(in srgb, ${variant.accent} 62%, transparent);
|
|
color: #ffffff;
|
|
font-size: 14px;
|
|
font-weight: 700;
|
|
letter-spacing: 0;
|
|
margin-bottom: 16px;
|
|
}
|
|
h1 {
|
|
margin: 0 0 18px;
|
|
font-size: 45px;
|
|
line-height: 1.04;
|
|
font-weight: 700;
|
|
letter-spacing: 0;
|
|
}
|
|
p {
|
|
margin: 0;
|
|
max-width: 360px;
|
|
color: rgba(255, 255, 255, 0.88);
|
|
font-size: 21px;
|
|
line-height: 1.34;
|
|
}
|
|
.screens {
|
|
position: relative;
|
|
height: 394px;
|
|
align-self: center;
|
|
z-index: 2;
|
|
}
|
|
.shot {
|
|
position: absolute;
|
|
width: 214px;
|
|
height: 380px;
|
|
object-fit: cover;
|
|
object-position: top center;
|
|
border-radius: 8px;
|
|
box-shadow: 0 24px 56px rgba(0, 0, 0, 0.34);
|
|
border: 1px solid rgba(255, 255, 255, 0.34);
|
|
background: #fff;
|
|
}
|
|
.shot--primary {
|
|
right: 234px;
|
|
top: 20px;
|
|
transform: rotate(-4deg);
|
|
}
|
|
.shot--secondary {
|
|
right: 54px;
|
|
top: 0;
|
|
transform: rotate(3deg);
|
|
}
|
|
.accent {
|
|
position: absolute;
|
|
right: 26px;
|
|
bottom: 32px;
|
|
width: 382px;
|
|
height: 84px;
|
|
border: 2px solid ${variant.accent};
|
|
border-radius: 8px;
|
|
opacity: 0.78;
|
|
z-index: 0;
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<main class="feature">
|
|
<section class="copy">
|
|
<img class="wordmark" src="${wordmark}" alt="" />
|
|
<div class="eyebrow">${variant.eyebrow}</div>
|
|
<h1>${variant.title}</h1>
|
|
<p>${variant.body}</p>
|
|
</section>
|
|
<section class="screens" aria-hidden="true">
|
|
<div class="accent"></div>
|
|
<img class="shot shot--primary" src="${primaryScreenshot}" alt="" />
|
|
<img class="shot shot--secondary" src="${secondaryScreenshot}" alt="" />
|
|
</section>
|
|
</main>
|
|
</body>
|
|
</html>`;
|
|
|
|
const context = await browser.newContext({
|
|
viewport: { width: 1024, height: 500 },
|
|
deviceScaleFactor: 1,
|
|
});
|
|
const page = await context.newPage();
|
|
try {
|
|
await page.setContent(html, { waitUntil: "load" });
|
|
await page.screenshot({
|
|
path: outputPath,
|
|
type: "jpeg",
|
|
quality: 94,
|
|
fullPage: false,
|
|
scale: "device",
|
|
animations: "disabled",
|
|
caret: "hide",
|
|
});
|
|
} finally {
|
|
await context.close();
|
|
}
|
|
|
|
return {
|
|
type: "feature-graphic",
|
|
variant: variant.key,
|
|
path: path.relative(outputRoot, outputPath),
|
|
altText: variant.altText,
|
|
expected: { width: 1024, height: 500 },
|
|
};
|
|
}
|
|
|
|
async function createFeatureGraphicVariants(browser) {
|
|
const entries = [];
|
|
for (const variant of featureGraphicVariants) {
|
|
entries.push(await createFeatureGraphicVariant(browser, variant));
|
|
}
|
|
return entries;
|
|
}
|
|
|
|
async function validateAsset(entry) {
|
|
const absolutePath = path.join(outputRoot, entry.path);
|
|
const metadata = await readImageMetadata(absolutePath);
|
|
if (metadata.width !== entry.expected.width || metadata.height !== entry.expected.height) {
|
|
throw new Error(
|
|
`${entry.path} has ${metadata.width}x${metadata.height}, expected ${entry.expected.width}x${entry.expected.height}`
|
|
);
|
|
}
|
|
|
|
if (entry.type === "screenshot" || entry.type === "feature-graphic") {
|
|
if (metadata.format !== "jpeg") {
|
|
throw new Error(`${entry.path} must be a JPEG`);
|
|
}
|
|
if (metadata.hasAlpha) {
|
|
throw new Error(`${entry.path} must not contain alpha`);
|
|
}
|
|
if (metadata.bytes > 8 * 1024 * 1024) {
|
|
throw new Error(`${entry.path} exceeds 8MB`);
|
|
}
|
|
}
|
|
|
|
return {
|
|
...entry,
|
|
width: metadata.width,
|
|
height: metadata.height,
|
|
format: metadata.format,
|
|
bytes: metadata.bytes,
|
|
};
|
|
}
|
|
|
|
async function writeMetadata(entries) {
|
|
const validatedEntries = [];
|
|
for (const entry of entries) {
|
|
validatedEntries.push(await validateAsset(entry));
|
|
}
|
|
|
|
const screenshotEntries = validatedEntries.filter((entry) => entry.type === "screenshot");
|
|
for (const device of deviceProfiles) {
|
|
const deviceScreenshots = screenshotEntries.filter((entry) => entry.device === device.key);
|
|
if (deviceScreenshots.length !== screenshotScenes.length) {
|
|
throw new Error(`${device.key} has ${deviceScreenshots.length} screenshots, expected ${screenshotScenes.length}`);
|
|
}
|
|
}
|
|
|
|
const routeViolations = screenshotEntries.filter((entry) => isDisallowedRoute(entry.route || ""));
|
|
if (routeViolations.length > 0) {
|
|
throw new Error(`Disallowed routes captured: ${routeViolations.map((entry) => entry.route).join(", ")}`);
|
|
}
|
|
|
|
const manifest = {
|
|
generatedAt,
|
|
app: {
|
|
name: "Truck Wash",
|
|
packageId: "io.truckwash.twa",
|
|
source: "pleno-vue TWA",
|
|
},
|
|
outputRoot,
|
|
assets: validatedEntries,
|
|
};
|
|
|
|
const altText = Object.fromEntries(validatedEntries.map((entry) => [entry.path, entry.altText]));
|
|
|
|
await fs.writeFile(path.join(outputRoot, "metadata/asset-manifest.json"), `${JSON.stringify(manifest, null, 2)}\n`);
|
|
await fs.writeFile(path.join(outputRoot, "metadata/alt-text.json"), `${JSON.stringify(altText, null, 2)}\n`);
|
|
}
|
|
|
|
async function run() {
|
|
await ensureDirectories();
|
|
const server = await startServerIfNeeded();
|
|
const browser = await chromium.launch();
|
|
const entries = [];
|
|
|
|
try {
|
|
entries.push(await copyAndValidateIcon());
|
|
|
|
for (const device of deviceProfiles) {
|
|
for (const scene of screenshotScenes) {
|
|
entries.push(await captureScene(browser, device, scene));
|
|
}
|
|
}
|
|
|
|
entries.push(await createFeatureGraphic(browser));
|
|
entries.push(...(await createFeatureGraphicVariants(browser)));
|
|
await writeMetadata(entries);
|
|
} finally {
|
|
await browser.close();
|
|
if (server) {
|
|
try {
|
|
process.kill(-server.pid, "SIGTERM");
|
|
} catch {
|
|
server.kill("SIGTERM");
|
|
}
|
|
}
|
|
}
|
|
|
|
console.log(`Generated ${entries.length} Play Store graphics in ${outputRoot}`);
|
|
}
|
|
|
|
run().catch((error) => {
|
|
console.error(error);
|
|
process.exitCode = 1;
|
|
});
|