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:
@ -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.
|
||||
|
||||
@ -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(`
|
||||
<!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();
|
||||
|
||||
Reference in New Issue
Block a user