Align generated artwork with the published Truck Wash storefront, add strict iPhone and iPad App Store screenshots, and complete signed iOS release automation.
1548 lines
48 KiB
JavaScript
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;
|
|
});
|