Governed substrate for autonomous agents: scoped identity (passports), audited actions, MCP workspace. Infra IPs and secrets redacted for public release.
154 lines
6.5 KiB
JavaScript
154 lines
6.5 KiB
JavaScript
#!/usr/bin/env node
|
||
/**
|
||
* ADR-0060 P5d — Playwright UI smoke for the public project page.
|
||
*
|
||
* Verifies the live render against devcrm.hltrn.cc:
|
||
* 1. /s/help/projects/146 loads, no console errors / no 401/403/5xx.
|
||
* 2. Sidebar renders + the help project is clickable.
|
||
* 3. The seeded table_view widget (4139) is mounted and shows rows.
|
||
* 4. No edit affordances (textarea, input-as-cell, "+ Add row", dnd handles)
|
||
* are interactable on the public surface.
|
||
* 5. Mobile (<768px) viewport: sidebar collapses, content still rendered.
|
||
*
|
||
* Run with:
|
||
* node scripts/smoke-adr0060-p5d.mjs
|
||
* Optional env:
|
||
* SMOKE_URL=https://devcrm.hltrn.cc (default)
|
||
* SMOKE_SLUG=help
|
||
* SMOKE_PROJECT_ID=146
|
||
*/
|
||
import { chromium } from 'playwright';
|
||
|
||
const BASE = process.env.SMOKE_URL || 'https://devcrm.hltrn.cc';
|
||
const SLUG = process.env.SMOKE_SLUG || 'help';
|
||
const PROJECT = process.env.SMOKE_PROJECT_ID || '146';
|
||
const URL = `${BASE}/s/${SLUG}/projects/${PROJECT}`;
|
||
|
||
const results = [];
|
||
function record(name, ok, detail = '') {
|
||
results.push({ name, ok, detail });
|
||
// eslint-disable-next-line no-console
|
||
console.log(`${ok ? 'PASS' : 'FAIL'} ${name}${detail ? ` — ${detail}` : ''}`);
|
||
}
|
||
|
||
async function run() {
|
||
const browser = await chromium.launch({ headless: true });
|
||
|
||
// ---------- Desktop pass ----------
|
||
const ctx = await browser.newContext({ viewport: { width: 1280, height: 800 } });
|
||
const page = await ctx.newPage();
|
||
|
||
const errors = [];
|
||
const badStatuses = [];
|
||
page.on('console', (msg) => {
|
||
if (msg.type() === 'error') errors.push(msg.text());
|
||
});
|
||
page.on('response', (r) => {
|
||
const s = r.status();
|
||
const u = r.url();
|
||
if (s >= 400 && u.startsWith(BASE)) badStatuses.push(`${s} ${u}`);
|
||
});
|
||
|
||
await page.goto(URL, { waitUntil: 'networkidle', timeout: 30_000 });
|
||
// Wait for either a data row OR the explicit "no widgets" empty state to
|
||
// settle — useQuery chains projects → dashboard → widget → data, which
|
||
// resolves after networkidle.
|
||
await page
|
||
.locator('text=GOD CRM User Guide, text=В этом проекте')
|
||
.first()
|
||
.waitFor({ timeout: 15_000 })
|
||
.catch(() => {});
|
||
await page.waitForTimeout(500);
|
||
|
||
// The auth-refresh probe is part of every page (not a P5d artefact);
|
||
// filter it out so the AC sees a clean signal for "no 4xx/5xx".
|
||
const realBadStatuses = badStatuses.filter((s) => !s.includes('/auth/refresh'));
|
||
const realErrors = errors.filter((e) => !e.includes('/auth/refresh') && !/401/.test(e));
|
||
|
||
record('HTML shell loads (desktop)', page.url().includes('/projects/'));
|
||
record('No 4xx/5xx on first-paint requests (auth-refresh exempted)', realBadStatuses.length === 0, realBadStatuses.slice(0, 5).join(' | '));
|
||
record('No console errors (auth-refresh exempted)', realErrors.length === 0, realErrors.slice(0, 3).join(' | '));
|
||
|
||
// The page mounts inside PublicLayout which renders the sidebar via
|
||
// PublicSpaceSidebar; the project is listed in the tree.
|
||
const sidebarMarker = await page.locator('text=Knowledge Base').first().count();
|
||
record('Sidebar shows project link', sidebarMarker > 0);
|
||
|
||
// The seeded widget 4139 is a table_view; its title is "Knowledge Base Documents".
|
||
const widgetMarker = await page.locator('text=Knowledge Base Documents').first().count();
|
||
record('Seeded table_view widget rendered', widgetMarker > 0);
|
||
|
||
// A row from the data endpoint should appear (we asserted via curl that 47 rows exist).
|
||
const rowMarker = await page.locator('text=GOD CRM User Guide').first().count();
|
||
record('At least one data row rendered', rowMarker > 0);
|
||
|
||
// No edit affordances: there should be NO input/textarea inside the dashboard
|
||
// body and no "Add row" / "Add column" buttons. We're permissive — the
|
||
// public layout has a search input in its chrome that's allowed.
|
||
const editableCells = await page
|
||
.locator('[role="grid"] input, [role="grid"] textarea, [data-cell-editable="true"]')
|
||
.count();
|
||
record('No editable cells in public viewer', editableCells === 0, `count=${editableCells}`);
|
||
|
||
const addRowBtn = await page.getByRole('button', { name: /\+\s*Add row|Add row|Добавить строку/i }).count();
|
||
record('No "Add row" button visible', addRowBtn === 0);
|
||
|
||
const addColBtn = await page.getByRole('button', { name: /Add column|Добавить колонку/i }).count();
|
||
record('No "Add column" button visible', addColBtn === 0);
|
||
|
||
// Bundle hash advanced (snapshot from current HTML)
|
||
const html = await page.content();
|
||
const bundleHash = (html.match(/index-([A-Za-z0-9]+)\.js/) || [])[1] || '(not-found)';
|
||
record('Bundle hash advanced from index-CQEbzHAo.js', bundleHash !== 'CQEbzHAo', `current=${bundleHash}`);
|
||
|
||
await page.screenshot({ path: '/tmp/p5d-desktop.png', fullPage: true });
|
||
|
||
await ctx.close();
|
||
|
||
// ---------- Mobile pass ----------
|
||
const mctx = await browser.newContext({ viewport: { width: 375, height: 667 } });
|
||
const mpage = await mctx.newPage();
|
||
await mpage.goto(URL, { waitUntil: 'networkidle', timeout: 30_000 });
|
||
await mpage
|
||
.locator('text=Knowledge Base Documents')
|
||
.first()
|
||
.waitFor({ timeout: 15_000 })
|
||
.catch(() => {});
|
||
await mpage.waitForTimeout(500);
|
||
|
||
const mobileWidget = await mpage.locator('text=Knowledge Base Documents').first().count();
|
||
record('[mobile 375px] widget still rendered', mobileWidget > 0);
|
||
|
||
// Sidebar must collapse on mobile — assert that no full-width sidebar nav is visible
|
||
// by checking a typical desktop sidebar landmark is either hidden or off-screen.
|
||
// PublicSpaceSidebar has a hamburger toggle on mobile; the project list is not in
|
||
// the initial DOM until the user expands it.
|
||
const visibleLinks = await mpage.locator('aside a, [data-sidebar] a').count();
|
||
record('[mobile 375px] sidebar links not exposed by default', visibleLinks < 10, `count=${visibleLinks}`);
|
||
|
||
await mpage.screenshot({ path: '/tmp/p5d-mobile.png', fullPage: true });
|
||
|
||
await mctx.close();
|
||
await browser.close();
|
||
|
||
// ---------- Summary ----------
|
||
const total = results.length;
|
||
const passed = results.filter((r) => r.ok).length;
|
||
// eslint-disable-next-line no-console
|
||
console.log(`\n${'='.repeat(60)}`);
|
||
console.log(`SUMMARY: ${passed}/${total} passed`);
|
||
console.log(`Screenshots: /tmp/p5d-desktop.png, /tmp/p5d-mobile.png`);
|
||
if (passed !== total) {
|
||
console.log(`FAILED ITEMS:`);
|
||
for (const r of results.filter((x) => !x.ok)) {
|
||
console.log(` - ${r.name}${r.detail ? ` (${r.detail})` : ''}`);
|
||
}
|
||
process.exit(1);
|
||
}
|
||
}
|
||
|
||
run().catch((err) => {
|
||
// eslint-disable-next-line no-console
|
||
console.error('SMOKE_ERROR', err);
|
||
process.exit(2);
|
||
});
|