Files
intotheeast-com/tests/ui/helpers.js
T
m038 1f4e2aeba5 fix(test): close the test-entry leak into real trip content
A ui-test entry had survived into the active trip's dailies. Three independent
failures had to line up for that, and all three were real:

1. cleanupEntry() used host-side fs.rmSync. Grav's Apache workers run as root,
   so every entry the form creates is root-owned and recursive removal needs
   write permission on that directory — which the host user lacks. Cleanup had
   never worked for form-created entries; it just threw inside a path nothing
   checked. It now falls back to `docker exec … rm -rf` in the container that
   actually serves USER_DIR.

2. globalTeardown's dailies sweep keyed off a `parent:` in post-form.md — a key
   deliberately removed (the write target comes from site.yaml active_trip, and
   CLAUDE.md forbids re-adding a static parent). The regex could never match, so
   dailiesDir was always null and the sweep silently did nothing. It now reuses
   helpers' own resolution instead of keeping a divergent copy.

3. Nothing pinned the suite to this checkout's server. playwright.config.js
   defaults to :8081, so a worktree run hit the MAIN checkout — entries created
   in one content tree while the specs asserted and cleaned up in another.
   test-ui now passes GRAV_BASE_URL from GRAV_PORT, and globalSetup hard-fails
   when the server's bind mount disagrees with the tree the specs read.

Also fixed, found on the way to a green run:

- test-account interpolated the password into an `sh -c` string, so a password
  containing a shell metacharacter was re-parsed by the container's shell
  (`sh: 2: <fragment>: not found`, no account, every UI run dead). It now
  travels via `docker exec -e`, making the recipe indifferent to its contents.
- `make start` in a worktree always failed: travel-memories declares
  `env_file: .env` and worktree-new creates none. It degrades to start-grav
  there — a worktree with no server is what sent runs to :8081 in the first
  place.
- test-form-config asserted a hero_image field that 8cf1145 deliberately
  removed; it had been failing ever since.

Verified: config 22/22, post 6/6, location-override 20/20, and a full UI run
now leaves zero ui-test entries behind. The remaining UI failures are
pre-existing on main — site.yaml pins owner_username to a real account while
the suite logs in as testrunner, so owner-only controls never render for it.
Only trip-publish.spec.js patches that; delete-flow, edit-mode and anon-view
do not. Left for a separate branch.
2026-07-24 22:33:46 +02:00

263 lines
11 KiB
JavaScript

// @ts-check
const path = require('path');
const fs = require('fs');
const { execSync } = require('child_process');
const { expect } = require('@playwright/test');
// The shared photo fixture every create goes through (the post form gates submit
// on at least one uploaded photo).
const TEST_PHOTO = path.join(__dirname, '../fixtures/test-photo.jpg');
/**
* Resolve the Grav user directory.
*
* Resolution order:
* 1. GRAV_USER_DIR env var (set in .env or shell)
* 2. Sibling `user/` directory — authoritative for this repo's layout, where
* docker-compose always bind-mounts `./user` relative to the checkout. This
* is correct for BOTH the main checkout and a git worktree (each worktree
* serves its own `./user`), so it must be preferred over docker inspect.
* 3. `docker inspect intotheeast_grav` — last-resort fallback for running the
* specs detached from the served checkout. NOTE: from a worktree this points
* at the MAIN checkout's container (a different `user/`), so it must never
* win over the sibling dir above, or disk assertions look in the wrong tree.
*/
function resolveUserDir() {
if (process.env.GRAV_USER_DIR) {
return process.env.GRAV_USER_DIR;
}
const sibling = path.join(__dirname, '../../user');
if (fs.existsSync(path.join(sibling, 'config/site.yaml'))) {
return sibling;
}
try {
const raw = execSync(
"docker inspect intotheeast_grav --format '{{range .Mounts}}{{if eq .Destination \"/var/www/html/user\"}}{{.Source}}{{end}}{{end}}'",
{ encoding: 'utf-8', stdio: ['pipe', 'pipe', 'ignore'] }
).trim();
if (raw) return raw;
} catch (_) {
// docker not available or container not running
}
return sibling;
}
/**
* Resolve the active trip slug from site.yaml `active_trip`.
*
* The post form no longer hardcodes `pageconfig.parent` — the write target is
* injected server-side from `site.active_trip` (see the cache-on-save plugin).
* `active_trip` is a full route ("/trips/italy-2026-demo") or a bare slug; both
* reduce to the trip slug here.
*/
function resolveActiveTripSlug(userDir) {
const sitePath = path.join(userDir, 'config/site.yaml');
if (!fs.existsSync(sitePath)) return null;
const content = fs.readFileSync(sitePath, 'utf-8');
const m = content.match(/^active_trip:\s*['"]?(\S+?)['"]?\s*$/m);
if (!m) return null;
return m[1]
.replace(/^\/?trips\//, '') // strip a leading /trips/
.replace(/^\//, '')
.replace(/\/.*$/, ''); // keep only the slug segment
}
/**
* Resolve the active dailies directory on disk from the active trip slug.
*/
function resolveDailiesDir(userDir) {
const tripSlug = resolveActiveTripSlug(userDir);
if (!tripSlug) return null;
const tripsBase = path.join(userDir, 'pages/01.trips');
if (!fs.existsSync(tripsBase)) return null;
const tripFolder = fs.readdirSync(tripsBase).find(f => f === tripSlug || f.endsWith('.' + tripSlug) || f.includes(tripSlug));
if (!tripFolder) return null;
const dailiesBase = path.join(tripsBase, tripFolder);
const dailiesFolder = fs.readdirSync(dailiesBase).find(f => f === 'dailies' || f === '01.dailies' || f.endsWith('.dailies'));
if (!dailiesFolder) return null;
return path.join(dailiesBase, dailiesFolder);
}
const USER_DIR = resolveUserDir();
const TRACKER_DIR = resolveDailiesDir(USER_DIR) || path.join(USER_DIR, 'pages/01.trips/italy-2026-demo/01.dailies');
/**
* The Grav route to the active trip page, derived from site.yaml `active_trip`.
* Posted entries surface in this page's journal feed.
* Falls back to '/trips/italy-2026-demo'.
*/
function resolveActiveTripUrl() {
const slug = resolveActiveTripSlug(USER_DIR);
return slug ? '/trips/' + slug : '/trips/italy-2026-demo';
}
const ACTIVE_TRIP_URL = resolveActiveTripUrl();
/**
* Type content into the EasyMDE editor. The underlying <textarea> is hidden by
* EasyMDE, so we set the value through the instance the bundle exposes on
* window.postFormEditor (which also syncs the textarea for submission).
*/
async function fillEditor(page, text) {
await page.waitForFunction(() => window.postFormEditor != null, { timeout: 10_000 });
await page.evaluate((t) => window.postFormEditor.value(t), text);
}
/**
* Wait for photos to finish uploading. post-form.js converts HEIC->JPEG and
* hands files to FilePond via pond.addFile(); FilePond then uploads each, and a
* finished item reaches data-filepond-item-state="processing-complete".
*/
async function waitForPhotoUpload(page, count = 1) {
await page.waitForFunction(
(n) => {
const items = document.querySelectorAll('.filepond--item[data-filepond-item-state]');
return [...items].filter(el => el.getAttribute('data-filepond-item-state') === 'processing-complete').length >= n;
},
count,
{ timeout: 40_000 }
);
}
/**
* Submit the post form with minimal required fields and return the unique title marker.
* Caller is responsible for cleanup via cleanupEntry().
*/
async function postEntry(page, { titleTag, content = 'Automated test. Safe to delete.', city = '', country = '' } = {}) {
const title = `UI Test ${titleTag} ${Date.now()}`;
await page.goto('/post');
await page.fill('input[name="data[title]"]', title);
await fillEditor(page, content);
if (city) await page.fill('input[name="data[location_city]"]', city);
if (country) await page.fill('input[name="data[location_country]"]', country);
await page.locator('.btn-post').evaluate(el => el.click());
await page.waitForSelector('.form-messages, .notices', { timeout: 15_000 });
return titleTag;
}
/**
* Create a fresh journal entry through the /post create form, with a photo
* attached so the submit gate is satisfied. Shared by the specs that need a
* disposable feed card to act on (delete-flow, edit-mode, anon-view draft).
*
* Pass the spec's `created` array so the tag is registered for cleanup BEFORE
* the (slow, 15s) success-toast assertion — a create that lands on disk but
* whose toast assertion times out would otherwise leak an entry the afterAll
* hook never sees. `publish:false` flips the Published toggle off to make a
* draft (the toggle is a visually-hidden radio pair behind "More options", so
* set state + fire `change` rather than fighting the visibility gate).
*/
async function createPhotoEntry(page, tag, { content, publish = true, created } = {}) {
await page.goto('/post');
await page.fill('input[name="data[title]"]', `UI Test ${tag}`);
await fillEditor(page, content || `Fixture for ${tag}. Safe to delete.`);
await page.locator('input.filepond--browser').setInputFiles(TEST_PHOTO);
await waitForPhotoUpload(page);
if (!publish) {
await page.evaluate(() => {
const off = document.querySelector('input[name="data[published]"][value="0"]');
off.checked = true;
off.dispatchEvent(new Event('change', { bubbles: true }));
});
}
await page.locator('.btn-post').evaluate(el => el.click());
if (created) created.push(tag);
await expect(page.locator('.form-messages, .notices')).toContainText(
'Entry posted successfully!', { timeout: 15_000 });
}
/**
* Resolve the Grav container that serves USER_DIR, so cleanup can delete as root.
* Prefers GRAV_CONTAINER (set by .worktree-env / .env), else matches on the bind
* mount so a worktree never picks the main checkout's container.
*/
function resolveGravContainer() {
if (process.env.GRAV_CONTAINER) return process.env.GRAV_CONTAINER;
try {
const want = fs.realpathSync(USER_DIR);
const names = execSync("docker ps --format '{{.Names}}'", { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'ignore'] })
.split('\n').filter(Boolean);
return names.find((n) => {
const src = execSync(
`docker inspect ${n} --format '{{range .Mounts}}{{if eq .Destination "/var/www/html/user"}}{{.Source}}{{end}}{{end}}'`,
{ encoding: 'utf-8', stdio: ['pipe', 'pipe', 'ignore'] }
).trim();
return src && fs.realpathSync(src) === want;
}) || null;
} catch (_) {
return null;
}
}
/**
* Delete an entry directory, falling back to the container when the host cannot.
*
* Grav's Apache workers run as root, so every entry the form creates is
* root-owned. Removing one recursively needs write permission on that directory,
* which the host user does not have — so a plain fs.rmSync throws EACCES and the
* entry survives. That is how a ui-test entry ended up committed-adjacent in the
* active trip's content on 2026-07-24: cleanup had never actually worked for
* form-created entries, it just failed inside a path nothing checked.
*
* `docker exec … rm -rf` runs as root in the container, which can remove them.
*/
function removeEntryDir(dir) {
try {
fs.rmSync(dir, { recursive: true });
return true;
} catch (err) {
if (err.code !== 'EACCES' && err.code !== 'EPERM') throw err;
}
const container = resolveGravContainer();
if (!container) {
throw new Error(
`Cannot remove ${dir}: it is root-owned (written by Grav in the container) and no ` +
`matching container was found to delete it as root. Set GRAV_CONTAINER or remove it manually.`
);
}
execSync(`docker exec ${container} rm -rf '/var/www/html/user/${path.relative(USER_DIR, dir)}'`,
{ stdio: ['pipe', 'pipe', 'pipe'] });
return true;
}
/**
* Find a tracker entry folder by a unique slug fragment, then delete it.
*/
function cleanupEntry(slugFragment) {
if (!slugFragment) return;
if (!fs.existsSync(TRACKER_DIR)) return;
const entries = fs.readdirSync(TRACKER_DIR);
const match = entries.find(e => e.includes(slugFragment));
if (match) {
removeEntryDir(path.join(TRACKER_DIR, match));
}
}
/**
* Find the first entry folder matching a slug fragment and return its full path.
*/
function findEntry(slugFragment) {
if (!fs.existsSync(TRACKER_DIR)) return null;
const entries = fs.readdirSync(TRACKER_DIR);
const match = entries.find(e => e.includes(slugFragment));
return match ? path.join(TRACKER_DIR, match) : null;
}
/**
* Read the entry .md file (entry.md or entry.en.md) from an entry folder.
*/
function readEntryMd(entryDir) {
const name = ['entry.md', 'entry.en.md'].find(f => fs.existsSync(path.join(entryDir, f)));
if (!name) return null;
return fs.readFileSync(path.join(entryDir, name), 'utf-8');
}
// USER_DIR is exported so global-setup/global-teardown resolve the same tree the
// specs assert against, instead of keeping their own (previously divergent) copy
// of this logic.
module.exports = { fillEditor, waitForPhotoUpload, postEntry, createPhotoEntry, cleanupEntry, removeEntryDir, findEntry, readEntryMd, TEST_PHOTO, USER_DIR, TRACKER_DIR, ACTIVE_TRIP_URL };