diff --git a/apps/jumentix-website/documentation/COMMERCIAL-EXPERIENCE.md b/apps/jumentix-website/documentation/COMMERCIAL-EXPERIENCE.md index aeedcafe..9f0f4976 100644 --- a/apps/jumentix-website/documentation/COMMERCIAL-EXPERIENCE.md +++ b/apps/jumentix-website/documentation/COMMERCIAL-EXPERIENCE.md @@ -75,6 +75,15 @@ Use a seeded account (`apps/backend-template/seed/users.ts`). The script loads t Management sample model, splits it into two services for the architecture view, and signs in to the frontend for the admin and dashboard views. +`public/product/screenshots.json` lists each image, the commit it was captured at (`capturedAt`, +written by the capture script) and the source paths of the screen it shows (`watch`). +`bun run website:check-screenshot-freshness` runs in `ci:gate` and warns — without failing — when +a watched path changed after the capture, naming the commits, so a stale image is visible in the +gate log instead of being noticed by a visitor. It fails only when the manifest itself is wrong: an +image missing from the manifest or from disk, an entry without `watch`, or a `capturedAt` that is +not a commit (a shallow clone only warns). After a recapture, commit the images and the manifest +together. + Every image on the site shows Jumentix itself. The starter-template leftovers the site was scaffolded with — the `mantine+nextjs+nextra-template.png` placeholder, the unused `Welcome`, `ProductHunt`, `Content`, `Sponsors` and `ColorSchemeToggle` components, the template author's diff --git a/apps/jumentix-website/documentation/COMMERCIAL-EXPERIENCE.pt-BR.md b/apps/jumentix-website/documentation/COMMERCIAL-EXPERIENCE.pt-BR.md index 376fb341..a1b462eb 100644 --- a/apps/jumentix-website/documentation/COMMERCIAL-EXPERIENCE.pt-BR.md +++ b/apps/jumentix-website/documentation/COMMERCIAL-EXPERIENCE.pt-BR.md @@ -74,6 +74,15 @@ Use uma conta do seed (`apps/backend-template/seed/users.ts`). O script carrega do Service Management, divide em dois serviços para a visão de arquitetura e entra no frontend para as visões administrativa e de dashboard. +`public/product/screenshots.json` lista cada imagem, o commit em que foi capturada (`capturedAt`, +escrito pelo script de captura) e os caminhos de código da tela que ela mostra (`watch`). +`bun run website:check-screenshot-freshness` roda no `ci:gate` e avisa — sem falhar — quando um +caminho observado mudou depois da captura, nomeando os commits, para que uma imagem desatualizada +apareça no log do gate em vez de ser notada por um visitante. Ele só falha quando o próprio +manifesto está errado: imagem ausente do manifesto ou do disco, entrada sem `watch`, ou um +`capturedAt` que não é um commit (um clone raso só avisa). Depois de recapturar, faça commit das +imagens e do manifesto juntos. + Toda imagem do site mostra o próprio Jumentix. As sobras do template inicial usado no scaffold — o placeholder `mantine+nextjs+nextra-template.png`, os componentes sem uso `Welcome`, `ProductHunt`, `Content`, `Sponsors` e `ColorSchemeToggle`, as listas de links do autor do diff --git a/apps/jumentix-website/public/product/screenshots.json b/apps/jumentix-website/public/product/screenshots.json new file mode 100644 index 00000000..f33133b7 --- /dev/null +++ b/apps/jumentix-website/public/product/screenshots.json @@ -0,0 +1,65 @@ +{ + "screenshots": [ + { + "file": "domain-designer.png", + "capturedAt": "a46de5d2", + "watch": [ + "apps/service-management/src", + "apps/service-management/index.html", + "apps/service-management/script.js", + "apps/service-management/styles.css", + "apps/service-management/tokens.css", + "packages/designer-core/src" + ] + }, + { + "file": "architecture-designer.png", + "capturedAt": "a46de5d2", + "watch": [ + "apps/service-management/src", + "apps/service-management/index.html", + "apps/service-management/script.js", + "apps/service-management/styles.css", + "apps/service-management/tokens.css" + ] + }, + { + "file": "openapi-swagger.png", + "capturedAt": "a46de5d2", + "watch": [ + "apps/service-management/src", + "apps/service-management/index.html", + "apps/service-management/script.js", + "apps/service-management/styles.css", + "apps/service-management/tokens.css", + "apps/backend-template/OASdoc" + ] + }, + { + "file": "code-workspace.png", + "capturedAt": "a46de5d2", + "watch": [ + "apps/service-management/src", + "apps/service-management/index.html", + "apps/service-management/script.js", + "apps/service-management/styles.css", + "apps/service-management/tokens.css", + "packages/designer-core/src" + ] + }, + { + "file": "frontend-dashboard.png", + "capturedAt": "a46de5d2", + "watch": [ + "apps/frontend/src" + ] + }, + { + "file": "frontend-xcrud-users.png", + "capturedAt": "a46de5d2", + "watch": [ + "apps/frontend/src" + ] + } + ] +} diff --git a/apps/jumentix-website/scripts/capture-product-screenshots.mjs b/apps/jumentix-website/scripts/capture-product-screenshots.mjs index fd9799c2..3d717175 100644 --- a/apps/jumentix-website/scripts/capture-product-screenshots.mjs +++ b/apps/jumentix-website/scripts/capture-product-screenshots.mjs @@ -1,7 +1,7 @@ #!/usr/bin/env node /** - * Capture the website's product screenshots from the running applications - * (JUM-896). Every image under public/product/ is produced here, so a refresh + * Capture the website's product screenshots from the running applications. + * Every image under public/product/ is produced here, so a refresh * is one command against current builds rather than a manual session. * * Prerequisites (see documentation/COMMERCIAL-EXPERIENCE.md): @@ -18,6 +18,7 @@ */ import fs from 'node:fs'; import path from 'node:path'; +import { execFileSync } from 'node:child_process'; import { createRequire } from 'node:module'; import { fileURLToPath } from 'node:url'; @@ -25,6 +26,7 @@ const require = createRequire(import.meta.url); const { webkit } = require('playwright-webkit'); const websiteRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const manifestPath = path.join(websiteRoot, 'public', 'product', 'screenshots.json'); const smUrl = process.env.SERVICE_MANAGEMENT_URL ?? 'http://127.0.0.1:3200'; const feUrl = process.env.FRONTEND_URL ?? 'http://127.0.0.1:3001'; const outDir = process.env.SCREENSHOT_OUT ?? path.join(websiteRoot, 'public', 'product'); @@ -115,6 +117,20 @@ async function captureFrontend(browser) { await page.close(); } +/** + * Stamp every manifest entry with the commit just captured, so + * check-screenshot-freshness.mjs measures staleness from this capture. + * Captures written elsewhere (SCREENSHOT_OUT) leave the manifest alone. + */ +function stampManifest() { + if (path.resolve(outDir) !== path.dirname(manifestPath)) return; + const commit = execFileSync('git', ['rev-parse', '--short=12', 'HEAD'], { cwd: websiteRoot, encoding: 'utf8' }).trim(); + const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); + for (const entry of manifest.screenshots) entry.capturedAt = commit; + fs.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`); + console.log(`[capture] screenshots.json capturedAt=${commit}`); +} + const browser = await webkit.launch(); try { await captureServiceManagement(browser); @@ -122,3 +138,4 @@ try { } finally { await browser.close(); } +stampManifest(); diff --git a/apps/jumentix-website/scripts/check-screenshot-freshness.mjs b/apps/jumentix-website/scripts/check-screenshot-freshness.mjs new file mode 100644 index 00000000..11caa7a5 --- /dev/null +++ b/apps/jumentix-website/scripts/check-screenshot-freshness.mjs @@ -0,0 +1,109 @@ +#!/usr/bin/env node +/** + * Screenshot freshness. + * + * `public/product/screenshots.json` records, per product screenshot, the + * commit it was captured against and the source paths of the screen it shows. + * When any of those paths changed after that commit, the screenshot may no + * longer match the product, and this check says which one and why. + * + * It warns and exits 0 on staleness: whether a change is visible enough to + * recapture is a human judgement, and an unrelated PR must not be blocked by + * it. It fails (exit 1) only when the manifest itself is wrong — unreadable, + * naming a missing image, an image the manifest does not list, or a commit + * git cannot resolve — because a broken manifest silently stops the warning. + * + * Recapture: `bun run --filter @jumentix/website screenshots:capture`, which + * rewrites every entry's `capturedAt` to the commit it captured. + */ +import fs from 'node:fs'; +import path from 'node:path'; +import { execFileSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; + +const websiteRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const repoRoot = path.resolve(websiteRoot, '..', '..'); +const productDir = path.join(websiteRoot, 'public', 'product'); +export const MANIFEST_PATH = path.join(productDir, 'screenshots.json'); + +function git(args) { + return execFileSync('git', args, { cwd: repoRoot, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim(); +} + +export const defaultIo = { + readManifest: () => JSON.parse(fs.readFileSync(MANIFEST_PATH, 'utf8')), + listImages: () => fs.readdirSync(productDir).filter((file) => file.endsWith('.png')), + isShallow: () => git(['rev-parse', '--is-shallow-repository']) === 'true', + commitExists: (sha) => { + try { + git(['cat-file', '-e', `${sha}^{commit}`]); + return true; + } catch { + return false; + } + }, + commitsSince: (sha, paths) => { + const out = git(['log', '--format=%h %s', `${sha}..HEAD`, '--', ...paths]); + return out ? out.split('\n') : []; + } +}; + +export function checkScreenshotFreshness(io = defaultIo) { + const failures = []; + const warnings = []; + let manifest; + try { + manifest = io.readManifest(); + } catch (error) { + return { failures: [`screenshots.json is unreadable: ${error.message}`], warnings }; + } + const entries = Array.isArray(manifest?.screenshots) ? manifest.screenshots : null; + if (!entries) return { failures: ['screenshots.json must hold a "screenshots" array'], warnings }; + + const listed = new Set(entries.map((entry) => entry.file)); + for (const image of io.listImages()) { + if (!listed.has(image)) failures.push(`${image}: not listed in screenshots.json`); + } + const images = new Set(io.listImages()); + for (const entry of entries) { + if (!entry.file || !entry.capturedAt || !Array.isArray(entry.watch) || entry.watch.length === 0) { + failures.push(`${entry.file ?? ''}: needs "file", "capturedAt" and a non-empty "watch" list`); + continue; + } + if (!images.has(entry.file)) { + failures.push(`${entry.file}: listed in screenshots.json but missing from public/product`); + continue; + } + if (!io.commitExists(entry.capturedAt)) { + // A shallow clone may simply not hold the commit; only a full history can + // prove the manifest wrong. + if (io.isShallow()) { + warnings.push(`${entry.file}: capturedAt ${entry.capturedAt} is outside this shallow clone; freshness not checked`); + } else { + failures.push(`${entry.file}: capturedAt ${entry.capturedAt} is not a commit in this repository`); + } + continue; + } + const commits = io.commitsSince(entry.capturedAt, entry.watch); + if (commits.length > 0) { + warnings.push( + `${entry.file} may be stale — ${commits.length} commit(s) touched ${entry.watch.join(', ')} since ${entry.capturedAt}: ` + + commits.slice(0, 3).join('; ') + ); + } + } + return { failures, warnings }; +} + +function main() { + const { failures, warnings } = checkScreenshotFreshness(); + warnings.forEach((warning) => console.warn(`[screenshots] WARN ${warning}`)); + if (failures.length > 0) { + failures.forEach((failure) => console.error(`[screenshots] ${failure}`)); + process.exitCode = 1; + return; + } + console.log(`[screenshots] manifest valid; ${warnings.length} screenshot(s) possibly stale.`); +} + +if (process.argv[1] && fileURLToPath(import.meta.url) === path.resolve(process.argv[1])) main(); diff --git a/apps/jumentix-website/scripts/check-screenshot-freshness.test.mjs b/apps/jumentix-website/scripts/check-screenshot-freshness.test.mjs new file mode 100644 index 00000000..d85ca503 --- /dev/null +++ b/apps/jumentix-website/scripts/check-screenshot-freshness.test.mjs @@ -0,0 +1,107 @@ +/** + * The screenshot freshness contract: stale captures warn, a broken manifest + * fails. A manifest that silently stops matching the images would turn the + * warning off without anyone noticing, so every way it can drift is a failure. + */ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { checkScreenshotFreshness, defaultIo } from './check-screenshot-freshness.mjs'; + +const productDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'public', 'product'); + +function fakeIo({ + manifest = { screenshots: [{ file: 'a.png', capturedAt: 'abc123', watch: ['apps/x/src'] }] }, + images = ['a.png'], + commits = {}, + known = ['abc123'], + shallow = false, +} = {}) { + return { + readManifest: () => { + if (manifest instanceof Error) throw manifest; + return manifest; + }, + listImages: () => images, + isShallow: () => shallow, + commitExists: (sha) => known.includes(sha), + commitsSince: (sha) => commits[sha] ?? [], + }; +} + +describe('screenshot freshness', () => { + it('passes quietly when nothing watched changed since the capture', () => { + expect.hasAssertions(); + + expect(checkScreenshotFreshness(fakeIo())).toStrictEqual({ failures: [], warnings: [] }); + }); + + it('warns, without failing, when a watched path changed after the capture', () => { + expect.hasAssertions(); + + const result = checkScreenshotFreshness(fakeIo({ commits: { abc123: ['d4e5f6 fix(x): new toolbar'] } })); + + expect(result.failures).toStrictEqual([]); + expect(result.warnings).toHaveLength(1); + expect(result.warnings[0]).toContain('a.png may be stale'); + expect(result.warnings[0]).toContain('d4e5f6 fix(x): new toolbar'); + }); + + it('fails when an image on disk is missing from the manifest', () => { + expect.hasAssertions(); + + const result = checkScreenshotFreshness(fakeIo({ images: ['a.png', 'b.png'] })); + + expect(result.failures).toStrictEqual(['b.png: not listed in screenshots.json']); + }); + + it('fails when the manifest lists an image that does not exist', () => { + expect.hasAssertions(); + + const result = checkScreenshotFreshness(fakeIo({ images: [] })); + + expect(result.failures).toStrictEqual(['a.png: listed in screenshots.json but missing from public/product']); + }); + + it('fails on an entry without a watch list', () => { + expect.hasAssertions(); + + const manifest = { screenshots: [{ file: 'a.png', capturedAt: 'abc123', watch: [] }] }; + const result = checkScreenshotFreshness(fakeIo({ manifest })); + + expect(result.failures[0]).toContain('needs "file", "capturedAt" and a non-empty "watch" list'); + }); + + it('fails on an unknown commit in a full clone and only warns in a shallow one', () => { + expect.hasAssertions(); + + expect(checkScreenshotFreshness(fakeIo({ known: [] })).failures).toStrictEqual([ + 'a.png: capturedAt abc123 is not a commit in this repository', + ]); + + const shallow = checkScreenshotFreshness(fakeIo({ known: [], shallow: true })); + + expect(shallow.failures).toStrictEqual([]); + expect(shallow.warnings[0]).toContain('outside this shallow clone'); + }); + + it('fails on an unreadable manifest or one without a screenshots array', () => { + expect.hasAssertions(); + + expect(checkScreenshotFreshness(fakeIo({ manifest: new Error('boom') })).failures).toStrictEqual([ + 'screenshots.json is unreadable: boom', + ]); + expect(checkScreenshotFreshness(fakeIo({ manifest: {} })).failures).toStrictEqual([ + 'screenshots.json must hold a "screenshots" array', + ]); + }); + + it('lists every product image in the committed manifest', () => { + expect.hasAssertions(); + + const images = fs.readdirSync(productDir).filter((file) => file.endsWith('.png')).sort(); + const listed = defaultIo.readManifest().screenshots.map((entry) => entry.file).sort(); + + expect(listed).toStrictEqual(images); + }); +}); diff --git a/package.json b/package.json index ae65d6bb..6a0c171f 100644 --- a/package.json +++ b/package.json @@ -83,10 +83,10 @@ "ci:smoke": "JUMENTIX_JWT_TOKEN_SECRET_KEY=${JUMENTIX_JWT_TOKEN_SECRET_KEY:-ci_jwt_secret_key} NODE_ENV=ci bun apps/backend-template/scripts/run-api-smoke.js", "ci:integration": "JUMENTIX_JWT_TOKEN_SECRET_KEY=${JUMENTIX_JWT_TOKEN_SECRET_KEY:-ci_jwt_secret_key} bun ci-cd/run-integration-tests.js", "ci:security-smoke": "NODE_ENV=ci bun apps/backend-template/scripts/run-security-smoke.js", - "ci:gate": "bun run check-bun-version && bun run deps:check-overrides && bun run deps:audit && bun run lint && bun run deps:check-cycles && bun run arch:check-boundaries && bun run arch:check-users-legacy-imports && bun run arch:check-workspace-boundaries && bun run arch:check-ownership-placement && bun run arch:check-http-adapters && bun run workspace:check-quality && bun run workspace:check-coverage-policy && bun run release:governance:check && bun run governance:check-authorship && bun run requirements:check && bun run docs:check-current-governance && bun run docs:check-audience && bun run packages:check-suites && bun run packages:check-build-freshness && bun run cli:check-template-freshness && bun run website:check-content-routes && bun run rtdb:check-indexes && bun run test:integrity && bun run test-map:check && bun run ci:check-provider && bun run ci:check-third-party-review && bun run integrations:check && bun run integration-migration:check && bun run agent-registry:check && bun run test:unit && bun run frontend:test:coverage && bun run frontend:coverage:check && bun run ci:security-smoke && bun run oas:check-routes && bun run oas:check-relations && bun run serverless:check-handlers && bun run build:dev && bun run ci:smoke", + "ci:gate": "bun run check-bun-version && bun run deps:check-overrides && bun run deps:audit && bun run lint && bun run deps:check-cycles && bun run arch:check-boundaries && bun run arch:check-users-legacy-imports && bun run arch:check-workspace-boundaries && bun run arch:check-ownership-placement && bun run arch:check-http-adapters && bun run workspace:check-quality && bun run workspace:check-coverage-policy && bun run release:governance:check && bun run governance:check-authorship && bun run requirements:check && bun run docs:check-current-governance && bun run docs:check-audience && bun run packages:check-suites && bun run packages:check-build-freshness && bun run cli:check-template-freshness && bun run website:check-content-routes && bun run website:check-screenshot-freshness && bun run rtdb:check-indexes && bun run test:integrity && bun run test-map:check && bun run ci:check-provider && bun run ci:check-third-party-review && bun run integrations:check && bun run integration-migration:check && bun run agent-registry:check && bun run test:unit && bun run frontend:test:coverage && bun run frontend:coverage:check && bun run ci:security-smoke && bun run oas:check-routes && bun run oas:check-relations && bun run serverless:check-handlers && bun run build:dev && bun run ci:smoke", "ci:gate:branch": "bun ci-cd/run-branch-quality-gate.js", "ci:gate:task": "bun run workspace:build:packages && bun ci-cd/run-task-change-tests.js", - "ci:gate:static": "bun run deps:check-overrides && bun run deps:audit && bun run arch:check-http-adapters && bun run governance:check-authorship && bun run packages:check-suites && bun run packages:check-build-freshness && bun run cli:check-template-freshness && bun run website:check-content-routes && bun run rtdb:check-indexes && bun run test-map:check && bun run ci:check-provider && bun run oas:check-relations", + "ci:gate:static": "bun run deps:check-overrides && bun run deps:audit && bun run arch:check-http-adapters && bun run governance:check-authorship && bun run packages:check-suites && bun run packages:check-build-freshness && bun run cli:check-template-freshness && bun run website:check-content-routes && bun run website:check-screenshot-freshness && bun run rtdb:check-indexes && bun run test-map:check && bun run ci:check-provider && bun run oas:check-relations", "ci:gate:strict": "bun ci-cd/run-full-test-matrix.js", "ci:gate:generated-automation": "bun ci-cd/check-generated-automation-pr.js", "dev": "bun run pm2:start:dev:restapi", @@ -314,6 +314,7 @@ "smoke:db-repositories": "bun run docker:up:cassandra && bun run docker:up:mongodb && bun run test:integration:db-repositories; status=$?; bun run docker:down:cassandra; bun run docker:down:mongodb; exit $status", "packages:check-build-freshness": "bun ci-cd/check-package-build-freshness.js", "website:check-content-routes": "bun apps/jumentix-website/scripts/check-content-routes.js", + "website:check-screenshot-freshness": "bun apps/jumentix-website/scripts/check-screenshot-freshness.mjs", "rtdb:check-indexes": "bun packages/agent-registry/bin/check-rtdb-indexes.js", "test:integrity": "bun ci-cd/check-test-integrity.js", "rtdb:export-rules": "bun ci-cd/export-database-rules.js", diff --git a/test-map.json b/test-map.json index 77c57430..aa0dc8ca 100644 --- a/test-map.json +++ b/test-map.json @@ -5510,6 +5510,18 @@ "timeoutMs": 60000, "reason": "Runs under the website's own Jest config; the root runner cannot execute it directly." }, + { + "id": "apps/jumentix-website/scripts/check-screenshot-freshness.test.mjs", + "path": "apps/jumentix-website/scripts/check-screenshot-freshness.test.mjs", + "layer": "website", + "kind": "non-hexagonal", + "type": "unit", + "script": "website:test:unit", + "runner": "bun", + "tier": "gate", + "timeoutMs": 60000, + "reason": "Runs under the website's own Jest config; the root runner cannot execute it directly." + }, { "id": "apps/jumentix-website/scripts/content-leaks.test.mjs", "path": "apps/jumentix-website/scripts/content-leaks.test.mjs", @@ -6090,6 +6102,6 @@ "unit": 362, "integration": 102, "smoke": 2, - "suites": 525 + "suites": 526 } }