3
0
mirror of https://github.com/snipe/snipe-it.git synced 2026-08-18 11:15:42 +00:00

Fixed weird JSON screenshot issue

This commit is contained in:
snipe
2026-07-21 14:31:06 +01:00
parent 3c28697ba9
commit 5a6fdb0784
2 changed files with 264 additions and 52 deletions

View File

@ -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 <name>` 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.

View File

@ -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) => (
{'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'}[c]
));
await fp.setContent(`
<!doctype html>
<html>
@ -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; }
</style>
</head>
<body>
<div class="frame">
<div class="chrome">
<span class="dot red"></span>
<span class="dot yellow"></span>
<span class="dot green"></span>
<div class="dots">
<span class="dot red"></span>
<span class="dot yellow"></span>
<span class="dot green"></span>
</div>
${escapedPath ? `<div class="addr">${escapedPath}</div>` : ''}
</div>
<img class="shot" src="${dataUri}"/>
</div>
@ -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 `<img src=".../qr_code">` 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();