From 2ca6a869b9182935f8bae910980bd95db1afbed7 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 26 Oct 2026 08:08:31 +0000 Subject: [PATCH 1/2] Resolve a relative goto against the base directory and keep its query and hash Co-authored-by: Gabe --- docs/spec-reference.mdx | 1 + e2e/goto-base-query.yaml | 9 +++++++++ e2e/site.mjs | 7 ++++++- scripts/gates.test.mjs | 2 +- src/browser/steps.test.ts | 13 ++++++++++++- src/browser/steps.ts | 23 ++++++++++++++++++----- 6 files changed, 47 insertions(+), 8 deletions(-) create mode 100644 e2e/goto-base-query.yaml diff --git a/docs/spec-reference.mdx b/docs/spec-reference.mdx index 52699f2f..67650036 100644 --- a/docs/spec-reference.mdx +++ b/docs/spec-reference.mdx @@ -157,6 +157,7 @@ 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. - `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 00000000..9338dc72 --- /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 c1b632d7..bb5b278d 100644 --- a/e2e/site.mjs +++ b/e2e/site.mjs @@ -164,6 +164,9 @@ promo.querySelector('button').onclick = () => {

`), + '/app': () => page('App', '

App

'), + '/app/login': () => page('App login', '

App login

', + `document.querySelector('#search').textContent = location.search;`), '/login': () => LOGIN(), '/login?error': () => LOGIN('

Your password is invalid!

'), '/secure': () => page('Secure Area', `

Secure Area

You logged into a secure area!

@@ -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', '

Basic Auth

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 ef19d9b0..7d8a2b10 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 8f04aa21..0e1f574f 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,17 @@ 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('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 45055c00..58886593 100644 --- a/src/browser/steps.ts +++ b/src/browser/steps.ts @@ -161,12 +161,25 @@ 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. A base query or hash stays unless the target has its own. + * 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(); + const resolved = new URL(relative, `${parsed.origin}${directory}`); + if (!resolved.search) resolved.search = parsed.search; + if (!resolved.hash) resolved.hash = parsed.hash; + return resolved.toString(); } async function selectOption(element: Locator, value: string): Promise { From 50412101d965c8b6fa3e94a72e0387443268e036 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 26 Oct 2026 09:01:09 +0000 Subject: [PATCH 2/2] Keep base credentials on a relative goto and honor an empty query or hash Co-authored-by: Gabe --- docs/spec-reference.mdx | 1 + src/browser/steps.test.ts | 13 +++++++++++++ src/browser/steps.ts | 15 +++++++++++---- 3 files changed, 25 insertions(+), 4 deletions(-) diff --git a/docs/spec-reference.mdx b/docs/spec-reference.mdx index 67650036..1cf85723 100644 --- a/docs/spec-reference.mdx +++ b/docs/spec-reference.mdx @@ -158,6 +158,7 @@ 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/src/browser/steps.test.ts b/src/browser/steps.test.ts index 0e1f574f..25e85dfc 100644 --- a/src/browser/steps.test.ts +++ b/src/browser/steps.test.ts @@ -101,6 +101,19 @@ test('make it fail: goto /login from a base url with a query keeps the base path 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 58886593..a0dceb76 100644 --- a/src/browser/steps.ts +++ b/src/browser/steps.ts @@ -163,7 +163,8 @@ async function withResolved(ctx: StepContext, kind: CandidateKind, target: strin /** * `url` is the app's base, not just its origin: `goto: /login` stays under the base path. - * The base path is a directory. A base query or hash stays unless the target has its own. + * 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 { @@ -176,9 +177,15 @@ export function resolveUrl(base: string, target: string): string { } const directory = parsed.pathname.endsWith('/') ? parsed.pathname : `${parsed.pathname}/`; const relative = target.startsWith('/') ? target.slice(1) : target; - const resolved = new URL(relative, `${parsed.origin}${directory}`); - if (!resolved.search) resolved.search = parsed.search; - if (!resolved.hash) resolved.hash = parsed.hash; + // 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(); }