Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/spec-reference.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,8 @@ An action step names a **target**: a sentence that Jev resolves to one element.

- A `goto` value that does not start with `http://` or `https://` is relative to the full `url`, not to the
site root. With `url: https://test.example/app`, `goto: /login` opens `https://test.example/app/login`.
The base path is the directory, and a base query or hash stays unless the target has its own.
An empty query or hash in the target clears that part. The user name and the password in the base url stay.
- `check` and `uncheck` read the state first and click only if it must change. If the click does not change
the state, the step is `error`. For a chip or button that shows no state, use `click`.
- `upload` paths are relative to the spec file, also in a [reusable flow](#reusable-flows).
Expand Down
9 changes: 9 additions & 0 deletions e2e/goto-base-query.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
name: goto keeps the base path when the url has a query
url: ${env.site}/app?from=e2e
env:
site: $PLAIN_E2E_SITE
steps:
- goto: /login
- expect:
- the App login heading is shown
- the page shows the query from=e2e
7 changes: 6 additions & 1 deletion e2e/site.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,9 @@ promo.querySelector('button').onclick = () => {
<p id="result" role="status"></p>
<div role="dialog" aria-modal="true" aria-label="Cookies" style="position:fixed;left:0;right:0;bottom:0;background:#eee;padding:12px">
<button>Accept cookies</button></div>`),
'/app': () => page('App', '<h1>App</h1>'),
'/app/login': () => page('App login', '<h1>App login</h1><p id="search"></p>',
`document.querySelector('#search').textContent = location.search;`),
'/login': () => LOGIN(),
'/login?error': () => LOGIN('<p role="alert">Your password is invalid!</p>'),
'/secure': () => page('Secure Area', `<h1>Secure Area</h1><p role="status">You logged into a secure area!</p>
Expand Down Expand Up @@ -270,7 +273,9 @@ async function respond(req, res) {
if (req.headers.authorization !== expected) return send(401, 'Not authorized', { 'www-authenticate': 'Basic realm="e2e"' });
return send(200, page('Basic Auth', '<h1>Basic Auth</h1><p>Congratulations! You must have the proper credentials.</p>'));
}
const html = PAGES[req.url];
// `/login?error` is its own page. Any other query uses the path.
const route = req.url.split('?')[0];
const html = PAGES[req.url] ?? PAGES[route];
return html ? send(200, html()) : send(404, page('Not found', '<h1>Not found</h1>'));
}

Expand Down
2 changes: 1 addition & 1 deletion scripts/gates.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ const root = join(dirname(fileURLToPath(import.meta.url)), '..');
const SELF = 'scripts/gates.test.mjs';

const FLOORS = {
'e2e specs (e2e/*.yaml)': 38,
'e2e specs (e2e/*.yaml)': 39,
'expect-fail e2e specs': 15,
'claim cases (scripts/claim-cases.json)': 233,
'claim cases that expect fail': 135,
Expand Down
26 changes: 25 additions & 1 deletion src/browser/steps.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { test, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { chromium, type Browser, type Page } from 'playwright';
import { runStep, runStepSafely } from './steps.js';
import { resolveUrl, runStep, runStepSafely } from './steps.js';
import { type StepContext } from './context.js';
import { parseStep } from '../core/spec.js';

Expand Down Expand Up @@ -90,6 +90,30 @@ test('a page with no candidates yet is looked at again for up to 2 s before "no
}
});

test('make it fail: goto /login from a base url with a query keeps the base path', () => {
assert.equal(resolveUrl('http://host/app', '/login'), 'http://host/app/login');
assert.equal(resolveUrl('http://host/app?x=1', '/login'), 'http://host/app/login?x=1');
assert.equal(resolveUrl('http://host/app#section', '/login'), 'http://host/app/login#section');
assert.equal(resolveUrl('http://host/app/?x=1', '/login'), 'http://host/app/login?x=1');
assert.equal(resolveUrl('http://host/app?x=1', '/login?y=2'), 'http://host/app/login?y=2');
assert.equal(resolveUrl('https://a.test', '/'), 'https://a.test/');
assert.equal(resolveUrl('http://host/app?x=1', 'https://other.test/abs'), 'https://other.test/abs');
assert.equal(resolveUrl('about:blank', 'data:text/html,hi'), 'data:text/html,hi');
});

test('make it fail: goto keeps the credentials in the base url', () => {
assert.equal(resolveUrl('https://user:pass@host/app', '/login'), 'https://user:pass@host/app/login');
assert.equal(resolveUrl('https://user:pass@host/app?x=1#section', '/login'), 'https://user:pass@host/app/login?x=1#section');
});

test('make it fail: goto /login? clears the base query', () => {
assert.equal(resolveUrl('https://host/app?x=1#section', '/login?'), 'https://host/app/login?#section');
});

test('make it fail: goto /login# clears the base hash', () => {
assert.equal(resolveUrl('https://host/app?x=1#section', '/login#'), 'https://host/app/login?x=1#');
});

test('an action error keeps the first line and the reason from Playwright\'s call log, not every retry', async () => {
await page.goto(html('<button>Save</button><div id="cover" style="position:fixed;inset:0"></div>'));
const result = await runStepSafely(ctx, parseStep('t', 0, { click: 'css=button' }));
Expand Down
30 changes: 25 additions & 5 deletions src/browser/steps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,12 +161,32 @@ async function withResolved(ctx: StepContext, kind: CandidateKind, target: strin
return { step: stepLabel, status: 'pass', detail: extra ? `${resolved.detail} ${extra}` : resolved.detail };
}

/** `url` is the app's base, not just its origin: `goto: /` goes back to the app under test, not to the site's root. */
function resolveUrl(base: string, target: string): string {
if (/^https?:\/\//.test(target)) return target;
const baseWithSlash = base.endsWith('/') ? base : `${base}/`;
/**
* `url` is the app's base, not just its origin: `goto: /login` stays under the base path.
* The base path is a directory. The user name and password stay.
* A base query or hash stays unless the target has its own, including an empty `?` or `#`.
* A target with its own scheme (`https://`, `data:`) is already absolute.
*/
export function resolveUrl(base: string, target: string): string {
if (/^[a-z][a-z0-9+.-]*:/i.test(target)) return target;
const parsed = new URL(base);
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
const baseWithSlash = base.endsWith('/') ? base : `${base}/`;
const relative = target.startsWith('/') ? target.slice(1) : target;
return new URL(relative, baseWithSlash).toString();
}
const directory = parsed.pathname.endsWith('/') ? parsed.pathname : `${parsed.pathname}/`;
const relative = target.startsWith('/') ? target.slice(1) : target;
return new URL(relative, baseWithSlash).toString();
// A copy keeps the user name and password. `origin` drops both.
const baseDir = new URL(parsed.href);
baseDir.pathname = directory;
baseDir.search = '';
baseDir.hash = '';
const resolved = new URL(relative, baseDir);
const beforeHash = target.split('#')[0];
if (!beforeHash.includes('?')) resolved.search = parsed.search;
if (!target.includes('#')) resolved.hash = parsed.hash;
return resolved.toString();
}

async function selectOption(element: Locator, value: string): Promise<void> {
Expand Down
Loading