diff --git a/.screenshotter/README.md b/.screenshotter/README.md index c1523f7c5e..9011c6db72 100644 --- a/.screenshotter/README.md +++ b/.screenshotter/README.md @@ -28,22 +28,46 @@ npm run screenshotter npm run screenshotter ``` -Environment overrides for all the knobs: +At startup the script prints its config so you can see what mode you're in, and at the end it prints how long the run took: -```bash -BASE_URL=https://staging.example.com \ -USERNAME=snipe \ -PASSWORD=secret \ -OUT=/tmp/snipe-shots \ -HEADLESS=false \ -VIEWPORT_WIDTH=1920 VIEWPORT_HEIGHT=1080 \ -npm run screenshotter +``` +Base URL: https://snipe-it.test +Login as: admin +Output: .screenshotter/screenshots +Viewport: 1840x900 +Framing: local (generic-light) +Submit: on (edit forms are posted after shot) +Tabs: off (view pages shoot base only) +Color: light + +→ logging in +→ assets (as admin) + ✓ assets/admin-assets-index-...png (framed) + ... +Done. 166 screenshots written to .screenshotter/screenshots in 4m 12.3s. ``` -`HEADLESS=false` runs the browser visibly so you can watch the walkthrough, which is useful when adding new blocks and debugging selectors. - Full runs wipe the walkthrough shots at the start of every run so stale images never mix with fresh ones. Only `.screenshotter/README.md`, `.screenshotter/src/screenshotter.mjs`, and the `.screenshotter/screenshots/adhoc/` subdirectory (see ad-hoc mode below) are preserved. +## All environment overrides + +```bash +BASE_URL=https://staging.example.com # default: https://snipe-it.test +USERNAME=snipe # default: admin +PASSWORD=secret # default: password +OUT=/tmp/snipe-shots # default: .screenshotter/screenshots +HEADLESS=false # default: true; false to watch it run +VIEWPORT_WIDTH=1920 VIEWPORT_HEIGHT=1080 # default: 1840x900 +FRAME=false # default: true +SUBMIT_FORMS=false # default: true +TABS=true # default: false +ALL_ROUTES=false # default: true +COLOR_SCHEME=dark # default: light +TABLE_PAGE_SIZE=25 # default: 10; bootstrap-table rows per shot +``` + +`HEADLESS=false` runs the browser visibly so you can watch the walkthrough, which is useful when adding new blocks and debugging selectors. `TABLE_PAGE_SIZE` shrinks index tables so screenshots don't get needlessly long from data that adds no docs value. + ## Side effects on the database The walkthrough posts each edit form after screenshotting it (to capture the post-save UI, whether that is a success callout or a validation-error state) so a full run writes back to the connected database. In practice this means: @@ -52,6 +76,8 @@ The walkthrough posts each edit form after screenshotting it (to capture the pos - Any observer/notification/webhook wired to an update event fires as if a real edit happened. On a demo install this is usually fine, but if the install has outbound webhooks pointed at a real endpoint (Slack, an internal service, etc.) those fire too. - No data is intentionally changed (the forms are submitted with the values already on the page), but "unchanged" is not the same as "no side effects." +Before submitting, the script forces the form's `redirect_option=index` hidden field so the post-save destination is always the section's index page. This gives a stable "success callout on the index" shot regardless of what Snipe-IT's default `redirect_option` handling would have picked based on session state. + For a demo-seeded local install this is expected and fine. For anything else, do not run the full walkthrough (see the data caution above), or disable the submit step: ```bash @@ -61,6 +87,12 @@ SUBMIT_FORMS=false npm run screenshotter With `SUBMIT_FORMS=false` the walkthrough is read-only: no form posts, no `action_logs` entries, no observer/webhook fires. Trade-off is you lose the post-save UI captures. +## Dev-tool overlays are blocked at the network level + +Debugbar, Telescope, and Clockwork all get their asset requests aborted via Playwright network interception. Their JS never loads, so their overlays cannot render, so no debug panel ever appears in a shot. This is stronger than CSS hiding, which was the previous approach and broke on Snipe-IT error pages where debugbar rendered visible JSON collector panels through selectors we couldn't reach. + +If you add another dev tool that injects a page-level overlay, add its asset path to the `context.route(...)` block near the top of the script. + ## Narrowing to specific sections Use `--section ` to regenerate just one or more sections instead of the whole walkthrough. Section names match the directory under `screenshots/`, and the section filter also selects which resource-managers run (managers whose section isn't in the filter are skipped). @@ -82,7 +114,7 @@ When `--section` is set, other sections' shots from prior runs are preserved (no ## Walking view-page tabs -Off by default. Set `TABS=true` to include a shot of every Bootstrap tab pane on view pages (asset view alone has ~10 tabs: Licenses, Components, Maintenances, Audits, Notes, Files, and so on). Shot names are `{section}/{user}-view-tab-{slug}`. +Off by default. Set `TABS=true` to include a shot of every Bootstrap tab pane on view pages (asset view alone has ~10 tabs: Licenses, Components, Maintenances, Audits, Notes, Files, and so on). Shot names look like `{section}/{user}-{section}-view-tab-{slug}`. ```bash # Include all tabs @@ -92,7 +124,18 @@ TABS=true npm run screenshotter TABS=true npm run screenshotter -- --section assets ``` -Skipped silently on pages without any tabs. +Skipped silently on pages without any tabs. The already-active tab is skipped too since the base view shot already captured its content. + +## Light and dark mode + +`COLOR_SCHEME=light` (default) or `COLOR_SCHEME=dark`. Uses Playwright's `colorScheme` context option which sets `prefers-color-scheme` at the browser level. Snipe-IT users whose theme preference is "system" render in the requested scheme automatically, without needing to toggle anything in the UI. + +```bash +COLOR_SCHEME=dark npm run screenshotter +COLOR_SCHEME=dark npm run screenshotter -- --section assets +``` + +If a user's theme preference is set to something specific ("always dark" or "always light"), the app will honor that regardless of `prefers-color-scheme`, so this flag has no effect for those accounts. ## Browser-chrome framing @@ -108,9 +151,11 @@ FRAME=false npm run screenshotter Framing is done entirely locally via an inline HTML template plus a Playwright screenshot of the composed result. No external services are called, no image content leaves your machine. +The frame's address bar shows the URL path of the shot (e.g. `/hardware/1/edit`) as a rounded pill centered in the chrome. Only the path is rendered, not the full URL. This keeps things clean regardless of what your local testing host is (`snipe-it.test`, an ngrok tunnel, etc.) and avoids leaking your local hostname into published images. + ## Ad-hoc single-shot mode -Skip the full walkthrough and capture just one URL as a specific user. Useful for regenerating one stale image without re-running the whole ~5-minute sweep, or grabbing an off-catalog page for a one-off. +Skip the full walkthrough and capture just one URL as a specific user. Useful for regenerating one stale image without re-running the whole sweep, or grabbing an off-catalog page for a one-off. ```bash # Just a URL, using the default USERNAME (admin) @@ -119,8 +164,11 @@ node .screenshotter/src/screenshotter.mjs --one /hardware # As a specific role node .screenshotter/src/screenshotter.mjs --one /hardware/create --as assetmgr -# Framed, with a custom output name -FRAME=true node .screenshotter/src/screenshotter.mjs --one /licenses/5 --as licensemgr --name license-detail +# With a custom output name +node .screenshotter/src/screenshotter.mjs --one /licenses/5 --as licensemgr --name license-detail + +# Dark mode, no framing +COLOR_SCHEME=dark FRAME=false node .screenshotter/src/screenshotter.mjs --one /hardware --as admin ``` Ad-hoc shots land in `.screenshotter/screenshots/adhoc/{username}-{name}-{timestamp}.png`. The `adhoc/` directory is deliberately preserved across full walkthrough runs so historical one-off images stick around, and every ad-hoc shot carries a timestamp so repeated captures of the same URL never overwrite each other. @@ -133,22 +181,43 @@ Arguments: ## What a full run produces -Every generated PNG follows the naming convention `{section}/{username}-{page}-{timestamp}.png` so alphabetical sort groups shots by section, then by role, then by run. Example section directory contents after a run as `admin` plus the resource managers: +Every generated PNG follows the naming convention `{section}/{username}-{section}-{page}-{timestamp}.png` so alphabetical sort groups shots by section, then by role, then by run. The section appears in both the directory name and the filename so a single PNG shared out of context (dropped into a Discord thread, a PR comment, a support ticket) is still self-identifying. + +Example section directory contents after a run as `admin` plus the resource managers: ``` .screenshotter/screenshots/assets/ -├── admin-index-2026-07-21-113043.png -├── admin-view-2026-07-21-113043.png -├── admin-edit-2026-07-21-113043.png -├── admin-create-2026-07-21-113043.png -├── admin-create-status-dropdown-2026-07-21-113043.png -├── assetmgr-index-2026-07-21-113043.png -├── assetmgr-view-2026-07-21-113043.png -├── assetmgr-edit-2026-07-21-113043.png -└── assetmgr-create-2026-07-21-113043.png +├── admin-assets-index-2026-07-21-141230.png +├── admin-assets-view-2026-07-21-141230.png +├── admin-assets-edit-2026-07-21-141230.png +├── admin-assets-edit-submitted-2026-07-21-141230.png +├── admin-assets-checkout-2026-07-21-141230.png +├── admin-assets-create-2026-07-21-141230.png +├── admin-assets-create-status-dropdown-2026-07-21-141230.png +├── admin-assets-bulk-checkout-2026-07-21-141230.png +├── admin-assets-bulk-checkin-2026-07-21-141230.png +├── assetmgr-assets-index-2026-07-21-141230.png +├── assetmgr-assets-view-2026-07-21-141230.png +├── assetmgr-assets-edit-2026-07-21-141230.png +├── assetmgr-assets-edit-submitted-2026-07-21-141230.png +├── assetmgr-assets-checkout-2026-07-21-141230.png +└── assetmgr-assets-create-2026-07-21-141230.png ``` -Sections covered by the default walkthrough: assets, licenses, accessories, consumables, components, users, models, categories, manufacturers, suppliers, locations, departments, kits, companies, statuslabels, depreciations, dashboard, settings, reports. Plus the resource-manager perspectives (assetmgr, licensemgr, accessorymgr, consumablemgr, componentmgr, usermgr) scoped to just what each manager can access. Plus a superuser sweep of every parameter-free GET route in the app, filed under `all-routes/`, for visual gut-check purposes (set `ALL_ROUTES=false` to skip that pass). +Coverage per section: + +- **Index, view, edit, edit-submitted** for every first-class object. +- **Info-panel toggle** on view pages that have one: the base `-view` shot captures the default (expanded) state; an extra `-view-info-collapsed` (or `-view-info-expanded` if the initial state happened to be collapsed) captures the other. Docs can then show both compact and expanded layouts. +- **Checkout** for the checkoutable ones (assets, licenses, accessories, consumables, components, kits). +- **Create form** as an extra where useful (assets, users, licenses). +- **Bulk-checkout and bulk-checkin** under the assets section (`/hardware/bulkcheckout`, `/hardware/bulkcheckin`). +- **Interaction shots** (only assets today: the status dropdown open on the create form). + +Sections in the default walkthrough: `assets`, `licenses`, `accessories`, `consumables`, `components`, `users`, `models`, `categories`, `manufacturers`, `suppliers`, `locations`, `departments`, `kits`, `companies`, `statuslabels`, `depreciations`, `custom-fields`, `fieldsets`, `maintenance-types`, `dashboard`, `settings`, `reports`. + +Also shot as separate walkthroughs from the perspective of scoped resource-managers (each seeded with permissions for exactly one resource): `assetmgr`, `licensemgr`, `accessorymgr`, `consumablemgr`, `componentmgr`, `usermgr`. These land in the same section directories as the admin shots for side-by-side comparison. + +Finally, a superuser sweep of every parameter-free GET route in the app, filed under `all-routes/`, for visual gut-check purposes. Set `ALL_ROUTES=false` to skip that pass. ## How to add a new screenshot @@ -157,12 +226,12 @@ Each block in the script is intentionally explicit. Adding a new page or interac ```js await page.goto(`${BASE_URL}/consumables`); await waitForTable(); -await shot(`consumables/${USERNAME}-index`); +await shot(`consumables/${USERNAME}-consumables-index`); ``` For interaction shots (dropdown open, modal open, mid-flow state), click the trigger, wait for the target element to appear, then screenshot. The script uses `page.waitForLoadState('networkidle')` inside the `shot()` helper to defuse AdminLTE's async rendering, and a `waitForTable()` helper waits for bootstrap-table's loading overlay to clear before shooting a table page. -If your new block covers a first-class object (list plus detail plus edit page), you can also just add an entry to the `firstClassObjects` config array near the top of the walkthrough section and the loop will produce the three shots for free. +If your new block covers a first-class object (list plus detail plus edit page), add an entry to the `firstClassObjects` config array near the top of the walkthrough section and the loop will produce the three shots for free. Add `hasCheckout: true` if the entity is checkoutable, `hasView: false` if it has no detail page. ## Shooting the same page as different users @@ -172,11 +241,11 @@ The script has an `asUser(username, fn)` block helper that clears cookies, logs await asUser('viewer', async () => { await page.goto(`${BASE_URL}/hardware`); await waitForTable(); - await shot(`assets/viewer-index`); + await shot('assets/viewer-assets-index'); }); ``` -Users referenced in these blocks must exist in the seeded database. The demo seeders ship a small set of named users at varying permission levels (see the "resource-manager perspectives" section above), and the alternate-account username needs to match one of them (or one you add). +Users referenced in these blocks must exist in the seeded database. The demo seeders ship the six resource-manager users (assetmgr, licensemgr, accessorymgr, consumablemgr, componentmgr, usermgr) plus the standard admin/snipe accounts. Add more via `UserFactory` states if you need finer-grained roles for docs comparison shots. ## Workflow expectation @@ -185,3 +254,5 @@ When a PR adds or meaningfully changes a user-visible screen, modal, form, dropd ## Implementation notes The script uses the `.mjs` extension rather than `.js` so it always runs as an ES module regardless of what the root `package.json` says. The alternative was to add `"type": "module"` to `package.json`, which would flip every other `.js` file in the repo to ESM at the same time and is a much larger change than this script warrants. + +Source and output are separated (`.screenshotter/src/` vs `.screenshotter/screenshots/`) so the wipe-before-run logic can never accidentally delete the script itself. An earlier version had them in the same directory with a filename-based skip list, which self-deleted the running script the moment someone renamed the output directory. diff --git a/.screenshotter/src/screenshotter.mjs b/.screenshotter/src/screenshotter.mjs index f8ac981666..742af06e61 100644 --- a/.screenshotter/src/screenshotter.mjs +++ b/.screenshotter/src/screenshotter.mjs @@ -82,6 +82,11 @@ const SUBMIT_FORMS = process.env.SUBMIT_FORMS !== 'false'; // (a page with 5 tabs adds 4 extra shots per user). Set TABS=true to // opt in when you want the full sweep. const TABS = process.env.TABS === 'true'; +// Color scheme: "light" (default), "dark", or "no-preference". Passed +// through to Playwright's emulateMedia which sets prefers-color-scheme. +// Snipe-IT honors the OS preference for dark mode when the user's +// account preference is set to "system". +const COLOR_SCHEME = process.env.COLOR_SCHEME ?? 'light'; const {values: cli} = parseArgs({ options: { @@ -107,6 +112,7 @@ const RUN_TIMESTAMP = (() => { const pad = (n) => String(n).padStart(2, '0'); return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}-${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`; })(); +const RUN_STARTED_AT = performance.now(); console.log(`Base URL: ${BASE_URL}`); console.log(`Login as: ${cli.as ?? USERNAME}`); @@ -115,6 +121,7 @@ console.log(`Viewport: ${VIEWPORT.width}x${VIEWPORT.height}`); console.log(`Framing: ${FRAME ? 'local (generic-light)' : 'off'}`); console.log(`Submit: ${SUBMIT_FORMS ? 'on (edit forms are posted after shot)' : 'off (edit forms are not posted)'}`); console.log(`Tabs: ${TABS ? 'on (view pages walk each tab)' : 'off (view pages shoot base only)'}`); +console.log(`Color: ${COLOR_SCHEME}`); if (SECTION_FILTER) console.log(`Sections: ${[...SECTION_FILTER].join(', ')}`); if (ONE_SHOT) console.log(`Mode: ad-hoc single shot (${ONE_SHOT})`); console.log(''); @@ -142,8 +149,26 @@ const context = await browser.newContext({ viewport: VIEWPORT, // Herd's local .test domains use self-signed certs. ignoreHTTPSErrors: true, + // Set prefers-color-scheme so apps that honor it (Snipe-IT, when + // the user's theme preference is "system") render in the requested + // scheme without needing to click any in-app toggle. + colorScheme: COLOR_SCHEME, }); +// Block debugbar / telescope / clockwork asset requests at the network +// level. If their JS never loads, their overlays can never render, and +// we don't have to fight CSS specificity to hide them (which was the +// approach in an earlier version and broke on Snipe-IT error pages +// where debugbar rendered visible JSON collector panels that our +// CSS selectors couldn't reach). Belt-and-suspenders: we also inject +// the hiding CSS via addInitScript below for any dev-tool asset paths +// this block misses. +await context.route('**/_debugbar/**', (route) => route.abort()); +await context.route('**/telescope/**', (route) => route.abort()); +await context.route('**/telescope-toolbar/**', (route) => route.abort()); +await context.route('**/clockwork/**', (route) => route.abort()); +await context.route('**/__clockwork/**', (route) => route.abort()); + // Hide dev-only UI chrome from every page. Debugbar/Telescope/Clockwork // would otherwise land in the middle of shots. await context.addInitScript(() => { @@ -152,9 +177,15 @@ await context.addInitScript(() => { const style = document.createElement('style'); style.id = '__screenshotter-hide'; style.textContent = ` - #phpdebugbar, #phpdebugbar-openhandler, - .phpdebugbar, .phpdebugbar-openhandler, - #telescope-toolbar, .telescope-toolbar, + /* Laravel Debugbar: hide any element whose id or class + starts with / contains "phpdebugbar". Catches the main + toolbar, the resize handle, the open handler, and the + expanded collector panels that render on error pages + (which the narrower "exact-id" selectors missed). */ + [id^="phpdebugbar"], [class*="phpdebugbar"], + /* Laravel Telescope Toolbar */ + [id^="telescope-toolbar"], [class*="telescope-toolbar"], + /* Clockwork */ #clockwork, #clockwork-toolbar, [data-clockwork], iframe[src*="clockwork"] { display: none !important; @@ -182,6 +213,12 @@ async function shot(name, opts = {}) { await mkdir(dirname(path), {recursive: true}); const {element, ...playwrightOpts} = opts; await page.waitForLoadState('networkidle').catch(() => {}); + // Capture the current URL path for the frame address bar (relative + // path only, so the frame doesn't leak / dumb-look the local .test + // hostname). Grabbed at shot time rather than inside frameLocally + // because element-scoped screenshots may not represent a whole page. + const pageUrl = new URL(page.url()); + const addressBar = pageUrl.pathname + (pageUrl.search || ''); if (element) { await element.screenshot({path, ...playwrightOpts}); } else { @@ -189,7 +226,7 @@ async function shot(name, opts = {}) { } shotCount++; if (FRAME) { - await frameLocally(path); + await frameLocally(path, addressBar); console.log(` ✓ ${name}-${RUN_TIMESTAMP}.png (framed)`); } else { console.log(` ✓ ${name}-${RUN_TIMESTAMP}.png`); @@ -201,7 +238,7 @@ async function shot(name, opts = {}) { * generic-light style. Runs entirely local via an inline HTML template. */ let framePage = null; -async function frameLocally(imagePath) { +async function frameLocally(imagePath, urlPath = '') { if (!framePage) framePage = await context.newPage(); const fp = framePage; const buf = await readFile(imagePath); @@ -218,6 +255,14 @@ async function frameLocally(imagePath) { const outerHeight = dims.height + CHROME_HEIGHT + PADDING_TOP + PADDING_BOTTOM; await fp.setViewportSize({width: outerWidth, height: outerHeight}); + + // HTML-escape the URL path so a literal `<` / `>` / `&` in a + // query string (rare, but possible) can't break out of the address + // bar's text content. + const escapedPath = urlPath.replace(/[&<>"']/g, (c) => ( + {'&': '&', '<': '<', '>': '>', '"': '"', "'": '''}[c] + )); + await fp.setContent(` @@ -244,27 +289,51 @@ async function frameLocally(imagePath) { align-items: center; padding: 0 14px; box-sizing: border-box; + gap: 12px; } + .dots { display: flex; align-items: center; gap: 8px; flex-shrink: 0; } .dot { width: 12px; height: 12px; border-radius: 50%; display: inline-block; - margin-right: 8px; box-shadow: inset 0 0 0 0.5px rgba(0, 0, 0, 0.15); } .dot.red { background: #ff5f57; } .dot.yellow { background: #febc2e; } .dot.green { background: #28c840; } + /* Address bar: rounded pill, muted background, small monospace-ish + text. Grown to fit the space between the traffic-light dots and + the right edge, capped so long paths ellipsis instead of stretching + the chrome. */ + .addr { + flex: 1; + max-width: 60%; + margin: 0 auto; + background: #ffffff; + border: 1px solid rgba(0, 0, 0, 0.08); + border-radius: 999px; + padding: 4px 12px; + font-size: 12px; + line-height: 1; + color: #4a4a4a; + text-align: center; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } .shot { display: block; width: ${dims.width}px; height: ${dims.height}px; }
- - - +
+ + + +
+ ${escapedPath ? `
${escapedPath}
` : ''}
@@ -349,7 +418,7 @@ async function getFirstEntityId(segment) { * post-save, whether that's the success callout on the redirect target * or the validation-error state on the same page. */ -async function shootIndexViewEdit({segment, name, hasView = true, hasEdit = true, viewer}) { +async function shootIndexViewEdit({segment, name, hasView = true, hasEdit = true, hasCheckout = false, viewer}) { const who = viewer ?? USERNAME; console.log(`→ ${name} (as ${who})`); await page.goto(`${BASE_URL}/${segment}`); @@ -368,6 +437,7 @@ async function shootIndexViewEdit({segment, name, hasView = true, hasEdit = true await page.goto(`${BASE_URL}/${segment}/${id}`); await page.waitForLoadState('networkidle').catch(() => {}); await shot(`${name}/${who}-${name}-view`); + await toggleInfoPanelAndShoot(`${name}/${who}-${name}-view`); await walkTabs(`${name}/${who}-${name}-view`); } if (hasEdit) { @@ -376,6 +446,11 @@ async function shootIndexViewEdit({segment, name, hasView = true, hasEdit = true await shot(`${name}/${who}-${name}-edit`); await submitEditForm(`${name}/${who}-${name}-edit-submitted`); } + if (hasCheckout) { + await page.goto(`${BASE_URL}/${segment}/${id}/checkout`); + await page.waitForLoadState('networkidle').catch(() => {}); + await shot(`${name}/${who}-${name}-checkout`); + } } /** @@ -384,6 +459,39 @@ async function shootIndexViewEdit({segment, name, hasView = true, hasEdit = true * the base shot already captured), wait a beat for the pane transition, * and screenshot. Skips silently on pages with no tabs. */ +/** + * If the current page has the info-panel toggle button, capture the + * opposite state as an extra shot. The base view shot already caught + * whatever state the page loaded in (expanded by default); this adds + * `{basename}-info-collapsed` (or -expanded, depending on the starting + * state) so docs can show what a compact/expanded view looks like. + * + * Info-panel state is persisted in localStorage.side_panel_state, but + * we don't need to touch that. Clicking the toggle button drives the + * DOM changes directly via the expandInfoSidePanel / collapseInfoSidePanel + * functions defined in the default layout. + */ +async function toggleInfoPanelAndShoot(basename) { + const toggle = page.locator('#expand-info-panel-button:visible'); + if (!(await toggle.count().catch(() => 0))) return; + + // Look at the panel's current expanded state so the suffix reflects + // what we actually captured (not what we requested). + const wasExpanded = await page.evaluate( + () => !!document.querySelector('.side-box.expanded') + ).catch(() => true); + + await toggle.click().catch(() => {}); + await page.waitForTimeout(400); // let the panel fade / column resize + const suffix = wasExpanded ? 'info-collapsed' : 'info-expanded'; + await shot(`${basename}-${suffix}`); + + // Restore original state so subsequent shots on this page (tabs, + // etc.) aren't stuck in the toggled layout. + await toggle.click().catch(() => {}); + await page.waitForTimeout(200); +} + async function walkTabs(basename) { if (!TABS) return; const tabs = await page.locator('.nav-tabs a[data-toggle="tab"]:visible').all(); @@ -416,6 +524,18 @@ async function walkTabs(basename) { */ async function submitEditForm(outName) { if (!SUBMIT_FORMS) return; + // Force redirect_option=index so the post-submit destination is + // always the section's index page. Without this, Snipe-IT's + // Helper::getRedirectOption reads redirect_option=back (the form + // default) and uses session's url.intended, which for pages whose + // view template embeds a `` gets set to the + // qr_code endpoint (because that image load counts as a "previous + // URL"). The result is that Save lands on a raw PNG, which isn't + // what we want to screenshot. + await page.evaluate(() => { + const field = document.querySelector('form [name="redirect_option"]'); + if (field) field.value = 'index'; + }); // Find the primary Save button of the edit form: // - Exclude the topbar search form (#topSearchButton) which is the // first `[type=submit]` on every page and would fire an empty @@ -492,7 +612,7 @@ async function asUser(username, fn, password) { * view, edit, create) scoped strictly to the resource they manage. * Shots land in `${name}/{username}-{page}` alongside the admin shots. */ -async function shootManager({username, segment, name}) { +async function shootManager({username, segment, name, hasCheckout = true}) { await asUser(username, async () => { await page.goto(`${BASE_URL}/${segment}`); await waitForTable(); @@ -503,12 +623,19 @@ async function shootManager({username, segment, name}) { await page.goto(`${BASE_URL}/${segment}/${id}`); await page.waitForLoadState('networkidle').catch(() => {}); await shot(`${name}/${username}-${name}-view`); + await toggleInfoPanelAndShoot(`${name}/${username}-${name}-view`); await walkTabs(`${name}/${username}-${name}-view`); await page.goto(`${BASE_URL}/${segment}/${id}/edit`); await page.waitForLoadState('networkidle').catch(() => {}); await shot(`${name}/${username}-${name}-edit`); await submitEditForm(`${name}/${username}-${name}-edit-submitted`); + + if (hasCheckout) { + await page.goto(`${BASE_URL}/${segment}/${id}/checkout`); + await page.waitForLoadState('networkidle').catch(() => {}); + await shot(`${name}/${username}-${name}-checkout`); + } } else { console.log(` ! ${username}: no rows in ${name}, skipping view/edit`); } @@ -537,7 +664,8 @@ if (ONE_SHOT) { await capPagination(); await shot(outName); - console.log(`Done. 1 screenshot written to ${OUT}/${outName}-${RUN_TIMESTAMP}.png`); + const elapsedMs = performance.now() - RUN_STARTED_AT; + console.log(`Done. 1 screenshot written to ${OUT}/${outName}-${RUN_TIMESTAMP}.png in ${(elapsedMs / 1000).toFixed(2)}s.`); await browser.close(); process.exit(0); } @@ -552,11 +680,12 @@ await loginAs(USERNAME, PASSWORD); // First-class objects: index + view + edit for each. // `hasView: false` skips the view shot for entities without a detail page. const firstClassObjects = [ - {segment: 'hardware', name: 'assets'}, - {segment: 'licenses', name: 'licenses'}, - {segment: 'accessories', name: 'accessories'}, - {segment: 'consumables', name: 'consumables'}, - {segment: 'components', name: 'components'}, + {segment: 'hardware', name: 'assets', hasCheckout: true}, + {segment: 'licenses', name: 'licenses', hasCheckout: true}, + {segment: 'accessories', name: 'accessories', hasCheckout: true}, + {segment: 'consumables', name: 'consumables', hasCheckout: true}, + {segment: 'components', name: 'components', hasCheckout: true}, + {segment: 'kits', name: 'kits', hasCheckout: true}, {segment: 'users', name: 'users'}, {segment: 'models', name: 'models'}, {segment: 'categories', name: 'categories'}, @@ -564,7 +693,6 @@ const firstClassObjects = [ {segment: 'suppliers', name: 'suppliers'}, {segment: 'locations', name: 'locations'}, {segment: 'departments', name: 'departments'}, - {segment: 'kits', name: 'kits'}, {segment: 'companies', name: 'companies'}, {segment: 'statuslabels', name: 'statuslabels'}, {segment: 'depreciations', name: 'depreciations'}, @@ -577,7 +705,7 @@ for (const obj of firstClassObjects) { await shootIndexViewEdit(obj); } -// Extras: create form + status-dropdown interaction on assets. +// Extras: create form, status-dropdown interaction, bulk pages. if (includesSection('assets')) { console.log('→ assets extras'); await page.goto(`${BASE_URL}/hardware/create`); @@ -588,6 +716,13 @@ if (includesSection('assets')) { }); await page.waitForSelector('.select2-dropdown', {timeout: 5_000}).catch(() => {}); await shot(`assets/${USERNAME}-assets-create-status-dropdown`, {fullPage: false}); + + // Bulk actions live on the assets side of the app. + await page.goto(`${BASE_URL}/hardware/bulkcheckout`); + await shot(`assets/${USERNAME}-assets-bulk-checkout`); + + await page.goto(`${BASE_URL}/hardware/bulkcheckin`); + await shot(`assets/${USERNAME}-assets-bulk-checkin`); } // Extra create forms for users and licenses (bundled with those sections @@ -632,7 +767,9 @@ const managers = [ {username: 'accessorymgr', segment: 'accessories', name: 'accessories'}, {username: 'consumablemgr', segment: 'consumables', name: 'consumables'}, {username: 'componentmgr', segment: 'components', name: 'components'}, - {username: 'usermgr', segment: 'users', name: 'users'}, + // Users are checkout targets, not checkoutable subjects, so no + // `/users/{id}/checkout` route to shoot. + {username: 'usermgr', segment: 'users', name: 'users', hasCheckout: false}, ]; for (const mgr of managers) { if (!includesSection(mgr.name)) continue; @@ -694,5 +831,9 @@ if (ALL_ROUTES && !SECTION_FILTER) { } console.log(''); -console.log(`Done. ${shotCount} screenshots written to ${OUT}`); +const elapsedMs = performance.now() - RUN_STARTED_AT; +const elapsed = elapsedMs > 60_000 + ? `${Math.floor(elapsedMs / 60_000)}m ${((elapsedMs % 60_000) / 1000).toFixed(1)}s` + : `${(elapsedMs / 1000).toFixed(2)}s`; +console.log(`Done. ${shotCount} screenshots written to ${OUT} in ${elapsed}.`); await browser.close();