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.
405 lines
20 KiB
JavaScript
405 lines
20 KiB
JavaScript
// @ts-check
|
|
// Tests: post form "More location details" — search-by-city lookup + draggable
|
|
// map pin preview for setting an entry's coordinates without live GPS.
|
|
// Covers R4-R14. The Open-Meteo geocoding endpoint is mocked via page.route()
|
|
// so this suite is hermetic (no live third-party call, no rate-limit flakiness).
|
|
const { test, expect } = require('@playwright/test');
|
|
const path = require('path');
|
|
const { fillEditor, waitForPhotoUpload, cleanupEntry, findEntry, readEntryMd, TEST_PHOTO } = require('../helpers');
|
|
|
|
const GEOCODE_URL = '**/geocoding-api.open-meteo.com/v1/search**';
|
|
|
|
const created = [];
|
|
test.afterAll(() => { created.forEach(cleanupEntry); });
|
|
|
|
// Real-API-shaped fixtures (verified live against geocoding-api.open-meteo.com).
|
|
const KYOTO_RESULTS = {
|
|
results: [
|
|
{ name: 'Kyoto', latitude: 35.0116, longitude: 135.7681, admin1: 'Kyoto Prefecture', country: 'Japan' }
|
|
]
|
|
};
|
|
|
|
// Mirrors the design doc's verified live Paris query: Île-de-France (France)
|
|
// first from the API, then five US states — Texas among them, in admin1 (the
|
|
// API's `country` field is "United States" for all of the US matches, so the
|
|
// ranking must also check admin1 to disambiguate on a US state name).
|
|
const PARIS_RESULTS = {
|
|
results: [
|
|
{ name: 'Paris', latitude: 48.85341, longitude: 2.3488, admin1: 'Île-de-France Region', country: 'France' },
|
|
{ name: 'Paris', latitude: 33.66094, longitude: -95.55551, admin1: 'Texas', country: 'United States' },
|
|
{ name: 'Paris', latitude: 36.302, longitude: -88.32671, admin1: 'Tennessee', country: 'United States' },
|
|
{ name: 'Paris', latitude: 38.2098, longitude: -84.2529, admin1: 'Kentucky', country: 'United States' },
|
|
{ name: 'Paris', latitude: 39.6112, longitude: -87.6961, admin1: 'Illinois', country: 'United States' }
|
|
]
|
|
};
|
|
|
|
function mockGeocode(page, body) {
|
|
return page.route(GEOCODE_URL, (route) => route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify(body)
|
|
}));
|
|
}
|
|
|
|
async function openLocationDetails(page) {
|
|
await page.locator('.location-details__summary').click();
|
|
await expect(page.locator('.location-details')).toHaveJSProperty('open', true);
|
|
}
|
|
|
|
// ── Panel closed by default (R1) ────────────────────────────────────────────
|
|
test('More location details is closed by default and holds the relocated lat/lng fields', async ({ page }) => {
|
|
await page.goto('/post');
|
|
const details = page.locator('.location-details');
|
|
await expect(details).toBeAttached();
|
|
await expect(details).toHaveJSProperty('open', false);
|
|
await expect(page.locator('.location-details input[name="data[lat]"]')).toBeAttached();
|
|
await expect(page.locator('.location-details input[name="data[lng]"]')).toBeAttached();
|
|
});
|
|
|
|
// ── R6: empty City + Country sends no request ───────────────────────────────
|
|
test('R6: clicking lookup with City and Country both empty sends no request', async ({ page }) => {
|
|
await page.goto('/post');
|
|
let requested = false;
|
|
await page.route(GEOCODE_URL, (route) => { requested = true; route.abort(); });
|
|
await openLocationDetails(page);
|
|
await page.click('#lookup-coords');
|
|
await expect(page.locator('#location-search-hint')).toContainText(/city or country/i);
|
|
expect(requested).toBe(false);
|
|
});
|
|
|
|
// ── R7: a search result sets lat/lng only, never City/Country ──────────────
|
|
test('R7: clicking a search result sets lat/lng and leaves City/Country untouched', async ({ page }) => {
|
|
await page.goto('/post');
|
|
await mockGeocode(page, KYOTO_RESULTS);
|
|
await page.fill('input[name="data[location_city]"]', 'Kyoto');
|
|
await openLocationDetails(page);
|
|
await page.click('#lookup-coords');
|
|
|
|
const results = page.locator('.location-search-results li button');
|
|
await expect(results).toHaveCount(1);
|
|
await results.first().click();
|
|
|
|
await expect(page.locator('input[name="data[lat]"]')).toHaveValue('35.011600');
|
|
await expect(page.locator('input[name="data[lng]"]')).toHaveValue('135.768100');
|
|
await expect(page.locator('input[name="data[location_city]"]')).toHaveValue('Kyoto');
|
|
await expect(page.locator('input[name="data[location_country]"]')).toHaveValue('');
|
|
// R7: the list hides again until the next lookup.
|
|
await expect(page.locator('.location-search-results li')).toHaveCount(0);
|
|
});
|
|
|
|
// ── R4/KTD2: Paris/Texas disambiguation ranks the Texas match first ─────────
|
|
test('disambiguation: City "Paris" + Country "Texas" ranks the Texas match first', async ({ page }) => {
|
|
await page.goto('/post');
|
|
let requestedUrl = null;
|
|
await page.route(GEOCODE_URL, (route) => {
|
|
requestedUrl = route.request().url();
|
|
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(PARIS_RESULTS) });
|
|
});
|
|
await page.fill('input[name="data[location_city]"]', 'Paris');
|
|
await page.fill('input[name="data[location_country]"]', 'Texas');
|
|
await openLocationDetails(page);
|
|
await page.click('#lookup-coords');
|
|
|
|
const results = page.locator('.location-search-results li button');
|
|
await expect(results).toHaveCount(5);
|
|
await expect(results.first()).toContainText('Texas');
|
|
|
|
// R4: Country is never concatenated into the query string.
|
|
expect(requestedUrl).toContain('name=Paris');
|
|
expect(requestedUrl).not.toContain('Texas');
|
|
});
|
|
|
|
// ── R8: no matches shows the inline hint, fields untouched ─────────────────
|
|
test('R8: no matches shows the no-match hint and leaves fields untouched', async ({ page }) => {
|
|
await page.goto('/post');
|
|
await mockGeocode(page, { results: [] });
|
|
await page.fill('input[name="data[location_city]"]', 'Nowheresville');
|
|
await openLocationDetails(page);
|
|
await page.click('#lookup-coords');
|
|
|
|
await expect(page.locator('#location-search-hint')).toContainText(/no matches/i);
|
|
await expect(page.locator('input[name="data[lat]"]')).toHaveValue('');
|
|
await expect(page.locator('input[name="data[lng]"]')).toHaveValue('');
|
|
});
|
|
|
|
// ── R5: in-flight state shows "Searching…" and always re-enables ───────────
|
|
test('R5: the lookup button shows a disabled "Searching…" state while in flight', async ({ page }) => {
|
|
await page.goto('/post');
|
|
await page.route(GEOCODE_URL, async (route) => {
|
|
await new Promise((r) => setTimeout(r, 400));
|
|
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(KYOTO_RESULTS) });
|
|
});
|
|
await page.fill('input[name="data[location_city]"]', 'Kyoto');
|
|
await openLocationDetails(page);
|
|
await page.click('#lookup-coords');
|
|
|
|
const btn = page.locator('#lookup-coords');
|
|
await expect(btn).toBeDisabled();
|
|
await expect(btn).toHaveText('Searching…');
|
|
await expect(btn).toBeEnabled({ timeout: 5_000 });
|
|
await expect(btn).toContainText('Look up coordinates');
|
|
});
|
|
|
|
// ── R8: a network failure degrades silently and re-enables the button ──────
|
|
test('a network failure degrades silently, leaves fields untouched, and re-enables the button', async ({ page }) => {
|
|
await page.goto('/post');
|
|
await page.route(GEOCODE_URL, (route) => route.abort('failed'));
|
|
await page.fill('input[name="data[location_city]"]', 'Kyoto');
|
|
await openLocationDetails(page);
|
|
await page.click('#lookup-coords');
|
|
|
|
await expect(page.locator('#lookup-coords')).toBeEnabled();
|
|
await expect(page.locator('input[name="data[lat]"]')).toHaveValue('');
|
|
await expect(page.locator('input[name="data[lng]"]')).toHaveValue('');
|
|
});
|
|
|
|
// ── XSS safety: an API-sourced name containing markup renders as literal text ──
|
|
test('a result name containing markup renders as literal text, not executed', async ({ page }) => {
|
|
await page.goto('/post');
|
|
await mockGeocode(page, {
|
|
results: [{ name: '<img src=x onerror="window.__xss=true">', latitude: 1, longitude: 2, country: 'Nowhere' }]
|
|
});
|
|
await page.fill('input[name="data[location_city]"]', 'Test');
|
|
await openLocationDetails(page);
|
|
await page.click('#lookup-coords');
|
|
|
|
const btn = page.locator('.location-search-results li button').first();
|
|
await expect(btn).toContainText('<img src=x onerror="window.__xss=true">');
|
|
expect(await btn.evaluate((el) => el.querySelector('img'))).toBeNull();
|
|
expect(await page.evaluate(() => window.__xss)).toBeUndefined();
|
|
});
|
|
|
|
// ── U4: map renders exactly one canvas, no pin until a coordinate is set ───
|
|
test('opening the panel renders exactly one map canvas with no initial pin', async ({ page }) => {
|
|
await page.goto('/post');
|
|
await openLocationDetails(page);
|
|
await expect(page.locator('#location-map canvas.maplibregl-canvas')).toHaveCount(1, { timeout: 10_000 });
|
|
await expect(page.locator('#location-map .maplibregl-marker')).toHaveCount(0);
|
|
});
|
|
|
|
// ── U4: reopening does not duplicate the canvas; resize keeps it non-zero ──
|
|
test('reopening the panel a second time leaves exactly one canvas with non-zero size', async ({ page }) => {
|
|
await page.goto('/post');
|
|
await openLocationDetails(page);
|
|
await page.locator('.location-details__summary').click(); // close
|
|
await expect(page.locator('.location-details')).toHaveJSProperty('open', false);
|
|
await openLocationDetails(page); // reopen
|
|
|
|
const canvases = page.locator('#location-map canvas.maplibregl-canvas');
|
|
await expect(canvases).toHaveCount(1, { timeout: 10_000 });
|
|
const box = await canvases.first().boundingBox();
|
|
expect(box && box.width).toBeGreaterThan(0);
|
|
expect(box && box.height).toBeGreaterThan(0);
|
|
});
|
|
|
|
// ── R11: a search pick shows a pin on the map ───────────────────────────────
|
|
test('a search-result pick renders a pin on the map', async ({ page }) => {
|
|
await page.goto('/post');
|
|
await mockGeocode(page, KYOTO_RESULTS);
|
|
await page.fill('input[name="data[location_city]"]', 'Kyoto');
|
|
await openLocationDetails(page);
|
|
await page.click('#lookup-coords');
|
|
await page.locator('.location-search-results li button').first().click();
|
|
|
|
await expect(page.locator('#location-map .maplibregl-marker')).toHaveCount(1, { timeout: 10_000 });
|
|
});
|
|
|
|
// ── R11: dragging the marker updates lat/lng (rounded to 6dp) ──────────────
|
|
test('dragging the pin updates lat/lng to the drop location', async ({ page }) => {
|
|
await page.goto('/post');
|
|
await mockGeocode(page, KYOTO_RESULTS);
|
|
await page.fill('input[name="data[location_city]"]', 'Kyoto');
|
|
await openLocationDetails(page);
|
|
await page.click('#lookup-coords');
|
|
await page.locator('.location-search-results li button').first().click();
|
|
|
|
const marker = page.locator('#location-map .maplibregl-marker');
|
|
await expect(marker).toHaveCount(1, { timeout: 10_000 });
|
|
const before = await page.locator('input[name="data[lat]"]').inputValue();
|
|
|
|
// setPin()'s map.panTo() animates the marker into view — wait for it to
|
|
// settle so the bounding box grabbed below matches where the marker will
|
|
// actually be when the mouse events land.
|
|
await page.waitForTimeout(800);
|
|
const box = await marker.boundingBox();
|
|
if (!box) throw new Error('marker has no bounding box');
|
|
const startX = box.x + box.width / 2;
|
|
const startY = box.y + box.height / 2;
|
|
await page.mouse.move(startX, startY);
|
|
await page.mouse.down();
|
|
await page.mouse.move(startX + 40, startY + 30, { steps: 5 });
|
|
await page.mouse.up();
|
|
|
|
await expect(async () => {
|
|
const after = await page.locator('input[name="data[lat]"]').inputValue();
|
|
expect(after).not.toBe(before);
|
|
expect(after).toMatch(/^-?\d+\.\d{6}$/);
|
|
}).toPass({ timeout: 5_000 });
|
|
});
|
|
|
|
// ── R11/R13: typing an invalid value flags the field without crashing ──────
|
|
test('typing an invalid lat value shows the mismatch flag and clears once fixed', async ({ page }) => {
|
|
await page.goto('/post');
|
|
await openLocationDetails(page);
|
|
|
|
const latEl = page.locator('input[name="data[lat]"]');
|
|
const lngEl = page.locator('input[name="data[lng]"]');
|
|
await latEl.fill('not-a-number');
|
|
await lngEl.fill('135.7681');
|
|
await lngEl.blur();
|
|
|
|
await expect(latEl).toHaveClass(/location-field--mismatch/);
|
|
await expect(latEl).toHaveAttribute('aria-invalid', 'true');
|
|
await expect(page.locator('#location-map .maplibregl-marker')).toHaveCount(0);
|
|
|
|
await latEl.fill('35.0116');
|
|
await latEl.blur();
|
|
await expect(latEl).not.toHaveClass(/location-field--mismatch/);
|
|
await expect(page.locator('#location-map .maplibregl-marker')).toHaveCount(1);
|
|
});
|
|
|
|
// ── U4: rapid close/reopen while the maplibre-gl chunk is still in flight must
|
|
// not build two Map instances against the same container (code-review fix) ──
|
|
test('rapid close/reopen before the maplibre-gl chunk resolves still leaves exactly one canvas', async ({ page }) => {
|
|
await page.route('**/*maplibre-gl*.js', async (route) => {
|
|
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
await route.continue();
|
|
});
|
|
await page.goto('/post');
|
|
|
|
// Open, then immediately close and reopen — both toggles land while the
|
|
// delayed chunk request above is still pending.
|
|
await page.locator('.location-details__summary').click();
|
|
await page.locator('.location-details__summary').click();
|
|
await page.locator('.location-details__summary').click();
|
|
await expect(page.locator('.location-details')).toHaveJSProperty('open', true);
|
|
|
|
await expect(page.locator('#location-map canvas.maplibregl-canvas')).toHaveCount(1, { timeout: 10_000 });
|
|
});
|
|
|
|
// ── U5: blanking both fields after a mismatch was flagged clears the flag ──
|
|
test('blanking both lat/lng fields after a mismatch clears the flag', async ({ page }) => {
|
|
await page.goto('/post');
|
|
await openLocationDetails(page);
|
|
|
|
const latEl = page.locator('input[name="data[lat]"]');
|
|
const lngEl = page.locator('input[name="data[lng]"]');
|
|
await latEl.fill('not-a-number');
|
|
await lngEl.blur();
|
|
await expect(latEl).toHaveClass(/location-field--mismatch/);
|
|
|
|
await latEl.fill('');
|
|
await lngEl.fill('');
|
|
await lngEl.blur();
|
|
await expect(latEl).not.toHaveClass(/location-field--mismatch/);
|
|
await expect(lngEl).not.toHaveClass(/location-field--mismatch/);
|
|
});
|
|
|
|
// ── U5: a flagged, unresolved lat/lng must block submit (code-review fix) ──
|
|
test('submitting with an unresolved lat/lng mismatch is blocked', async ({ page }) => {
|
|
const tag = `loc-mismatch-${Date.now()}`;
|
|
await page.goto('/post');
|
|
await page.fill('input[name="data[title]"]', `UI Test ${tag}`);
|
|
await fillEditor(page, 'Location-override mismatch-blocks-submit guard. Safe to delete.');
|
|
await page.locator('input.filepond--browser').setInputFiles(TEST_PHOTO);
|
|
await waitForPhotoUpload(page);
|
|
|
|
await openLocationDetails(page);
|
|
const latEl = page.locator('input[name="data[lat]"]');
|
|
const lngEl = page.locator('input[name="data[lng]"]');
|
|
await latEl.fill('999');
|
|
await lngEl.fill('999');
|
|
await lngEl.blur();
|
|
await expect(latEl).toHaveClass(/location-field--mismatch/);
|
|
|
|
// Register for cleanup BEFORE the click: if the gate ever regresses, the
|
|
// entry lands on disk and the afterAll hook must still see the tag.
|
|
created.push(tag);
|
|
await page.locator('.btn-post').evaluate((el) => el.click());
|
|
|
|
// `.notices` toHaveCount(0) and toHaveURL(/\/post/) both pass instantly and
|
|
// both also hold for a SUCCESSFUL submit (the form posts to /post and only
|
|
// renders its notice after the round trip), so neither can distinguish a
|
|
// working gate from a regressed one. Prove the negative on disk instead,
|
|
// after giving a regressed submit time to actually write.
|
|
await page.waitForTimeout(2000);
|
|
expect(findEntry(tag), 'a flagged coordinate must never reach the server').toBeFalsy();
|
|
// And prove the block was the gate's doing: still flagged, value untouched.
|
|
await expect(latEl).toHaveClass(/location-field--mismatch/);
|
|
await expect(latEl).toHaveValue('999');
|
|
});
|
|
|
|
// ── U4: lazy-load boundary — an ordinary GPS-only submit never fetches maplibre-gl ──
|
|
// The URL pattern deliberately covers BOTH halves of the lazy boundary: the JS
|
|
// chunk (js/post/maplibre-gl-*.js) and the stylesheet
|
|
// (css-compiled/maplibre-gl.css, <link>ed by location-map.js at panel-open —
|
|
// see its ensureMaplibreCss). Neither may be requested when the panel stays shut.
|
|
test('an ordinary submit without opening the panel never fetches the maplibre-gl chunk', async ({ page }) => {
|
|
const chunkRequests = [];
|
|
page.on('request', (req) => {
|
|
if (/maplibre-gl/.test(req.url())) chunkRequests.push(req.url());
|
|
});
|
|
|
|
const tag = `loc-nomap-${Date.now()}`;
|
|
await page.goto('/post');
|
|
await page.fill('input[name="data[title]"]', `UI Test ${tag}`);
|
|
await fillEditor(page, 'Location-override lazy-load guard. Safe to delete.');
|
|
await page.locator('input.filepond--browser').setInputFiles(TEST_PHOTO);
|
|
await waitForPhotoUpload(page);
|
|
await page.locator('.btn-post').evaluate((el) => el.click());
|
|
await expect(page.locator('.notices')).toContainText('Entry posted successfully!', { timeout: 15_000 });
|
|
created.push(tag);
|
|
|
|
expect(chunkRequests, 'neither the maplibre-gl chunk nor its stylesheet may be fetched when the panel is never opened').toHaveLength(0);
|
|
});
|
|
|
|
// ── The other half of that boundary: opening the panel DOES apply the vendor CSS ──
|
|
// Without this, the guard above could keep passing while the stylesheet silently
|
|
// stopped loading at all (a broken href, a missed build step), leaving the map
|
|
// unstyled with nothing to catch it. Asserts the <link> exists AND parsed —
|
|
// link.sheet is null until the browser has actually applied it.
|
|
test('opening the panel lazily links maplibre\'s stylesheet and applies it', async ({ page }) => {
|
|
await page.goto('/post');
|
|
|
|
const hrefBefore = await page.evaluate(() => Array.from(document.styleSheets)
|
|
.map((s) => s.href || '').filter((h) => /maplibre-gl\.css/.test(h)));
|
|
expect(hrefBefore, 'the vendor stylesheet must not be present before the panel opens').toHaveLength(0);
|
|
|
|
await openLocationDetails(page);
|
|
await expect(page.locator('#location-map canvas.maplibregl-canvas')).toHaveCount(1, { timeout: 10_000 });
|
|
|
|
await expect.poll(
|
|
() => page.evaluate(() => {
|
|
const link = Array.from(document.querySelectorAll('link[rel="stylesheet"]'))
|
|
.find((l) => /maplibre-gl\.css/.test(l.href));
|
|
return link ? link.sheet !== null : false;
|
|
}),
|
|
{ message: 'maplibre\'s stylesheet must be linked and applied once the panel opens', timeout: 10_000 }
|
|
).toBe(true);
|
|
});
|
|
|
|
// ── Full submit: a search-picked location round-trips into the frontmatter ──
|
|
test('a full submit with a search-picked location saves the expected lat/lng', async ({ page }) => {
|
|
const tag = `loc-submit-${Date.now()}`;
|
|
await page.goto('/post');
|
|
await mockGeocode(page, KYOTO_RESULTS);
|
|
await page.fill('input[name="data[title]"]', `UI Test ${tag}`);
|
|
await fillEditor(page, 'Location-override submit test. Safe to delete.');
|
|
await page.fill('input[name="data[location_city]"]', 'Kyoto');
|
|
await openLocationDetails(page);
|
|
await page.click('#lookup-coords');
|
|
await page.locator('.location-search-results li button').first().click();
|
|
|
|
await page.locator('input.filepond--browser').setInputFiles(TEST_PHOTO);
|
|
await waitForPhotoUpload(page);
|
|
await page.locator('.btn-post').evaluate((el) => el.click());
|
|
await expect(page.locator('.notices')).toContainText('Entry posted successfully!', { timeout: 15_000 });
|
|
created.push(tag);
|
|
|
|
const entryDir = findEntry(tag);
|
|
expect(entryDir, 'Entry folder should exist on disk').toBeTruthy();
|
|
const md = readEntryMd(entryDir);
|
|
expect(md).toContain('35.0116');
|
|
expect(md).toContain('135.7681');
|
|
});
|