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
30 changes: 30 additions & 0 deletions packages/effect-e2e-conformance/src/engine-law.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,24 @@ const isReadyText = (entry: LogEntry): boolean =>
const networkEvidence = (engine: EngineShape, logs: ReadonlyArray<LogEntry>): 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<LogEntry>): string =>
[...logs.filter(LogEntry.guards.Navigation), ...logs.filter(LogEntry.guards.Reveal)]
.map((entry) => `${entry._tag}@${entry.url}`).join(', ')

const bothRecorded = (logs: ReadonlyArray<LogEntry>, url: string): boolean =>
logs.some(isNavigationOf(url)) && logs.some(isRevealOf(url))

const recordedFor = (logs: ReadonlyArray<LogEntry>, url: string): boolean | string =>
bothRecorded(logs, url) || `missing for ${url}; recorded: [${reportedUrls(logs)}] of ${logs.length} entries`

const navigationEvidence = (engine: EngineShape, logs: ReadonlyArray<LogEntry>, url: string): boolean | string =>
declares(engine, 'navigation-timing') ? recordedFor(logs, url) : 'not declared'

const screenshotOutcome = (engine: EngineShape): Effect.Effect<ReadonlyArray<number> | string> =>
declares(engine, 'screenshot')
? Effect.match(engine.screenshot({ mask: [email, missing] }), {
Expand Down Expand Up @@ -313,6 +331,18 @@ const laws: ReadonlyArray<Law> = [
)
}),
},
{
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) =>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
Engine,
type EngineShape,
type Locator,
LogEntry,
type Observation,
ReadRequest,
type SemanticNode,
Expand Down Expand Up @@ -49,6 +50,12 @@ const breakages = {
route: Effect.map(good.route, (route) => ({ ...route, path: '/' })),
}),
logs: (good: EngineShape): Partial<EngineShape> => ({ logs: Effect.as(good.logs, []) }),
navigation: (good: EngineShape): Partial<EngineShape> => ({
logs: Effect.map(
good.logs,
(entries) => entries.filter((entry) => !LogEntry.guards.Navigation(entry) && !LogEntry.guards.Reveal(entry)),
),
}),
observe: (good: EngineShape): Partial<EngineShape> => ({
observe: Effect.map(good.observe, (observation): Observation => ({
...observation,
Expand Down
17 changes: 15 additions & 2 deletions packages/effect-e2e-conformance/tests/__fixtures__/law-site.ts
Original file line number Diff line number Diff line change
@@ -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'

Expand All @@ -9,6 +15,7 @@ export const everyCapability: ReadonlyArray<EngineCapability> = [
'screenshot',
'vision',
'network',
'navigation-timing',
'drag',
'file-upload',
'frames',
Expand Down Expand Up @@ -50,11 +57,16 @@ const form: ReadonlyArray<SemanticNode> = [
}),
]

const pageReportLogs = (path: string): ReadonlyArray<LogEntry> => [
{ _tag: 'Navigation', url: `${LAW_ORIGIN}${path}`, deliveryType: '', activationStart: 0 },
{ _tag: 'Reveal', url: `${LAW_ORIGIN}${path}`, viewTransition: false },
]

export const lawSite = (capabilities: ReadonlyArray<EngineCapability>): 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: {},
Expand All @@ -66,6 +78,7 @@ export const lawSite = (capabilities: ReadonlyArray<EngineCapability>): MemorySi
url: `${LAW_ORIGIN}${LawSite.assetAnsweringNotFoundOnFormOpen}`,
status: 404,
},
...pageReportLogs(LawSite.form),
],
},
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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'] },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
48 changes: 43 additions & 5 deletions packages/effect-e2e-web/src/engine-web.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -12,6 +12,7 @@ import {
decodeObservation,
Engine,
EnvironmentUnavailable,
LogEntry,
SemanticNode,
} from '@systemfsoftware/effect-e2e/engine'
import * as Effect from 'effect/Effect'
Expand All @@ -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<EngineCapability> =>
spec.browser === 'chromium' ? [...WEB_CAPABILITIES, 'navigation-timing'] : WEB_CAPABILITIES

export interface WebEngineOptions {
readonly spec: WebEngineSpec
readonly contextHeaders: Readonly<Record<string, Redacted.Redacted<string>>>
Expand Down Expand Up @@ -91,6 +97,35 @@ const closed = (session: Session): Effect.Effect<void> =>
onSome: (open) => Effect.ignore(attempt(() => open.context.close())),
})

const REPORT_DELIVERY_TIMEOUT_MS = 5_000

const hasReportsFor = (url: string) => (entries: ReadonlyArray<LogEntry>): 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<ReadonlyArray<LogEntry>>, url: string): Effect.Effect<void> =>
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<ReadonlyArray<LogEntry>>) =>
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<number>) =>
(url: string): Effect.Effect<OpenSession, string> =>
Expand All @@ -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<ReadonlyArray<LogEntry>>([])
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 }
})

Expand Down Expand Up @@ -170,7 +208,7 @@ export const layer = (options: WebEngineOptions): Layer.Layer<Engine, Environmen
Effect.mapError(notOpen),
)
return Engine.of({
capabilities: WEB_CAPABILITIES,
capabilities: capabilitiesOf(options.spec),
open: (url) =>
SynchronizedRef.modifyEffect(session, opening(browser, options, snapshots, url)).pipe(
Effect.flatMap(Effect.fromResult),
Expand Down
18 changes: 18 additions & 0 deletions packages/effect-e2e-web/src/logs.ts
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -35,3 +40,16 @@ export const requestFailedEntry = (request: RequestSeen & { readonly failure: st

export const responseEntries = (response: RequestSeen & { readonly status: number }): ReadonlyArray<LogEntry> =>
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 }),
)
36 changes: 36 additions & 0 deletions packages/effect-e2e-web/src/page-reporter.ts
Original file line number Diff line number Diff line change
@@ -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<void>
}
}

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 })),
)
}
26 changes: 26 additions & 0 deletions packages/effect-e2e-web/src/web.schema.ts
Original file line number Diff line number Diff line change
@@ -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 })
Expand Down Expand Up @@ -41,3 +43,27 @@ export const AriaNode: Schema.Codec<AriaNode> = 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
Loading
Loading