diff --git a/.changeset/range-wrapper-cut.md b/.changeset/range-wrapper-cut.md new file mode 100644 index 00000000..3b43ad3d --- /dev/null +++ b/.changeset/range-wrapper-cut.md @@ -0,0 +1,5 @@ +--- +"@monetizationos/proxy": minor +--- + +Range replacement cuts the range with one callback instead of one per element when the start and end markers are direct children of the container. Other pages are cut element by element as before. `RewriterElement` now requires `removeAndKeepContent`, which lol-html and `HTMLRewriter` elements already implement. A page with a range is now buffered before it is rewritten, and HTML comments inside the cut range are removed with it. diff --git a/src/adapters/HtmlRewriterAdapter.ts b/src/adapters/HtmlRewriterAdapter.ts index bd88c5cd..ef2b9d01 100644 --- a/src/adapters/HtmlRewriterAdapter.ts +++ b/src/adapters/HtmlRewriterAdapter.ts @@ -55,6 +55,7 @@ export interface RewriterElement { append(content: string, options?: ContentOptions): void replace(content: string, options?: ContentOptions): void remove(): void + removeAndKeepContent(): void onEndTag?(callback: () => void | Promise): void } diff --git a/src/stages/surfaceComponents.ts b/src/stages/surfaceComponents.ts index 7a4f052f..ccb31366 100644 --- a/src/stages/surfaceComponents.ts +++ b/src/stages/surfaceComponents.ts @@ -4,6 +4,7 @@ import type { SurfaceDecisionResponse } from '../types' import { buildReplacementHandlers } from './buildReplacementHandlers' import { ContentElementHandler } from './ContentElementHandler' import { findMarkerPositions } from './findMarkerPositions' +import { planRangeWrapper, type RangeWrapper } from './wrappedRange' export default async function handleSurfaceComponents( ctx: ComponentStageContext, @@ -15,14 +16,56 @@ export default async function handleSurfaceComponents( return response } + const wrapper = rewriter.capabilities.onEndTag && response.body ? planRangeWrapper(ctx, surfaceDecisions) : null + if (!wrapper) { + return applyComponents(ctx, response, surfaceDecisions, rewriter, null) + } + + // Buffered, because whether the wrapper can cut the page is only known once pass 1 has read all of it + const page = await response.arrayBuffer() + const headers = new Headers(response.headers) + headers.delete('content-length') + const init = { status: response.status, statusText: response.statusText, headers } + const wrapped = await applyComponents(ctx, new Response(page, init), surfaceDecisions, rewriter, wrapper) + const wrappedPage = await wrapped.arrayBuffer() + if (wrapper.unsupported()) { + return applyComponents(ctx, new Response(page, init), surfaceDecisions, rewriter, null) + } + if (wrapper.wrapped() === 0) { + return new Response(wrappedPage, init) + } + return rewriter.create().on(wrapper.unwrap.selector, wrapper.unwrap.handler).transform(new Response(wrappedPage, init)) +} + +/** With no `wrapper`, ranges are cut element by element. */ +export async function applyComponents( + ctx: ComponentStageContext, + response: Response, + surfaceDecisions: SurfaceDecisionResponse, + rewriter: HtmlRewriterAdapter, + wrapper: RangeWrapper | null, +): Promise { const { config, logger } = ctx const supportsRangeReplacement = rewriter.capabilities.onEndTag - const { markers, componentsWithInvalidSelectors } = supportsRangeReplacement - ? await findMarkerPositions(ctx, response, surfaceDecisions, rewriter) - : { markers: {}, componentsWithInvalidSelectors: [] } + const { markers, componentsWithInvalidSelectors } = + supportsRangeReplacement && !wrapper + ? await findMarkerPositions(ctx, response, surfaceDecisions, rewriter) + : { markers: {}, componentsWithInvalidSelectors: [] } let doRewrite = false let session = rewriter.create() + const registerWrapper = (handlers: RangeWrapper['handlers']) => { + for (const { selector, handler } of handlers) { + try { + session = session.on(selector, handler) + doRewrite = true + } catch { + wrapper?.fail() + } + } + } + // Ahead of every other component, so what they insert lands outside the wrapper + registerWrapper(wrapper?.handlers ?? []) Object.entries(surfaceDecisions.componentBehaviors).forEach(([componentKey, componentBehavior]) => { if (!componentBehavior.metadata.cssSelector || !componentBehavior.content) { @@ -46,7 +89,9 @@ export default async function handleSurfaceComponents( }, ] - if (supportsRangeReplacement && !componentsWithInvalidSelectors.includes(componentKey)) { + if (wrapper && componentKey === wrapper.componentKey) { + registerWrapper(wrapper.checks) + } else if (supportsRangeReplacement && !componentsWithInvalidSelectors.includes(componentKey)) { handlers.push(...buildReplacementHandlers(ctx, componentBehavior, markers[componentKey])) } else if (!supportsRangeReplacement && componentBehavior.content.replaceRange) { logger.log({ diff --git a/src/stages/wrappedRange.ts b/src/stages/wrappedRange.ts new file mode 100644 index 00000000..afc59398 --- /dev/null +++ b/src/stages/wrappedRange.ts @@ -0,0 +1,250 @@ +import type { ElementHandlers, RewriterElement } from '../adapters/HtmlRewriterAdapter' +import type { HtmlStageContext } from '../context' +import type { SubSurfaceBehaviorApi, SurfaceDecisionResponse } from '../types' +import { renderElement } from './elements' +import { isVoidElement } from './voidElements' + +type Handler = { selector: string; handler: ElementHandlers } + +// Their content is text, so a wrapper tag inserted into it is never parsed as a tag +const RAW_TEXT_ELEMENTS = new Set(['iframe', 'noembed', 'noframes', 'noscript', 'plaintext', 'script', 'style', 'textarea', 'title', 'xmp']) + +const INSERTIONS = ['prepend', 'append', 'before', 'after'] as const + +const withoutNested = (selector: string) => selector.replace(/"[^"]*"|'[^']*'|\[[^\]]*\]|\([^)]*\)/g, '') +// One compound selector, so `${container} > ${marker}` only matches a direct child +const isCompound = (selector: string) => !/[\s>+~,]/.test(withoutNested(selector.trim())) + +export type RangeWrapper = { + componentKey: string + /** Pass 1, ahead of every other component: split each container into `` segments. */ + handlers: Handler[] + /** Pass 1, in the range component's own place, so they see markers removed by the components before it. */ + checks: Handler[] + /** Pass 2: drop the segment holding the range and put the replacement there; unwrap the rest. */ + unwrap: Handler + wrapped: () => number + /** Pass 1 met a page the wrapper can't cut the way the element path would, so the caller must redo it that way. */ + unsupported: () => boolean + fail: () => void +} + +/** + * Cuts a range by splitting its container into wrapper elements in one pass and removing the one that holds the range + * in the next, so the cut costs one callback instead of one per element. The split only balances when both markers + * are direct children of the container. Only a decision with exactly one range component, and no other content on + * that component, qualifies. + */ +export const planRangeWrapper = (ctx: HtmlStageContext, decisions: SurfaceDecisionResponse): RangeWrapper | null => { + const ranges = Object.entries(decisions.componentBehaviors).filter(([, b]) => b.content?.replaceRange && b.metadata.cssSelector) + if (ranges.length !== 1) { + return null + } + const [componentKey, behavior] = ranges[0] as [string, SubSurfaceBehaviorApi] + const { replaceRange, ...others } = behavior.content ?? {} + if (!replaceRange || Object.values(others).some((value) => (Array.isArray(value) ? value.length : value))) { + return null + } + const { fromMarker, toMarker } = replaceRange + if (fromMarker && toMarker && fromMarker === toMarker) { + return null + } + const container = behavior.metadata.cssSelector as string + if ((fromMarker && !isCompound(fromMarker)) || (toMarker && !isCompound(toMarker)) || withoutNested(container).includes(',')) { + return null + } + // Components ahead of the range whose inserts the element path places differently, or keeps where the wrapper cuts + const keys = Object.keys(decisions.componentBehaviors) + const inserting = keys + .slice(0, keys.indexOf(componentKey)) + .map((key) => decisions.componentBehaviors[key]) + .filter((b) => b?.metadata.cssSelector && INSERTIONS.some((key) => b.content?.[key]?.length)) + .map((b) => b?.metadata.cssSelector as string) + + const tag = `mos-range-${Math.random().toString(36).slice(2, 10)}` + const cut = `<${tag}>` + const keep = `<${tag} data-keep>` + const close = `` + const html = { html: true } + const replacement = (replaceRange.replaceWith ?? []).map((item) => renderElement(ctx, item)) + + let unsupported = false + let wrapped = 0 + let depth = 0 + // `inStart`: a start marker nested in the first one doesn't start the range again + let phase: 'scanning' | 'inStart' | 'open' | 'closed' = 'closed' + // Set by a `>` handler for the check on the same element, which runs after it + let directStart = false + let directEnd = false + // Cleared by the range's own check on the container, after the components ahead of it + let atContainer = false + // The rewriter mismatches the wrapper's end tags inside MathML + let mathDepth = 0 + + const handlers: Handler[] = [ + { + selector: 'math', + handler: { + element(element: RewriterElement) { + mathDepth++ + try { + element.onEndTag?.(() => { + mathDepth-- + }) + } catch { + // Self-closing ``, so nothing is inside it + mathDepth-- + } + }, + }, + }, + { + selector: container, + handler: { + element(element: RewriterElement) { + if (isVoidElement(element)) { + return + } + if (!element.onEndTag || RAW_TEXT_ELEMENTS.has(element.tagName.toLowerCase()) || mathDepth > 0 || depth > 0) { + unsupported = true + return + } + atContainer = true + depth++ + phase = fromMarker ? 'scanning' : 'open' + // Closed by the container's own end, so what other components append lands outside it + element.prepend(fromMarker ? keep : cut, html) + element.append(close, html) + wrapped++ + try { + element.onEndTag(() => { + phase = 'closed' + depth-- + }) + } catch { + // Self-closing foreign element (``), so there is no end tag to close the cut at + unsupported = true + } + }, + }, + }, + ] + const checks: Handler[] = [ + { + selector: container, + handler: { + element() { + atContainer = false + }, + }, + }, + ] + + if (toMarker) { + handlers.push({ + selector: `${container} > ${toMarker}`, + handler: { + element(element: RewriterElement) { + if (phase === 'open') { + element.before(close + keep, html) + phase = 'closed' + directEnd = true + } + }, + }, + }) + checks.push({ + selector: `${container} ${toMarker}`, + handler: { + element(element: RewriterElement) { + if (directEnd) { + directEnd = false + unsupported ||= element.removed + } else if (phase === 'open') { + unsupported = true + } + }, + }, + }) + } + if (fromMarker) { + handlers.push({ + selector: `${container} > ${fromMarker}`, + handler: { + element(element: RewriterElement) { + if (phase !== 'scanning') { + return + } + element.after(close + cut, html) + directStart = true + phase = 'inStart' + if (!element.onEndTag || isVoidElement(element)) { + phase = 'open' + return + } + try { + element.onEndTag(() => { + phase = 'open' + }) + } catch { + // Self-closing foreign element, so nothing is inside it + phase = 'open' + } + }, + }, + }) + checks.push({ + selector: `${container} ${fromMarker}`, + handler: { + element(element: RewriterElement) { + if (directStart) { + directStart = false + unsupported ||= element.removed + } else if (phase === 'scanning') { + unsupported = true + } + }, + }, + }) + } + + // After the wrapper's own handlers, so they see its state for the same element + for (const selector of inserting) { + handlers.push({ + selector, + handler: { + element() { + if (atContainer || phase === 'open' || (phase === 'inStart' && directStart)) { + unsupported = true + } + }, + }, + }) + } + + return { + componentKey, + handlers, + checks, + unwrap: { + selector: tag, + handler: { + element(element: RewriterElement) { + if (element.hasAttribute('data-keep')) { + element.removeAndKeepContent() + return + } + for (const [content, options] of replacement) { + element.before(content, options) + } + element.remove() + }, + }, + }, + wrapped: () => wrapped, + unsupported: () => unsupported, + fail: () => { + unsupported = true + }, + } +} diff --git a/test/fakes/EndTagSpyRewriter.ts b/test/fakes/EndTagSpyRewriter.ts new file mode 100644 index 00000000..3831f229 --- /dev/null +++ b/test/fakes/EndTagSpyRewriter.ts @@ -0,0 +1,47 @@ +import type { + ElementHandlers, + HtmlRewriterAdapter, + HtmlRewriterCapabilities, + HtmlRewriterSession, + RewriterElement, +} from '../../src/adapters/HtmlRewriterAdapter' +import { LolHtmlRewriter } from './LolHtmlRewriter' + +/** Wraps the real rewriter and records the tag of every element `onEndTag` is called on. */ +export class EndTagSpyRewriter implements HtmlRewriterAdapter { + readonly inner = new LolHtmlRewriter() + readonly capabilities: HtmlRewriterCapabilities = this.inner.capabilities + readonly endTagCalls: string[] = [] + + // Methods are bound to the element, which a real rewriter element needs + private spyOn(element: RewriterElement): RewriterElement { + const onEndTag: RewriterElement['onEndTag'] = (callback) => { + this.endTagCalls.push(element.tagName.toLowerCase()) + element.onEndTag?.(callback) + } + return new Proxy(element, { + get: (target, prop) => { + if (prop === 'onEndTag') { + return onEndTag + } + const value = Reflect.get(target, prop) + return typeof value === 'function' ? value.bind(target) : value + }, + }) + } + + create(): HtmlRewriterSession { + const session = this.inner.create() + const spy: HtmlRewriterSession = { + on: (selector: string, handlers: ElementHandlers) => { + session.on(selector, { + ...handlers, + element: handlers.element ? (element) => handlers.element?.(this.spyOn(element)) : undefined, + }) + return spy + }, + transform: (response) => session.transform(response), + } + return spy + } +} diff --git a/test/fakes/LolHtmlRewriter.ts b/test/fakes/LolHtmlRewriter.ts index 8e2e4231..1b2c07b5 100644 --- a/test/fakes/LolHtmlRewriter.ts +++ b/test/fakes/LolHtmlRewriter.ts @@ -64,6 +64,10 @@ class LolHtmlSession implements HtmlRewriterSession { throw new Error('LolHtmlRewriter session can only transform a single response') } this.consumed = true + if (!response.body) { + this.rewriter.free() + return new Response(null, response) + } const rewriter = this.rewriter const chunks = this.chunks @@ -126,6 +130,9 @@ const adaptElement = (element: WasmElement): RewriterElement => ({ remove: () => { element.remove() }, + removeAndKeepContent: () => { + element.removeAndKeepContent() + }, onEndTag: (callback) => { element.onEndTag(async () => { await callback() diff --git a/test/stages/voidElements.spec.ts b/test/stages/voidElements.spec.ts index d046ac3e..cac95d45 100644 --- a/test/stages/voidElements.spec.ts +++ b/test/stages/voidElements.spec.ts @@ -1,11 +1,11 @@ import { describe, expect, it } from 'vitest' -import type { ElementHandlers, HtmlRewriterAdapter, HtmlRewriterSession, RewriterElement } from '../../src/adapters/HtmlRewriterAdapter' +import type { RewriterElement } from '../../src/adapters/HtmlRewriterAdapter' import { normalizeMOSConfig } from '../../src/config' import type { PipelineContext } from '../../src/context' -import handleSurfaceComponents from '../../src/stages/surfaceComponents' +import { applyComponents } from '../../src/stages/surfaceComponents' import { isVoidElement } from '../../src/stages/voidElements' import type { SurfaceDecisionResponse } from '../../src/types' -import { LolHtmlRewriter } from '../fakes/LolHtmlRewriter' +import { EndTagSpyRewriter } from '../fakes/EndTagSpyRewriter' const ctx: PipelineContext = { config: normalizeMOSConfig({ @@ -19,45 +19,6 @@ const ctx: PipelineContext = { logger: { log() {} }, } -/** Wraps the real rewriter and records the tag of every element `onEndTag` is called on. */ -class EndTagSpyRewriter implements HtmlRewriterAdapter { - readonly inner = new LolHtmlRewriter() - readonly capabilities = this.inner.capabilities - readonly endTagCalls: string[] = [] - - // Methods are bound to the element, which a real rewriter element needs - private spyOn(element: RewriterElement): RewriterElement { - const onEndTag: RewriterElement['onEndTag'] = (callback) => { - this.endTagCalls.push(element.tagName.toLowerCase()) - element.onEndTag?.(callback) - } - return new Proxy(element, { - get: (target, prop) => { - if (prop === 'onEndTag') { - return onEndTag - } - const value = Reflect.get(target, prop) - return typeof value === 'function' ? value.bind(target) : value - }, - }) - } - - create(): HtmlRewriterSession { - const session = this.inner.create() - const spy: HtmlRewriterSession = { - on: (selector: string, handlers: ElementHandlers) => { - session.on(selector, { - ...handlers, - element: handlers.element ? (element) => handlers.element?.(this.spyOn(element)) : undefined, - }) - return spy - }, - transform: (response) => session.transform(response), - } - return spy - } -} - const decisions = (replaceRange: { fromMarker?: string; toMarker?: string }, cssSelector = 'div.hero'): SurfaceDecisionResponse => ({ status: 'success', identity: { identifier: 'anon', isAuthenticated: false, authType: 'anonymous', jwtClaims: {} }, @@ -76,7 +37,7 @@ const decisions = (replaceRange: { fromMarker?: string; toMarker?: string }, css const run = async (body: string, replaceRange: { fromMarker?: string; toMarker?: string }, cssSelector?: string) => { const rewriter = new EndTagSpyRewriter() const response = new Response(body, { headers: { 'Content-Type': 'text/html; charset=utf-8' } }) - const result = await handleSurfaceComponents(ctx, response, decisions(replaceRange, cssSelector), rewriter) + const result = await applyComponents(ctx, response, decisions(replaceRange, cssSelector), rewriter, null) return { body: await result.text(), endTagCalls: rewriter.endTagCalls } } diff --git a/test/stages/wrappedRange.spec.ts b/test/stages/wrappedRange.spec.ts new file mode 100644 index 00000000..c9e2e88b --- /dev/null +++ b/test/stages/wrappedRange.spec.ts @@ -0,0 +1,310 @@ +import { describe, expect, it } from 'vitest' +import { normalizeMOSConfig } from '../../src/config' +import type { PipelineContext } from '../../src/context' +import handleSurfaceComponents, { applyComponents } from '../../src/stages/surfaceComponents' +import type { SurfaceDecisionResponse, WebElement } from '../../src/types' +import { EndTagSpyRewriter } from '../fakes/EndTagSpyRewriter' + +const ctx: PipelineContext = { + config: normalizeMOSConfig({ + originUrl: 'https://origin.example.com', + surfaceSlug: 'web', + mosHost: 'https://api.monetizationos.com', + mosSecretKey: 'sk_env_test_abc', + anonymousSessionCookieName: 'anon-session', + authenticatedUserJwtCookieName: '__session', + }), + logger: { log() {} }, +} + +type Range = { fromMarker?: string; toMarker?: string; replaceWith?: WebElement[] } +type Others = Record }> +type Options = { others?: Others; othersFirst?: boolean } +const html = (content: string) => ({ type: 'html' as const, content }) + +const decision = (selector: string, range: Range, { others = {}, othersFirst = false }: Options = {}): SurfaceDecisionResponse => { + const rangeBehavior = { + range: { metadata: { cssSelector: selector }, content: { replaceRange: { replaceWith: [html('NEW')], ...range } } }, + } + const otherBehaviors = Object.fromEntries( + Object.entries(others).map(([key, { selector: cssSelector, content }]) => [key, { metadata: { cssSelector }, content }]), + ) + return { + status: 'success', + identity: { identifier: 'anon', isAuthenticated: false, authType: 'anonymous', jwtClaims: {} }, + features: {}, + customer: { hasProducts: false }, + surfaceBehavior: {}, + componentsSkipped: false, + componentBehaviors: othersFirst ? { ...otherBehaviors, ...rangeBehavior } : { ...rangeBehavior, ...otherBehaviors }, + } +} + +const run = async (body: string, selector: string, range: Range, options?: Options, elementByElement = false) => { + const rewriter = new EndTagSpyRewriter() + const response = new Response(body, { headers: { 'Content-Type': 'text/html; charset=utf-8' } }) + const decisions = decision(selector, range, options) + const result = elementByElement + ? await applyComponents(ctx, response, decisions, rewriter, null) + : await handleSurfaceComponents(ctx, response, decisions, rewriter) + return { body: await result.text(), endTagCalls: rewriter.endTagCalls } +} + +/** Runs the page through the wrapper and element by element, and expects the same output from both. */ +const bothWays = async (body: string, selector: string, range: Range, options?: Options) => { + const wrapped = await run(body, selector, range, options) + const elementByElement = await run(body, selector, range, options, true) + expect(wrapped.body).toBe(elementByElement.body) + return { ...wrapped, elementByElement } +} + +const page = '

t

a

b

c


d

f
' + +describe('range replacement by wrapper', () => { + it('cuts from the start marker to the end of the container with one callback', async () => { + const { body, endTagCalls, elementByElement } = await bothWays(page, '.c', { fromMarker: 'p.s' }) + expect(body).toBe('

t

a

NEW
f
') + expect(endTagCalls.sort()).toEqual(['div', 'p']) + expect(elementByElement.endTagCalls.length).toBeGreaterThan(endTagCalls.length) + }) + + it('keeps the end marker and what follows it', async () => { + const { body } = await bothWays(page, '.c', { fromMarker: 'p.s', toMarker: 'hr.e' }) + expect(body).toBe('

t

a

NEW

d

f
') + }) + + it('cuts from the top of the container when there is only an end marker', async () => { + const { body } = await bothWays(page, '.c', { toMarker: 'hr.e' }) + expect(body).toBe('
NEW

d

f
') + }) + + it('replaces the whole container content when there are no markers', async () => { + const { body } = await bothWays(page, '.c', {}) + expect(body).toBe('
NEW
f
') + }) + + it('removes the range when there is nothing to replace it with', async () => { + const { body } = await bothWays(page, '.c', { fromMarker: 'p.s', replaceWith: [] }) + expect(body).toBe('

t

a

f
') + }) + + it('cuts each matching container', async () => { + const two = '

a

b

c

d

' + const { body } = await bothWays(two, '.c', { fromMarker: 'p.s' }) + expect(body).toBe('

a

NEW

c

NEW
') + }) + + it('leaves the container alone when the start marker is absent', async () => { + const { body } = await bothWays('

a

b

', '.c', { fromMarker: 'p.s' }) + expect(body).toBe('

a

b

') + }) + + it('ignores an end marker before the start marker', async () => { + const { body } = await bothWays('

a

b

', '.c', { + fromMarker: 'p.s', + toMarker: 'hr.e', + }) + expect(body).toBe('

a

NEW
') + }) + + it('starts the range on an element that matches both markers', async () => { + const { body } = await bothWays('

a

b

', '.c', { fromMarker: '.s', toMarker: '.e' }) + expect(body).toBe('

a

NEW
') + }) + + it('starts the range on a void element that matches both markers', async () => { + const { body } = await bothWays('

b

', '.c', { fromMarker: '.s', toMarker: '.e' }) + expect(body).toBe('

NEW
') + }) + + it('removes comments inside the cut', async () => { + const { body } = await run('

a

b

', '.c', { fromMarker: 'p.s' }) + expect(body).toBe('

a

NEW
') + }) + + it('removes stray end tags of void elements inside the cut', async () => { + const { body, endTagCalls } = await run('

a



b

', '.c', { + fromMarker: 'p.s', + }) + expect(body).toBe('

a

NEW
') + expect(endTagCalls).toEqual(['div', 'p']) + }) + + it('cuts after a start marker that holds another with one callback', async () => { + const { body, endTagCalls } = await bothWays('

a

b

c

', '.c', { + fromMarker: '.s', + }) + expect(body).toBe('

a

b

NEW
') + expect(endTagCalls.sort()).toEqual(['div', 'div']) + }) + + it('leaves a container with no end tag alone', async () => { + const { body, endTagCalls } = await bothWays('

a

b

', 'img.c', {}) + expect(body).toBe('

a

b

') + expect(endTagCalls).toEqual([]) + }) + + it('leaves a response with no body alone', async () => { + const rewriter = new EndTagSpyRewriter() + const response = new Response(null, { status: 304, headers: { 'Content-Type': 'text/html' } }) + const result = await handleSurfaceComponents(ctx, response, decision('.c', { fromMarker: 'p.s' }), rewriter) + expect(result.status).toBe(304) + }) + + describe('other components', () => { + const around: Others = { + container: { selector: '.c', content: { prepend: [html('P')], append: [html('A')] } }, + start: { selector: 'p.s', content: { before: [html('B')], after: [html('F')] } }, + end: { selector: 'hr.e', content: { before: [html('B')], after: [html('F')] } }, + } + + it('keep what they insert around the container and the markers', async () => { + const { body } = await bothWays(page, '.c', { fromMarker: 'p.s', toMarker: 'hr.e' }, { others: around }) + expect(body).toBe( + '
P

t

B

a

FNEWB
F

d

A
f
', + ) + }) + + it('keep what they append to a container cut to its end', async () => { + const { body } = await bothWays(page, '.c', { fromMarker: 'p.s' }, { others: { container: around.container as never } }) + expect(body).toBe('
P

t

a

NEWA
f
') + }) + + it('still apply to the content that stays', async () => { + const others = { heading: { selector: 'h1', content: { append: [html('x')] } } } + const { body } = await bothWays(page, '.c', { fromMarker: 'p.s' }, { others }) + expect(body).toBe('

tx

a

NEW
f
') + }) + }) + + describe('falls back to cutting element by element', () => { + const expectsFallback = async (body: string, selector: string, range: Range, options?: Options) => { + const { endTagCalls, elementByElement } = await bothWays(body, selector, range, options) + // The abandoned wrapper pass may have registered callbacks before the element path starts over + expect(endTagCalls.slice(-elementByElement.endTagCalls.length)).toEqual(elementByElement.endTagCalls) + } + + it('when the start marker is not a direct child of the container', async () => { + await expectsFallback('

a

b

c

', '.c', { fromMarker: 'p.s' }) + }) + + it('when the end marker is not a direct child of the container', async () => { + await expectsFallback('

a

b


c

', '.c', { + fromMarker: 'p.s', + toMarker: 'hr.e', + }) + }) + + it('when containers are nested', async () => { + await expectsFallback('

a

b

c

', '.c', { + fromMarker: 'p.s', + }) + }) + + it('when a component before the range removes a marker', async () => { + for (const marker of ['p.s', 'hr.e']) { + const options = { others: { gone: { selector: marker, content: { remove: true } } }, othersFirst: true } + await expectsFallback(page, '.c', { fromMarker: 'p.s', toMarker: 'hr.e' }, options) + } + }) + + it('when the container holds raw text', async () => { + for (const tag of ['textarea', 'title', 'script', 'style', 'noscript']) { + await expectsFallback(`<${tag} class="c">

a

b

z

`, '.c', {}) + } + }) + + it('when the container is in MathML', async () => { + for (const body of [ + '

a

x
', + '

a

x
', + ]) { + await expectsFallback(body, '.c', { fromMarker: '.s' }) + } + }) + + it('when a marker selector has a combinator', async () => { + await expectsFallback('

t

a

b

c

', '.c', { + fromMarker: 'section .s', + }) + }) + + it('when the container selector names the MathML element', async () => { + await expectsFallback('

a

x

z

', 'math > mi.c', { fromMarker: '.s' }) + }) + + it('when a component before the range inserts content', async () => { + for (const [selector, content] of [ + ['p.s', { after: [html('O')] }], + ['section', { before: [html('O')] }], + ] as const) { + const options = { others: { other: { selector, content } }, othersFirst: true } + await expectsFallback(page, '.c', { fromMarker: 'p.s', toMarker: 'hr.e' }, options) + } + }) + + it('when the container selector is a list', async () => { + await expectsFallback(`

x

${page}`, '.x, .c', { fromMarker: 'p.s', toMarker: 'hr.e' }) + }) + }) + + it('uses the element path when the decision has several range components', async () => { + const two = decision('.c', { fromMarker: 'p.s' }) + two.componentBehaviors.second = { + metadata: { cssSelector: 'footer' }, + content: { replaceRange: { replaceWith: [html('2')] } }, + } + const rewriter = new EndTagSpyRewriter() + const result = await handleSurfaceComponents(ctx, new Response(page, { headers: { 'Content-Type': 'text/html' } }), two, rewriter) + expect(await result.text()).toBe('

t

a

NEW
2
') + expect(rewriter.endTagCalls.length).toBeGreaterThan(2) + }) + + it('gives the same page as cutting element by element on random pages', async () => { + let seed = 7 + const rnd = () => (seed = (seed * 1664525 + 1013904223) >>> 0) / 2 ** 32 + const pick = (items: readonly T[]): T => items[Math.floor(rnd() * items.length)] as T + const tree = (depth: number): string => { + let out = '' + for (let i = 0, n = 1 + Math.floor(rnd() * 5); i < n; i++) { + const r = rnd() + if (r < 0.2) out += pick(['text ', 'a b ', '& ']) + else if (r < 0.35) out += `<${pick(['img', 'br', 'hr', 'input'])}${rnd() < 0.4 ? ` class="${pick(['s', 'e', 'x'])}"` : ''}>` + else if (r < 0.4) out += `` + else if (depth > 0) { + const tag = pick(['div', 'p', 'span', 'section', 'ul', 'li', 'a', 'h2']) + const cls = pick(['s', 'e', 'x', 'c', '']) + out += `<${tag}${cls ? ` class="${cls}"` : ''}>${tree(depth - 1)}` + } else out += 'leaf ' + } + return out + } + for (let i = 0; i < 300; i++) { + const container = pick(['div', 'div', 'div', 'textarea', 'mi']) + const containerHtml = `<${container} class="c">${tree(3)}` + const body = `${tree(2)}${container === 'mi' ? `${containerHtml}` : containerHtml}${rnd() < 0.3 ? `
${tree(2)}
` : ''}${tree(1)}` + const range = { + fromMarker: pick([undefined, '.s', 'p', 'h2', 'img']), + toMarker: pick([undefined, undefined, '.e', 'hr', 'li']), + } + const otherSelector = pick(['.s', '.e', '.c', 'p', 'li']) + const options = pick([ + {}, + { others: { other: { selector: otherSelector, content: { remove: true } } }, othersFirst: rnd() < 0.5 }, + { + others: { + other: { + selector: otherSelector, + content: { [pick(['append', 'prepend', 'before', 'after'])]: [html('O')] }, + }, + }, + othersFirst: rnd() < 0.5, + }, + ]) + const selector = pick(['.c', 'div', 'article', 'body']) + const wrapped = await run(body, selector, range, options) + const elementByElement = await run(body, selector, range, options, true) + expect(wrapped.body, JSON.stringify({ body, selector, range, options })).toBe(elementByElement.body) + } + }) +})