diff --git a/docs/spec-reference.mdx b/docs/spec-reference.mdx
index 52699f2..1cf8572 100644
--- a/docs/spec-reference.mdx
+++ b/docs/spec-reference.mdx
@@ -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).
diff --git a/e2e/goto-base-query.yaml b/e2e/goto-base-query.yaml
new file mode 100644
index 0000000..9338dc7
--- /dev/null
+++ b/e2e/goto-base-query.yaml
@@ -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
diff --git a/e2e/site.mjs b/e2e/site.mjs
index c1b632d..bb5b278 100644
--- a/e2e/site.mjs
+++ b/e2e/site.mjs
@@ -164,6 +164,9 @@ promo.querySelector('button').onclick = () => {
Congratulations! You must have the proper credentials.
'));
}
- 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', '
Not found
'));
}
diff --git a/scripts/gates.test.mjs b/scripts/gates.test.mjs
index ef19d9b..7d8a2b1 100644
--- a/scripts/gates.test.mjs
+++ b/scripts/gates.test.mjs
@@ -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,
diff --git a/src/browser/steps.test.ts b/src/browser/steps.test.ts
index 8f04aa2..25e85df 100644
--- a/src/browser/steps.test.ts
+++ b/src/browser/steps.test.ts
@@ -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';
@@ -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(''));
const result = await runStepSafely(ctx, parseStep('t', 0, { click: 'css=button' }));
diff --git a/src/browser/steps.ts b/src/browser/steps.ts
index 45055c0..a0dceb7 100644
--- a/src/browser/steps.ts
+++ b/src/browser/steps.ts
@@ -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 {