Files
pleno-vue/scripts/playstore/generate-graphics.mjs
T
Jeppe B 97df3193e3 Complete App Store artwork and signed release automation (#200)
Align generated artwork with the published Truck Wash storefront, add strict iPhone and iPad App Store screenshots, and complete signed iOS release automation.
2026-07-20 18:19:47 +00:00

1548 lines
48 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 appStoreScreenshotRoot = path.join(projectRoot, "fastlane/screenshots/da-DK");
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 appStoreScreenshotsOnly = process.argv.includes("--app-store-screenshots");
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 appStoreDeviceProfiles = [
{
key: "iphone-6.9",
outputRoot: appStoreScreenshotRoot,
fileNamePrefix: "iphone-6.9-",
viewport: { width: 440, height: 956 },
deviceScaleFactor: 3,
isMobile: true,
hasTouch: true,
expected: { width: 1320, height: 2868 },
},
{
key: "ipad-13",
outputRoot: appStoreScreenshotRoot,
fileNamePrefix: "ipad-13-",
viewport: { width: 768, height: 1024 },
deviceScaleFactor: 2.6875,
isMobile: true,
hasTouch: true,
expected: { width: 2064, height: 2752 },
},
];
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" || device?.key === "iphone-6.9") {
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, bookingsystem_time_based_enabled: true },
],
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 })),
fs.mkdir(appStoreScreenshotRoot, { 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", "add_own_department_selfserve_vehicle_conditions"],
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 captureRoot = device.outputRoot || outputRoot;
const outputPath = path.join(
captureRoot,
device.screenshotDir || "",
`${device.fileNamePrefix || ""}${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(captureRoot, outputPath),
...(device.outputRoot ? { absolutePath: 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 = entry.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 {
const captureDevices = appStoreScreenshotsOnly ? appStoreDeviceProfiles : deviceProfiles;
for (const device of captureDevices) {
for (const scene of screenshotScenes) {
entries.push(await captureScene(browser, device, scene));
}
}
if (appStoreScreenshotsOnly) {
for (const entry of entries) {
await validateAsset(entry);
}
} else {
entries.unshift(await copyAndValidateIcon());
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");
}
}
}
if (appStoreScreenshotsOnly) {
console.log(`Generated ${entries.length} App Store screenshots in ${appStoreScreenshotRoot}`);
} else {
console.log(`Generated ${entries.length} Play Store graphics in ${outputRoot}`);
}
}
run().catch((error) => {
console.error(error);
process.exitCode = 1;
});