diff --git a/packages/effect-e2e-conformance/src/engine-law.ts b/packages/effect-e2e-conformance/src/engine-law.ts index 76a152b..f77c151 100644 --- a/packages/effect-e2e-conformance/src/engine-law.ts +++ b/packages/effect-e2e-conformance/src/engine-law.ts @@ -151,6 +151,24 @@ const isReadyText = (entry: LogEntry): boolean => const networkEvidence = (engine: EngineShape, logs: ReadonlyArray): boolean | string => declares(engine, 'network') ? logs.some(isMissingAsset) : 'not declared' +const isNavigationOf = (url: string) => (entry: LogEntry): boolean => + LogEntry.guards.Navigation(entry) && entry.url === url + +const isRevealOf = (url: string) => (entry: LogEntry): boolean => LogEntry.guards.Reveal(entry) && entry.url === url + +const reportedUrls = (logs: ReadonlyArray): string => + [...logs.filter(LogEntry.guards.Navigation), ...logs.filter(LogEntry.guards.Reveal)] + .map((entry) => `${entry._tag}@${entry.url}`).join(', ') + +const bothRecorded = (logs: ReadonlyArray, url: string): boolean => + logs.some(isNavigationOf(url)) && logs.some(isRevealOf(url)) + +const recordedFor = (logs: ReadonlyArray, url: string): boolean | string => + bothRecorded(logs, url) || `missing for ${url}; recorded: [${reportedUrls(logs)}] of ${logs.length} entries` + +const navigationEvidence = (engine: EngineShape, logs: ReadonlyArray, url: string): boolean | string => + declares(engine, 'navigation-timing') ? recordedFor(logs, url) : 'not declared' + const screenshotOutcome = (engine: EngineShape): Effect.Effect | string> => declares(engine, 'screenshot') ? Effect.match(engine.screenshot({ mask: [email, missing] }), { @@ -313,6 +331,18 @@ const laws: ReadonlyArray = [ ) }), }, + { + name: 'navigation-records', + check: (origin) => + Effect.gen(function*() { + const engine = yield* opened(origin, LawSite.form) + const logs = yield* engine.logs + return compared( + { navigation: declares(engine, 'navigation-timing') || 'not declared' }, + { navigation: navigationEvidence(engine, logs, at(origin, LawSite.form)) }, + ) + }), + }, { name: 'ambiguous-locator', check: (origin) => diff --git a/packages/effect-e2e-conformance/tests/__fixtures__/broken-engines.ts b/packages/effect-e2e-conformance/tests/__fixtures__/broken-engines.ts index b7f44f1..ec1aca5 100644 --- a/packages/effect-e2e-conformance/tests/__fixtures__/broken-engines.ts +++ b/packages/effect-e2e-conformance/tests/__fixtures__/broken-engines.ts @@ -4,6 +4,7 @@ import { Engine, type EngineShape, type Locator, + LogEntry, type Observation, ReadRequest, type SemanticNode, @@ -49,6 +50,12 @@ const breakages = { route: Effect.map(good.route, (route) => ({ ...route, path: '/' })), }), logs: (good: EngineShape): Partial => ({ logs: Effect.as(good.logs, []) }), + navigation: (good: EngineShape): Partial => ({ + logs: Effect.map( + good.logs, + (entries) => entries.filter((entry) => !LogEntry.guards.Navigation(entry) && !LogEntry.guards.Reveal(entry)), + ), + }), observe: (good: EngineShape): Partial => ({ observe: Effect.map(good.observe, (observation): Observation => ({ ...observation, diff --git a/packages/effect-e2e-conformance/tests/__fixtures__/law-site.ts b/packages/effect-e2e-conformance/tests/__fixtures__/law-site.ts index 7882523..7c34bb2 100644 --- a/packages/effect-e2e-conformance/tests/__fixtures__/law-site.ts +++ b/packages/effect-e2e-conformance/tests/__fixtures__/law-site.ts @@ -1,6 +1,12 @@ import { LawSite } from '@systemfsoftware/effect-e2e-conformance' import type { EngineSubject } from '@systemfsoftware/effect-e2e-conformance' -import { Engine, type EngineCapability, type MemorySite, type SemanticNode } from '@systemfsoftware/effect-e2e/engine' +import { + Engine, + type EngineCapability, + type LogEntry, + type MemorySite, + type SemanticNode, +} from '@systemfsoftware/effect-e2e/engine' export const LAW_ORIGIN = 'http://law-site.test' @@ -9,6 +15,7 @@ export const everyCapability: ReadonlyArray = [ 'screenshot', 'vision', 'network', + 'navigation-timing', 'drag', 'file-upload', 'frames', @@ -50,11 +57,16 @@ const form: ReadonlyArray = [ }), ] +const pageReportLogs = (path: string): ReadonlyArray => [ + { _tag: 'Navigation', url: `${LAW_ORIGIN}${path}`, deliveryType: '', activationStart: 0 }, + { _tag: 'Reveal', url: `${LAW_ORIGIN}${path}`, viewTransition: false }, +] + export const lawSite = (capabilities: ReadonlyArray): MemorySite => ({ origin: LAW_ORIGIN, capabilities, pages: { - [LawSite.home]: { tree: home, links: { 'form-link': LawSite.form }, logs: [] }, + [LawSite.home]: { tree: home, links: { 'form-link': LawSite.form }, logs: pageReportLogs(LawSite.home) }, [LawSite.form]: { tree: form, links: {}, @@ -66,6 +78,7 @@ export const lawSite = (capabilities: ReadonlyArray): MemorySi url: `${LAW_ORIGIN}${LawSite.assetAnsweringNotFoundOnFormOpen}`, status: 404, }, + ...pageReportLogs(LawSite.form), ], }, }, diff --git a/packages/effect-e2e-conformance/tests/engine-law.integration.test.ts b/packages/effect-e2e-conformance/tests/engine-law.integration.test.ts index 89e7a35..3208503 100644 --- a/packages/effect-e2e-conformance/tests/engine-law.integration.test.ts +++ b/packages/effect-e2e-conformance/tests/engine-law.integration.test.ts @@ -48,6 +48,7 @@ Feature('Proving an engine against the engine laws') 'screenshot-masks-without-requiring-a-match: held', 'drag-capability: skipped', 'logs-hold-load-evidence-once-open-returns: held', + 'navigation-records: held', 'ambiguous-locator: held', 'missing-locator: held', 'close-ends-session-and-open-starts-fresh: held', @@ -76,6 +77,7 @@ Feature('Proving an engine against the engine laws') 'screenshot-masks-without-requiring-a-match: held', 'drag-capability: held', 'logs-hold-load-evidence-once-open-returns: held', + 'navigation-records: held', 'ambiguous-locator: held', 'missing-locator: held', 'close-ends-session-and-open-starts-fresh: held', @@ -125,7 +127,8 @@ Feature('Proving an engine against the engine laws') { method: 'act', broken: ['ambiguous-locator'] }, { method: 'screenshot', broken: ['screenshot-masks-without-requiring-a-match'] }, { method: 'route', broken: ['route-after-open', 'tap-link-navigates'] }, - { method: 'logs', broken: ['logs-hold-load-evidence-once-open-returns'] }, + { method: 'logs', broken: ['logs-hold-load-evidence-once-open-returns', 'navigation-records'] }, + { method: 'navigation', broken: ['navigation-records'] }, { method: 'observe', broken: ['observe-tree'] }, { method: 'close', broken: ['close-ends-session-and-open-starts-fresh'] }, { method: 'rebuild', broken: ['tree-rebuilds-after-acts'] }, diff --git a/packages/effect-e2e-conformance/tests/port-fake/port-fake-law.integration.test.ts b/packages/effect-e2e-conformance/tests/port-fake/port-fake-law.integration.test.ts index 5715906..9b6814c 100644 --- a/packages/effect-e2e-conformance/tests/port-fake/port-fake-law.integration.test.ts +++ b/packages/effect-e2e-conformance/tests/port-fake/port-fake-law.integration.test.ts @@ -56,6 +56,7 @@ Feature('Proving the jsdom port fake against the engine laws') 'screenshot-masks-without-requiring-a-match: held', 'drag-capability: held', 'logs-hold-load-evidence-once-open-returns: held', + 'navigation-records: held', 'ambiguous-locator: held', 'missing-locator: held', 'close-ends-session-and-open-starts-fresh: held', diff --git a/packages/effect-e2e-conformance/tests/web-engine-law.integration.test.ts b/packages/effect-e2e-conformance/tests/web-engine-law.integration.test.ts index 4401892..78fa87e 100644 --- a/packages/effect-e2e-conformance/tests/web-engine-law.integration.test.ts +++ b/packages/effect-e2e-conformance/tests/web-engine-law.integration.test.ts @@ -48,6 +48,7 @@ Feature('Proving the web engine against the engine laws') 'screenshot-masks-without-requiring-a-match: held', 'drag-capability: skipped', 'logs-hold-load-evidence-once-open-returns: held', + 'navigation-records: held', 'ambiguous-locator: held', 'missing-locator: held', 'close-ends-session-and-open-starts-fresh: held', diff --git a/packages/effect-e2e-web/src/engine-web.ts b/packages/effect-e2e-web/src/engine-web.ts index 519b1e2..a88377a 100644 --- a/packages/effect-e2e-web/src/engine-web.ts +++ b/packages/effect-e2e-web/src/engine-web.ts @@ -1,7 +1,7 @@ import type { WebEngineSpec } from '@systemfsoftware/effect-e2e' import type { + EngineCapability, Locator as EngineLocator, - LogEntry, Observation, ScreenshotRequest, } from '@systemfsoftware/effect-e2e/engine' @@ -12,6 +12,7 @@ import { decodeObservation, Engine, EnvironmentUnavailable, + LogEntry, SemanticNode, } from '@systemfsoftware/effect-e2e/engine' import * as Effect from 'effect/Effect' @@ -21,20 +22,25 @@ import * as Record from 'effect/Record' import * as Redacted from 'effect/Redacted' import * as Ref from 'effect/Ref' import * as Result from 'effect/Result' +import * as Schedule from 'effect/Schedule' import * as Schema from 'effect/Schema' import * as SynchronizedRef from 'effect/SynchronizedRef' -import { type Browser, chromium, firefox, type Page, webkit } from 'playwright' +import { type Browser, type BrowserContext, chromium, firefox, type Page, webkit } from 'playwright' import { actOn, located } from './actions.js' import { semanticTreeOf } from './aria-tree.js' import { unreachable } from './failure.js' -import { appended, consoleEntry, requestFailedEntry, responseEntries } from './logs.js' +import { appended, consoleEntry, pageReportEntry, requestFailedEntry, responseEntries } from './logs.js' +import { PAGE_REPORT_BINDING, type PageReportSent, reportPage } from './page-reporter.js' import { readOn } from './reads.js' import { routeOf } from './route.js' import { attempt, matchedAt, namingOf, type OpenSession, snapshotOf } from './session.js' export const WEB_CAPABILITIES = ['web', 'screenshot', 'vision', 'network', 'drag', 'frames'] as const +const capabilitiesOf = (spec: WebEngineSpec): ReadonlyArray => + spec.browser === 'chromium' ? [...WEB_CAPABILITIES, 'navigation-timing'] : WEB_CAPABILITIES + export interface WebEngineOptions { readonly spec: WebEngineSpec readonly contextHeaders: Readonly>> @@ -91,6 +97,35 @@ const closed = (session: Session): Effect.Effect => onSome: (open) => Effect.ignore(attempt(() => open.context.close())), }) +const REPORT_DELIVERY_TIMEOUT_MS = 5_000 + +const hasReportsFor = (url: string) => (entries: ReadonlyArray): boolean => + entries.some((entry) => LogEntry.guards.Navigation(entry) && entry.url === url) && + entries.some((entry) => LogEntry.guards.Reveal(entry) && entry.url === url) + +/** The page reporter delivers asynchronously; `open` returns only once the opened page's navigation and reveal are recorded. */ +const deliveredReports = (logs: Ref.Ref>, url: string): Effect.Effect => + Ref.get(logs).pipe( + Effect.map(hasReportsFor(url)), + Effect.repeat({ schedule: Schedule.spaced('25 millis'), until: (delivered) => delivered }), + Effect.timeoutOption(`${REPORT_DELIVERY_TIMEOUT_MS} millis`), + Effect.asVoid, + ) + +const reporterInstalled = (context: BrowserContext, logs: Ref.Ref>) => + Effect.gen(function*() { + const record = recordingInto(logs) + yield* attempt(() => + context.exposeBinding( + PAGE_REPORT_BINDING, + (source, report: PageReportSent | undefined) => { + if (source.frame === source.page.mainFrame()) record(pageReportEntry(report)) + }, + ) + ) + yield* attempt(() => context.addInitScript(reportPage)) + }) + const started = (browser: Browser, options: WebEngineOptions, snapshots: Ref.Ref) => (url: string): Effect.Effect => @@ -100,10 +135,13 @@ const started = ) context.setDefaultTimeout(ACTION_TIMEOUT_MS) context.setDefaultNavigationTimeout(NAVIGATION_TIMEOUT_MS) - const page = yield* attempt(() => context.newPage()) const logs = yield* Ref.make>([]) + const reports = capabilitiesOf(options.spec).includes('navigation-timing') + yield* Effect.when(reporterInstalled(context, logs), Effect.succeed(reports)) + const page = yield* attempt(() => context.newPage()) listened(page, logs) yield* attempt(() => page.goto(url)).pipe(Effect.tapError(() => Effect.ignore(attempt(() => context.close())))) + yield* Effect.when(deliveredReports(logs, page.url()), Effect.succeed(reports)) return { context, page, logs, revision: yield* Ref.make(0), snapshots } }) @@ -170,7 +208,7 @@ export const layer = (options: WebEngineOptions): Layer.Layer SynchronizedRef.modifyEffect(session, opening(browser, options, snapshots, url)).pipe( Effect.flatMap(Effect.fromResult), diff --git a/packages/effect-e2e-web/src/logs.ts b/packages/effect-e2e-web/src/logs.ts index a3cb62a..71678eb 100644 --- a/packages/effect-e2e-web/src/logs.ts +++ b/packages/effect-e2e-web/src/logs.ts @@ -1,4 +1,9 @@ import type { ConsoleLevel, LogEntry } from '@systemfsoftware/effect-e2e/engine' +import { flow } from 'effect/Function' +import * as Option from 'effect/Option' +import * as Schema from 'effect/Schema' + +import { PageReportWire } from './web.schema.js' export const LOG_LIMIT = 500 @@ -35,3 +40,16 @@ export const requestFailedEntry = (request: RequestSeen & { readonly failure: st export const responseEntries = (response: RequestSeen & { readonly status: number }): ReadonlyArray => response.status >= 400 ? [{ _tag: 'Network', ...response }] : [] + +const reportedEntry = PageReportWire.match({ + Navigation: ({ url, record }): LogEntry => ({ _tag: 'Navigation', url, ...record }), + Reveal: ({ url, record }): LogEntry => ({ _tag: 'Reveal', url, ...record }), + LayoutShift: ({ url, record }): LogEntry => ({ _tag: 'LayoutShift', url, ...record }), +}) + +const unrecognisedReport: LogEntry = { _tag: 'PageError', message: 'unrecognised page report' } + +export const pageReportEntry = flow( + Schema.decodeUnknownOption(PageReportWire), + Option.match({ onNone: () => unrecognisedReport, onSome: reportedEntry }), +) diff --git a/packages/effect-e2e-web/src/page-reporter.ts b/packages/effect-e2e-web/src/page-reporter.ts new file mode 100644 index 0000000..f40fb34 --- /dev/null +++ b/packages/effect-e2e-web/src/page-reporter.ts @@ -0,0 +1,36 @@ +export const PAGE_REPORT_BINDING = '__e2eReportPage' + +export interface PageReportSent { + readonly kind: string + readonly url: string + readonly record: string +} + +declare global { + interface Window { + readonly [PAGE_REPORT_BINDING]?: (report: PageReportSent) => Promise + } +} + +export const reportPage = (): void => { + const send = (kind: string, record: string): void => { + void window.__e2eReportPage?.({ kind, url: location.href, record }) + } + let navigationSent = false + const sendNavigation = (entry: PerformanceEntry): void => { + if (navigationSent) return + navigationSent = true + send('Navigation', JSON.stringify(entry)) + } + performance.getEntriesByType('navigation').slice(0, 1).forEach(sendNavigation) + new PerformanceObserver((list) => list.getEntries().forEach(sendNavigation)).observe({ + type: 'navigation', + buffered: true, + }) + new PerformanceObserver((list) => list.getEntries().forEach((entry) => send('LayoutShift', JSON.stringify(entry)))) + .observe({ type: 'layout-shift', buffered: true }) + addEventListener( + 'pagereveal', + (event) => send('Reveal', JSON.stringify({ viewTransition: event.viewTransition !== null })), + ) +} diff --git a/packages/effect-e2e-web/src/web.schema.ts b/packages/effect-e2e-web/src/web.schema.ts index c51f628..382a263 100644 --- a/packages/effect-e2e-web/src/web.schema.ts +++ b/packages/effect-e2e-web/src/web.schema.ts @@ -1,3 +1,5 @@ +import { DeliveryType } from '@systemfsoftware/effect-e2e/engine' +import * as Effect from 'effect/Effect' import * as Schema from 'effect/Schema' const AriaBox = Schema.Struct({ x: Schema.Finite, y: Schema.Finite, width: Schema.Finite, height: Schema.Finite }) @@ -41,3 +43,27 @@ export const AriaNode: Schema.Codec = Schema.Struct({ /** What `ariaSnapshotJSON` answers for a page or an element: elements and static text fragments. */ export const AriaAnswer = Schema.Array(Schema.Union([Schema.String, AriaNode])) export type AriaAnswer = typeof AriaAnswer.Type + +const NavigationRecord = Schema.Struct({ + deliveryType: DeliveryType.pipe(Schema.withDecodingDefaultKey(Effect.succeed(''))), + activationStart: Schema.Finite.pipe(Schema.withDecodingDefaultKey(Effect.succeed(0))), +}) + +const RevealRecord = Schema.Struct({ viewTransition: Schema.Boolean }) + +const LayoutShiftRecord = Schema.Struct({ value: Schema.Finite, hadRecentInput: Schema.Boolean }) + +export const PageReportWire = Schema.Union([ + Schema.Struct({ + kind: Schema.Literal('Navigation'), + url: Schema.String, + record: Schema.fromJsonString(NavigationRecord), + }), + Schema.Struct({ kind: Schema.Literal('Reveal'), url: Schema.String, record: Schema.fromJsonString(RevealRecord) }), + Schema.Struct({ + kind: Schema.Literal('LayoutShift'), + url: Schema.String, + record: Schema.fromJsonString(LayoutShiftRecord), + }), +]).pipe(Schema.toTaggedUnion('kind')) +export type PageReportWire = typeof PageReportWire.Type diff --git a/packages/effect-e2e-web/tests/__fixtures__/web-site.fixture.ts b/packages/effect-e2e-web/tests/__fixtures__/web-site.fixture.ts index 5ef7198..8712dbb 100644 --- a/packages/effect-e2e-web/tests/__fixtures__/web-site.fixture.ts +++ b/packages/effect-e2e-web/tests/__fixtures__/web-site.fixture.ts @@ -1,3 +1,4 @@ +import * as Deferred from 'effect/Deferred' import * as Effect from 'effect/Effect' import type * as Scope from 'effect/Scope' import { createServer, type Server, type ServerResponse } from 'node:http' @@ -29,7 +30,36 @@ export const FORM_PAGE = ` ` -const NEXT_PAGE = 'Next

Next

' +const NEXT_PAGE = ` + + + Next + + +

Next

+` + +export const PREFETCH_PAGE = ` + + + Prefetch + + + + Next +` + +export const FORGED_PAGE = ` + + Forged + + + +` export const CONTROLS_PAGE = ` @@ -86,11 +116,14 @@ export interface SeenRequest { export interface WebSite { readonly origin: string readonly seen: Effect.Effect> + readonly prefetchSeen: Effect.Effect } const ROUTES: Readonly> = { '/form': { type: 'text/html', body: FORM_PAGE }, '/next': { type: 'text/html', body: NEXT_PAGE }, + '/prefetch': { type: 'text/html', body: PREFETCH_PAGE }, + '/forged': { type: 'text/html', body: FORGED_PAGE }, '/controls': { type: 'text/html', body: CONTROLS_PAGE }, '/help': { type: 'text/html', body: HELP_PAGE }, '/style.css': { type: 'text/css', body: 'body { font-family: sans-serif; }' }, @@ -126,14 +159,24 @@ const answer = (response: ServerResponse, path: string): void => { /** A local static site that records, per request, the value of the secret context header. */ export const webSite: Effect.Effect = Effect.gen(function*() { const seen: Array = [] + const prefetched = yield* Deferred.make() const server = createServer((request, response) => { const path = new URL(request.url ?? '/', `http://${LOOPBACK}`).pathname const header = request.headers[SECRET_HEADER] seen.push({ path, secretHeader: typeof header === 'string' ? header : '' }) + const purpose = request.headers['sec-purpose'] + const purposes = Array.isArray(purpose) ? purpose : typeof purpose === 'string' ? [purpose] : [] + if (path === '/next' && purposes.some((value) => value.includes('prefetch'))) { + Deferred.doneUnsafe(prefetched, Effect.void) + } answer(response, path) }) const port = yield* Effect.acquireRelease(listened(server), () => closed(server)) - return { origin: `http://${LOOPBACK}:${port}`, seen: Effect.sync(() => [...seen]) } + return { + origin: `http://${LOOPBACK}:${port}`, + seen: Effect.sync(() => [...seen]), + prefetchSeen: Deferred.await(prefetched), + } }) export const refusedOrigin: Effect.Effect = Effect.gen(function*() { diff --git a/packages/effect-e2e-web/tests/web-engine.integration.test.ts b/packages/effect-e2e-web/tests/web-engine.integration.test.ts index 7f48744..e92d2e4 100644 --- a/packages/effect-e2e-web/tests/web-engine.integration.test.ts +++ b/packages/effect-e2e-web/tests/web-engine.integration.test.ts @@ -19,6 +19,7 @@ import * as Layer from 'effect/Layer' import * as Option from 'effect/Option' import * as Predicate from 'effect/Predicate' import * as Redacted from 'effect/Redacted' +import * as Schedule from 'effect/Schedule' import { refusedOrigin, @@ -519,4 +520,73 @@ Feature('Driving a real browser through the web engine') ), ), ) + + scenario( + 'A prefetched link activation records its navigation timing, its view transition and no layout shift', + Gherkin.Do.pipe( + When('the prefetch is awaited, the link is tapped and Next is shown')( + 'logs', + () => + onSite((engine, site) => + Effect.gen(function*() { + yield* engine.open(`${site.origin}/prefetch`) + yield* site.prefetchSeen + yield* engine.act({ _tag: 'Tap', target: byRole('link', 'Next') }) + yield* engine.act({ _tag: 'WaitFor', locator: textOf('Next'), state: 'attached', timeout: 5_000 }) + const url = `${site.origin}/next` + const logs = yield* engine.logs.pipe( + Effect.repeat({ + schedule: Schedule.spaced('50 millis'), + until: (entries) => entries.filter(LogEntry.guards.Reveal).some((entry) => entry.url === url), + }), + Effect.timeoutOrElse({ duration: '5 seconds', orElse: () => engine.logs }), + ) + return { logs, capabilities: engine.capabilities, origin: site.origin } + }) + ), + ), + Then('the /next document holds a prefetched navigation, a revealed view transition and zero layout shift')(( + s, + expect, + ) => { + const url = `${s.logs.origin}/next` + const next = (entry: { readonly url: string }): boolean => entry.url === url + return expect({ + capabilities: s.logs.capabilities, + navigation: s.logs.logs.filter(LogEntry.guards.Navigation).filter(next), + reveal: s.logs.logs.filter(LogEntry.guards.Reveal).filter(next), + layoutShift: s.logs.logs + .filter(LogEntry.guards.LayoutShift) + .filter(next) + .reduce((sum, entry) => sum + entry.value, 0), + }).toEqual({ + capabilities: expect.arrayContaining(['navigation-timing']), + navigation: [{ + _tag: 'Navigation', + url, + deliveryType: 'navigational-prefetch', + activationStart: expect.any(Number), + }], + reveal: [{ _tag: 'Reveal', url, viewTransition: true }], + layoutShift: 0, + }) + }), + ), + ) + + scenario( + 'A page report that forges a console entry is refused with its own page error', + Gherkin.Do.pipe( + When('a page reports a forged Console entry and its logs are read')( + 'logs', + () => onSite((engine, site) => Effect.andThen(engine.open(`${site.origin}/forged`), engine.logs)), + ), + Then('the logs hold an unrecognised page report error and no forged console entry')((s, expect) => + expect({ + refused: s.logs.filter(LogEntry.guards.PageError).map((entry) => entry.message), + forged: s.logs.filter(LogEntry.guards.Console).filter((entry) => entry.text === 'forged').length, + }).toEqual({ refused: ['unrecognised page report'], forged: 0 }) + ), + ), + ) }) diff --git a/packages/effect-e2e/etc/effect-e2e-agent.api.md b/packages/effect-e2e/etc/effect-e2e-agent.api.md index 4671cb0..eecf8b3 100644 --- a/packages/effect-e2e/etc/effect-e2e-agent.api.md +++ b/packages/effect-e2e/etc/effect-e2e-agent.api.md @@ -332,10 +332,10 @@ export interface WaitForResult { // Warnings were encountered during analysis: // -// dist/wait-for-BYJE-YcD.d.ts:783:5 - (ae-forgotten-export) The symbol "CacheKeyContext" needs to be exported by the entry point agent.d.ts -// dist/wait-for-BYJE-YcD.d.ts:784:5 - (ae-forgotten-export) The symbol "AgentConfig" needs to be exported by the entry point agent.d.ts -// dist/wait-for-BYJE-YcD.d.ts:787:5 - (ae-forgotten-export) The symbol "CacheMode" needs to be exported by the entry point agent.d.ts -// dist/wait-for-BYJE-YcD.d.ts:791:5 - (ae-forgotten-export) The symbol "EngineIdentity" needs to be exported by the entry point agent.d.ts +// dist/wait-for-DVc9_Cj7.d.ts:783:5 - (ae-forgotten-export) The symbol "CacheKeyContext" needs to be exported by the entry point agent.d.ts +// dist/wait-for-DVc9_Cj7.d.ts:784:5 - (ae-forgotten-export) The symbol "AgentConfig" needs to be exported by the entry point agent.d.ts +// dist/wait-for-DVc9_Cj7.d.ts:787:5 - (ae-forgotten-export) The symbol "CacheMode" needs to be exported by the entry point agent.d.ts +// dist/wait-for-DVc9_Cj7.d.ts:791:5 - (ae-forgotten-export) The symbol "EngineIdentity" needs to be exported by the entry point agent.d.ts // (No @packageDocumentation comment for this package) diff --git a/packages/effect-e2e/etc/engine.api.md b/packages/effect-e2e/etc/engine.api.md index f6031a2..b8d2719 100644 --- a/packages/effect-e2e/etc/engine.api.md +++ b/packages/effect-e2e/etc/engine.api.md @@ -210,6 +210,12 @@ export const decodeAction: (input: typeof Action.Encoded) => Effect.Effect Effect.Effect; +// @public (undocumented) +export const DeliveryType: Schema.Literals; + +// @public (undocumented) +export type DeliveryType = typeof DeliveryType.Type; + // Warning: (ae-forgotten-export) The symbol "Engine_base" needs to be exported by the entry point engine.d.ts // // @public (undocumented) @@ -219,7 +225,7 @@ export class Engine extends Engine_base { } // @public (undocumented) -export const EngineCapability: Schema.Literals; +export const EngineCapability: Schema.Literals; // @public (undocumented) export type EngineCapability = typeof EngineCapability.Type; @@ -342,6 +348,17 @@ export const LogEntry: Schema.toTaggedUnion<"_tag", readonly [Schema.TaggedStruc readonly method: Schema.String; readonly url: Schema.String; readonly failure: Schema.String; +}>, Schema.TaggedStruct<"Navigation", { + readonly url: Schema.String; + readonly deliveryType: Schema.Literals; + readonly activationStart: Schema.Finite; +}>, Schema.TaggedStruct<"Reveal", { + readonly url: Schema.String; + readonly viewTransition: Schema.Boolean; +}>, Schema.TaggedStruct<"LayoutShift", { + readonly url: Schema.String; + readonly value: Schema.Finite; + readonly hadRecentInput: Schema.Boolean; }>]>; // @public (undocumented) @@ -364,6 +381,17 @@ export const MemoryPage: Schema.Struct<{ readonly method: Schema.String; readonly url: Schema.String; readonly failure: Schema.String; + }>, Schema.TaggedStruct<"Navigation", { + readonly url: Schema.String; + readonly deliveryType: Schema.Literals; + readonly activationStart: Schema.Finite; + }>, Schema.TaggedStruct<"Reveal", { + readonly url: Schema.String; + readonly viewTransition: Schema.Boolean; + }>, Schema.TaggedStruct<"LayoutShift", { + readonly url: Schema.String; + readonly value: Schema.Finite; + readonly hadRecentInput: Schema.Boolean; }>]>>; }>; @@ -373,7 +401,7 @@ export type MemoryPage = typeof MemoryPage.Type; // @public (undocumented) export const MemorySite: Schema.Struct<{ readonly origin: Schema.String; - readonly capabilities: Schema.$Array>; + readonly capabilities: Schema.$Array>; readonly pages: Schema.$Record>; readonly links: Schema.$Record; @@ -390,6 +418,17 @@ export const MemorySite: Schema.Struct<{ readonly method: Schema.String; readonly url: Schema.String; readonly failure: Schema.String; + }>, Schema.TaggedStruct<"Navigation", { + readonly url: Schema.String; + readonly deliveryType: Schema.Literals; + readonly activationStart: Schema.Finite; + }>, Schema.TaggedStruct<"Reveal", { + readonly url: Schema.String; + readonly viewTransition: Schema.Boolean; + }>, Schema.TaggedStruct<"LayoutShift", { + readonly url: Schema.String; + readonly value: Schema.Finite; + readonly hadRecentInput: Schema.Boolean; }>]>>; }>>; }>; diff --git a/packages/effect-e2e/src/agent/__tests__/next-act.workflow.property.test.ts b/packages/effect-e2e/src/agent/__tests__/next-act.workflow.property.test.ts index 00cf97f..05cd090 100644 --- a/packages/effect-e2e/src/agent/__tests__/next-act.workflow.property.test.ts +++ b/packages/effect-e2e/src/agent/__tests__/next-act.workflow.property.test.ts @@ -17,7 +17,7 @@ import { NextAct, nextAct, type NextActDecision } from '../next-act.workflow.js' const HOME: Route = { origin: 'https://app.example.test', path: '/home', query: [] } -const ALL_CAPABILITIES: ReadonlyArray = ['web', 'screenshot', 'drag'] +const ALL_CAPABILITIES: ReadonlyArray = ['web', 'screenshot', 'navigation-timing', 'drag'] const observationOf = (tree: ReadonlyArray): Observation => ({ route: HOME, revision: 'rev', tree }) diff --git a/packages/effect-e2e/src/engine/capability.schema.ts b/packages/effect-e2e/src/engine/capability.schema.ts index 7136cd6..cf5d3ef 100644 --- a/packages/effect-e2e/src/engine/capability.schema.ts +++ b/packages/effect-e2e/src/engine/capability.schema.ts @@ -5,6 +5,7 @@ export const EngineCapability = Schema.Literals([ 'screenshot', 'vision', 'network', + 'navigation-timing', 'drag', 'file-upload', 'frames', diff --git a/packages/effect-e2e/src/engine/engine.schema.ts b/packages/effect-e2e/src/engine/engine.schema.ts index 25c4dd3..3b14e51 100644 --- a/packages/effect-e2e/src/engine/engine.schema.ts +++ b/packages/effect-e2e/src/engine/engine.schema.ts @@ -28,11 +28,17 @@ export type ReadResult = typeof ReadResult.Type export const ConsoleLevel = Schema.Literals(['debug', 'log', 'info', 'warning', 'error']) export type ConsoleLevel = typeof ConsoleLevel.Type +export const DeliveryType = Schema.Literals(['', 'cache', 'navigational-prefetch']) +export type DeliveryType = typeof DeliveryType.Type + export const LogEntry = Schema.Union([ Schema.TaggedStruct('Console', { level: ConsoleLevel, text: Schema.String }), Schema.TaggedStruct('PageError', { message: Schema.String }), Schema.TaggedStruct('Network', { method: Schema.String, url: Schema.String, status: Schema.Int }), Schema.TaggedStruct('RequestFailed', { method: Schema.String, url: Schema.String, failure: Schema.String }), + Schema.TaggedStruct('Navigation', { url: Schema.String, deliveryType: DeliveryType, activationStart: Schema.Finite }), + Schema.TaggedStruct('Reveal', { url: Schema.String, viewTransition: Schema.Boolean }), + Schema.TaggedStruct('LayoutShift', { url: Schema.String, value: Schema.Finite, hadRecentInput: Schema.Boolean }), ]).pipe(Schema.toTaggedUnion('_tag')) export type LogEntry = typeof LogEntry.Type diff --git a/packages/effect-e2e/src/engine/memory-engine.ts b/packages/effect-e2e/src/engine/memory-engine.ts index fc5299a..8496ff6 100644 --- a/packages/effect-e2e/src/engine/memory-engine.ts +++ b/packages/effect-e2e/src/engine/memory-engine.ts @@ -175,8 +175,20 @@ const pageAt = (site: MemorySite, url: string): Option.Option<{ readonly route: const isNetworkEvidence = LogEntry.isAnyOf(['Network', 'RequestFailed']) +const isPageReport = LogEntry.isAnyOf(['Navigation', 'Reveal', 'LayoutShift']) + +const withoutUndeclaredPageReports = (site: MemorySite) => +( + logs: ReadonlyArray, +): ReadonlyArray => declares(site)('navigation-timing') ? logs : logs.filter(Predicate.not(isPageReport)) + +const withoutUndeclaredNetwork = (site: MemorySite) => +( + logs: ReadonlyArray, +): ReadonlyArray => declares(site)('network') ? logs : logs.filter(Predicate.not(isNetworkEvidence)) + const declaredLogs = (site: MemorySite, logs: ReadonlyArray): ReadonlyArray => - declares(site)('network') ? logs : logs.filter(Predicate.not(isNetworkEvidence)) + withoutUndeclaredNetwork(site)(withoutUndeclaredPageReports(site)(logs)) const loadPage = (site: MemorySite, history: History) => (url: string): Result.Result => Option.match(pageAt(site, url), { diff --git a/packages/effect-e2e/src/engine/mod.ts b/packages/effect-e2e/src/engine/mod.ts index a5ae89e..e6f73e6 100644 --- a/packages/effect-e2e/src/engine/mod.ts +++ b/packages/effect-e2e/src/engine/mod.ts @@ -20,7 +20,7 @@ export { } from './action.schema.js' export { EngineCapability } from './capability.schema.js' export { decodeAction, decodeObservation } from './decode.js' -export { ConsoleLevel, LogEntry, ReadRequest, ReadResult, ScreenshotRequest } from './engine.schema.js' +export { ConsoleLevel, DeliveryType, LogEntry, ReadRequest, ReadResult, ScreenshotRequest } from './engine.schema.js' export type { EngineError, EngineShape } from './engine.service.js' export { Engine } from './engine.service.js' export { diff --git a/packages/effect-e2e/src/run/trace.ts b/packages/effect-e2e/src/run/trace.ts index 4005697..21cfe5d 100644 --- a/packages/effect-e2e/src/run/trace.ts +++ b/packages/effect-e2e/src/run/trace.ts @@ -137,6 +137,9 @@ const isSelected = (entry: LogEntry): boolean => PageError: () => true, Network: ({ status }) => status >= 400, RequestFailed: () => true, + Navigation: () => false, + Reveal: () => false, + LayoutShift: ({ value, hadRecentInput }) => !hadRecentInput && value > 0, }) const logSectionOf = (logs: ScreenSnapshot['logs']): ReadonlyArray => diff --git a/packages/effect-e2e/src/verdict/classify.ts b/packages/effect-e2e/src/verdict/classify.ts index d92c077..54d8466 100644 --- a/packages/effect-e2e/src/verdict/classify.ts +++ b/packages/effect-e2e/src/verdict/classify.ts @@ -69,6 +69,10 @@ export const logLineOf = (entry: LogEntry): string => PageError: ({ message }) => `[pageerror] ${message}`, Network: ({ method, url, status }) => `[network] ${method} ${url} answered ${status}`, RequestFailed: ({ method, url, failure }) => `[request-failed] ${method} ${url}: ${failure}`, + Navigation: ({ url, deliveryType, activationStart }) => + `[navigation] ${url} delivery "${deliveryType}" activation ${activationStart}`, + Reveal: ({ url, viewTransition }) => `[reveal] ${url} view transition ${viewTransition ? 'shown' : 'none'}`, + LayoutShift: ({ url, value }) => `[layout-shift] ${url} value ${value}`, }) const logLinesOf = (entries: ReadonlyArray, ledger: SecretLedgerShape): ReadonlyArray =>