MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 2m13s
- README: current features (journal, focus mode, push, themes, sharing into the inbox), outgoing connections, :latest instead of :stable, VAPID_* and LEGAL_CUSTOM_LINK_*, new journal and phone screenshots - e2e/screenshots.sh: light and dark in the same state joined along a diagonal, own container/port per run - hashtag tiles at least 18rem wide chore(release): v0.15.0 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
198 lines
8.2 KiB
JavaScript
198 lines
8.2 KiB
JavaScript
// Takes the README screenshots from a fresh server in demo mode (sample data of admin/admin).
|
||
// Every picture is taken twice in the same state – light and dark theme – and the two halves are
|
||
// joined along a slanted diagonal (light left, dark right).
|
||
// Usage: e2e/screenshots.sh (builds the image) or MINDSIFT_URL=… SHOTS_DIR=… node screenshots.mjs
|
||
// Theme pair: SHOT_LIGHT / SHOT_DARK (default: light / dark, the "Klassik" family)
|
||
import fs from 'node:fs';
|
||
import { chromium } from 'playwright';
|
||
|
||
const BASE = process.env.MINDSIFT_URL || 'http://localhost:18080';
|
||
const OUT = process.env.SHOTS_DIR || '../screenshots';
|
||
const LIGHT = process.env.SHOT_LIGHT || 'light';
|
||
const DARK = process.env.SHOT_DARK || 'dark';
|
||
const DESKTOP = { viewport: { width: 1518, height: 886 }, deviceScaleFactor: 2 };
|
||
const MOBILE = { viewport: { width: 390, height: 844 }, deviceScaleFactor: 2, isMobile: true, hasTouch: true };
|
||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||
|
||
async function login(user) {
|
||
const res = await fetch(`${BASE}/api/v1/auth/login`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ login: user, password: user }),
|
||
});
|
||
if (!res.ok) throw new Error(`login ${user}: ${res.status}`);
|
||
return (await res.json()).token;
|
||
}
|
||
|
||
async function open(browser, token, options) {
|
||
const ctx = await browser.newContext({ ...options, colorScheme: 'dark', locale: 'de-DE' });
|
||
await ctx.addInitScript(
|
||
([t, theme]) => {
|
||
localStorage.setItem('mindsift_jwt_token', t);
|
||
localStorage.setItem('mindsift_active_theme', theme);
|
||
},
|
||
[token, DARK]
|
||
);
|
||
const page = await ctx.newPage();
|
||
page.on('pageerror', (e) => console.log('PAGEERROR', e.message));
|
||
await page.goto(BASE);
|
||
await page.waitForLoadState('networkidle');
|
||
return page;
|
||
}
|
||
|
||
// Anchored, so "Workspace" does not hit the triage button "Als Notiz – Im Workspace"
|
||
const tab = (page, name) => page.getByRole('button', { name: new RegExp(`^${name}`) }).locator('visible=true').first().click();
|
||
|
||
// Switches the theme in place (no reload), so both halves show exactly the same state
|
||
async function setTheme(page, id, dark) {
|
||
await page.evaluate(
|
||
([themeId, isDark]) => {
|
||
const root = document.documentElement;
|
||
root.setAttribute('data-theme', themeId);
|
||
root.classList.toggle('dark', isDark);
|
||
},
|
||
[id, dark]
|
||
);
|
||
await sleep(250);
|
||
}
|
||
|
||
let composer;
|
||
// Light picture on the left of the diagonal, dark on the right, a thin line between them
|
||
async function compose(lightPng, darkPng, file) {
|
||
const dataUrl = await composer.evaluate(
|
||
async ([light, dark]) => {
|
||
const load = (b64) =>
|
||
new Promise((resolve) => {
|
||
const img = new Image();
|
||
img.onload = () => resolve(img);
|
||
img.src = 'data:image/png;base64,' + b64;
|
||
});
|
||
const [a, b] = await Promise.all([load(light), load(dark)]);
|
||
const w = a.naturalWidth;
|
||
const h = a.naturalHeight;
|
||
const canvas = document.createElement('canvas');
|
||
canvas.width = w;
|
||
canvas.height = h;
|
||
const ctx = canvas.getContext('2d');
|
||
ctx.drawImage(b, 0, 0);
|
||
const top = w * 0.58;
|
||
const bottom = w * 0.42;
|
||
ctx.save();
|
||
ctx.beginPath();
|
||
ctx.moveTo(0, 0);
|
||
ctx.lineTo(top, 0);
|
||
ctx.lineTo(bottom, h);
|
||
ctx.lineTo(0, h);
|
||
ctx.closePath();
|
||
ctx.clip();
|
||
ctx.drawImage(a, 0, 0);
|
||
ctx.restore();
|
||
ctx.strokeStyle = 'rgba(255, 255, 255, 0.9)';
|
||
ctx.lineWidth = Math.max(2, w / 600);
|
||
ctx.shadowColor = 'rgba(0, 0, 0, 0.35)';
|
||
ctx.shadowBlur = w / 300;
|
||
ctx.beginPath();
|
||
ctx.moveTo(top, 0);
|
||
ctx.lineTo(bottom, h);
|
||
ctx.stroke();
|
||
return canvas.toDataURL('image/png');
|
||
},
|
||
[lightPng.toString('base64'), darkPng.toString('base64')]
|
||
);
|
||
fs.writeFileSync(`${OUT}/${file}`, Buffer.from(dataUrl.split(',')[1], 'base64'));
|
||
}
|
||
|
||
async function shot(page, file) {
|
||
await sleep(800); // animations
|
||
await page.mouse.move(0, 0);
|
||
await setTheme(page, LIGHT, false);
|
||
const light = await page.screenshot();
|
||
await setTheme(page, DARK, true);
|
||
const dark = await page.screenshot();
|
||
await compose(light, dark, file);
|
||
console.log(`OK ${file}`);
|
||
}
|
||
|
||
const main = async () => {
|
||
const admin = await login('admin');
|
||
const user = await login('user');
|
||
const browser = await chromium.launch({ args: ['--lang=de-DE'] });
|
||
composer = await (await browser.newContext()).newPage();
|
||
|
||
const page = await open(browser, admin, DESKTOP);
|
||
await tab(page, 'Triage');
|
||
await shot(page, 'Triage_desktop.png');
|
||
|
||
await tab(page, 'Aufgaben');
|
||
await page.getByText('Homelab', { exact: true }).first().click().catch(() => {});
|
||
await page.getByRole('button', { name: /Kanban|Board/ }).first().click().catch(() => {});
|
||
await shot(page, 'aufgaben_desktop.png');
|
||
|
||
// The demo user edits a bullet of the shared note, so the screenshot shows lock and presence
|
||
const other = await open(browser, user, DESKTOP);
|
||
await tab(other, 'Workspace');
|
||
await other.getByText(/^Homelab 2\.0/).first().click();
|
||
await other.waitForFunction(() => document.querySelectorAll('textarea').length > 5, null, { timeout: 10000 });
|
||
await other.getByText('Endlich Platz für die Dienste', { exact: false }).first().click();
|
||
await other.keyboard.press('End');
|
||
await other.keyboard.type(': Paperless und Immich', { delay: 20 });
|
||
|
||
await tab(page, 'Workspace');
|
||
await page.getByText(/^Homelab 2\.0/).first().click();
|
||
await page.waitForFunction(() => document.querySelectorAll('textarea').length > 5, null, { timeout: 10000 });
|
||
await page.getByText('User', { exact: false }).first().waitFor({ timeout: 15000 }).catch(() => {});
|
||
await sleep(2000);
|
||
await shot(page, 'workspace_desktop.png');
|
||
|
||
// Journal: a few days with entries, the calendar in the sidebar
|
||
const H = { Authorization: `Bearer ${admin}`, 'Content-Type': 'application/json' };
|
||
const today = new Date();
|
||
const iso = (d) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||
const day = (offset) => iso(new Date(today.getFullYear(), today.getMonth(), today.getDate() - offset));
|
||
await fetch(`${BASE}/api/v1/journal/${day(0)}`, { method: 'POST', headers: H });
|
||
await page.getByRole('button', { name: 'Journal', exact: true }).click();
|
||
await sleep(1500);
|
||
const write = async (date, lines) => {
|
||
const section = page.locator(`[data-journal-date="${date}"]`);
|
||
if ((await section.count()) === 0) return; // day outside the visible month
|
||
await section.locator('.cursor-text').first().click();
|
||
for (let i = 0; i < lines.length; i++) {
|
||
if (i > 0) await page.keyboard.press('Enter');
|
||
await page.keyboard.type(lines[i], { delay: 5 });
|
||
}
|
||
await sleep(600);
|
||
};
|
||
await write(day(0), ['Standup: Umzug liegt im Plan #homelab', 'Nachmittags Backup-Test mit restic', 'Idee: Wochenrückblick als Vorlage anlegen']);
|
||
for (const offset of [2, 5]) {
|
||
await page.getByTitle(new RegExp(new Date(today.getFullYear(), today.getMonth(), today.getDate() - offset).toLocaleDateString('de-DE', { day: 'numeric', month: 'long' }))).first().click().catch(() => {});
|
||
await sleep(1200);
|
||
await write(day(offset), offset === 2 ? ['Rasen gemäht, Hecke wartet noch #garten'] : ['Erste Gedanken zur Hausrenovierung #renovierung', 'Angebote einholen bis Monatsende']);
|
||
}
|
||
await page.getByRole('button', { name: 'Heute' }).click().catch(() => {});
|
||
await page.mouse.click(1000, 860);
|
||
await sleep(2500);
|
||
await shot(page, 'journal_desktop.png');
|
||
await page.getByRole('button', { name: 'Journal', exact: true }).click(); // close it again
|
||
|
||
await tab(page, 'Listen');
|
||
await page.getByText('Wocheneinkauf', { exact: true }).first().click().catch(() => {});
|
||
await shot(page, 'Listen_desktop.png');
|
||
|
||
const mobile = await open(browser, admin, MOBILE);
|
||
await mobile.getByText('Später im Browser weiter nutzen').click({ timeout: 5000 }).catch(() => {}); // install prompt
|
||
await shot(mobile, 'Mobile_Triage.png');
|
||
|
||
await tab(mobile, 'Workspace');
|
||
await sleep(600);
|
||
await mobile.getByText(/^Homelab 2\.0/).first().click();
|
||
await sleep(1500);
|
||
await shot(mobile, 'Mobile_Workspace.png');
|
||
|
||
await browser.close();
|
||
};
|
||
|
||
main().catch((e) => {
|
||
console.error(e);
|
||
process.exit(1);
|
||
});
|