Skip to content
File

Blob: src/workerd/api/tests/htmlrewriter-test.js

javascript1214 lines
1// Copyright (c) 2024 Cloudflare, Inc.
2// Licensed under the Apache 2.0 license found in the LICENSE file or at:
3// https://opensource.org/licenses/Apache-2.0
4import {
5 strictEqual,
6 deepStrictEqual,
7 notStrictEqual,
8 rejects,
9 throws,
10} from 'node:assert';
11 
12const arrayIterator = [][Symbol.iterator]();
13const arrayIteratorPrototype = Object.getPrototypeOf(
14 Object.getPrototypeOf(arrayIterator)
15);
16 
17export const passthroughWithContent = {
18 async test() {
19 const response = new Response('hello', {
20 headers: {
21 foo: 'bar',
22 },
23 });
24 strictEqual(response.headers.get('foo'), 'bar');
25 
26 const rewriter = new HTMLRewriter();
27 
28 const newResponse = rewriter.transform(response);
29 
30 notStrictEqual(response, newResponse);
31 strictEqual(newResponse.headers.get('foo'), 'bar');
32 strictEqual(await newResponse.text(), 'hello');
33 },
34};
35 
36export const passthroughWithContentAndHandler = {
37 async test() {
38 const response = new Response('<h3>hello</h3>', {
39 headers: {
40 foo: 'bar',
41 },
42 });
43 strictEqual(response.headers.get('foo'), 'bar');
44 
45 const rewriter = new HTMLRewriter().on('h3', {
46 element() {},
47 comments() {},
48 text() {},
49 });
50 
51 const newResponse = rewriter.transform(response);
52 
53 notStrictEqual(response, newResponse);
54 strictEqual(newResponse.headers.get('foo'), 'bar');
55 strictEqual(await newResponse.text(), '<h3>hello</h3>');
56 },
57};
58 
59export const passthroughWithContentAndAsyncHandler = {
60 async test() {
61 const response = new Response('<h3>hello</h3>', {
62 headers: {
63 foo: 'bar',
64 },
65 });
66 strictEqual(response.headers.get('foo'), 'bar');
67 
68 const rewriter = new HTMLRewriter().on('h3', {
69 async test() {
70 await scheduler.wait(10);
71 },
72 async element() {
73 await scheduler.wait(10);
74 },
75 async comments() {
76 await scheduler.wait(10);
77 },
78 });
79 
80 const newResponse = rewriter.transform(response);
81 
82 notStrictEqual(response, newResponse);
83 strictEqual(newResponse.headers.get('foo'), 'bar');
84 strictEqual(await newResponse.text(), '<h3>hello</h3>');
85 },
86};
87 
88export const passthroughWithContentAndAsyncHandler2 = {
89 async test() {
90 const response = new Response('<h3>hello</h3>', {
91 headers: {
92 foo: 'bar',
93 },
94 });
95 strictEqual(response.headers.get('foo'), 'bar');
96 
97 const { promise, resolve } = Promise.withResolvers();
98 
99 const rewriter = new HTMLRewriter().on('h3', {
100 async test() {
101 await promise;
102 },
103 async element() {
104 await scheduler.wait(10);
105 },
106 async comments() {
107 await scheduler.wait(10);
108 },
109 });
110 
111 const newResponse = rewriter.transform(response);
112 
113 // We can resolve the promise after calling transform and the transform
114 // will complete as expected.
115 resolve();
116 
117 notStrictEqual(response, newResponse);
118 strictEqual(newResponse.headers.get('foo'), 'bar');
119 strictEqual(await newResponse.text(), '<h3>hello</h3>');
120 },
121};
122 
123export const passthroughWithContentStream = {
124 async test() {
125 const { readable, writable } = new TransformStream();
126 
127 const rewriter = new HTMLRewriter().on('h3', {
128 async text(content) {
129 await scheduler.wait(10);
130 },
131 });
132 
133 const response = rewriter.transform(new Response(readable));
134 
135 const writer = writable.getWriter();
136 const enc = new TextEncoder();
137 const results = await Promise.all([
138 response.text(),
139 writer.write(enc.encode('<h3>hello</h3>')),
140 writer.close(),
141 ]);
142 
143 strictEqual(results[0], '<h3>hello</h3>');
144 },
145};
146 
147export const passthroughWithEmptyStream = {
148 async test() {
149 const { readable, writable } = new TransformStream();
150 
151 const rewriter = new HTMLRewriter().on('h3', {
152 async text(content) {
153 await scheduler.wait(10);
154 },
155 });
156 
157 const response = rewriter.transform(new Response(readable));
158 
159 const writer = writable.getWriter();
160 const results = await Promise.all([response.text(), writer.close()]);
161 
162 strictEqual(results[0], '');
163 },
164};
165 
166export const asyncElementHandler = {
167 async test() {
168 const rewriter = new HTMLRewriter().on('body', {
169 async element(e) {
170 await scheduler.wait(10);
171 e.setInnerContent('world');
172 },
173 });
174 
175 const response = rewriter.transform(new Response('<body>hello</body>'));
176 
177 strictEqual(await response.text(), '<body>world</body>');
178 },
179};
180 
181export const asyncCommentHandler = {
182 async test() {
183 const rewriter = new HTMLRewriter().on('body', {
184 async comments(comment) {
185 await scheduler.wait(10);
186 if (comment.text == 'hello') {
187 comment.text = 'world';
188 }
189 },
190 });
191 
192 const response = rewriter.transform(
193 new Response('<body><!--hello--></body>')
194 );
195 
196 strictEqual(await response.text(), '<body><!--world--></body>');
197 },
198};
199 
200export const objectHandlers = {
201 async test() {
202 class DocumentContentHandlers {
203 deadTokens = {};
204 doctypeCount = 0;
205 commentCount = 0;
206 textCount = 0;
207 expectedErrors = [];
208 
209 doctype(token) {
210 if (!this.deadTokens.doctype) {
211 this.deadTokens.doctype = token;
212 }
213 ++this.doctypeCount;
214 
215 this.sawDoctype = JSON.stringify(token);
216 }
217 
218 comments(token) {
219 if (!this.deadTokens.comment) {
220 this.deadTokens.comment = token;
221 }
222 ++this.commentCount;
223 }
224 
225 text(token) {
226 if (!this.deadTokens.text) {
227 this.deadTokens.text = token;
228 }
229 ++this.textCount;
230 }
231 
232 end(token) {
233 this.reachedEnd = true;
234 }
235 }
236 
237 class ElementContentHandlers {
238 deadTokens = {};
239 elementCount = 0;
240 commentCount = 0;
241 textCount = 0;
242 expectedErrors = [];
243 
244 element(token) {
245 if (!this.deadTokens.element) {
246 this.deadTokens.element = token;
247 }
248 if (!this.deadTokens.attributesIterator) {
249 this.deadTokens.attributesIterator = token.attributes;
250 }
251 ++this.elementCount;
252 
253 // Exercise all the different methods on Element.
254 if (
255 token.tagName === 'body' &&
256 token.hasAttribute('foo') &&
257 !token.hasAttribute('baz') &&
258 token.getAttribute('foo') === 'bar'
259 ) {
260 token.removeAttribute('foo');
261 token.setAttribute('baz', 'qux');
262 
263 try {
264 token.tagName = 'should throw';
265 throw new Error('should have thrown');
266 } catch (e) {
267 this.expectedErrors.push(e.message);
268 }
269 
270 token.tagName = 'tail';
271 
272 // These will show up in order in the response body.
273 token.before('<1>');
274 token.before('<2>', { html: false });
275 token.before('<3>\n', null);
276 token.before('<html>', { html: true });
277 
278 // These will show up in reverse order in the response body.
279 token.prepend('<em>hello</em>, ', { html: true });
280 token.prepend('<6>\n');
281 token.prepend('<5>', { html: false });
282 token.prepend('\n<4>', null);
283 
284 // Iterator tests.
285 
286 this.sawAttributes = JSON.stringify([...token.attributes]);
287 
288 let iterator = token.attributes;
289 let iteratorPrototype = Object.getPrototypeOf(
290 Object.getPrototypeOf(iterator)
291 );
292 if (iteratorPrototype !== arrayIteratorPrototype) {
293 throw new Error(
294 'attributes iterator does not have iterator prototype'
295 );
296 }
297 
298 // Run the iterator down until it's done.
299 for (let [_k, _v] of iterator) {
300 // intentionally empty
301 }
302 // .next() should now be idempotent.
303 let result = iterator.next();
304 let result2 = iterator.next();
305 if (
306 result.done !== result2.done ||
307 result.value !== result2.value ||
308 !result.done ||
309 result.value
310 ) {
311 throw new Error(
312 'exhausted iterator should continually return done'
313 );
314 }
315 } else if (token.tagName === 'remove') {
316 let mode = token.getAttribute('mode');
317 if (mode === null) {
318 throw new Error("missing attribute on 'remove' element");
319 }
320 if (token.removed) {
321 throw new Error('element should not have been removed yet');
322 }
323 
324 if (mode === 'all') {
325 token.remove();
326 } else {
327 token.removeAndKeepContent();
328 }
329 
330 if (!token.removed) {
331 throw new Error('element should have been removed now');
332 }
333 } else if (token.tagName === 'after') {
334 let isHtml = token.getAttribute('is-html');
335 let html = isHtml === 'true' ? true : false;
336 token.after('<after>', { html });
337 } else if (token.tagName === 'append') {
338 let isHtml = token.getAttribute('is-html');
339 let html = isHtml === 'true' ? true : false;
340 token.append('<append>', { html });
341 } else if (token.tagName === 'replace') {
342 let isHtml = token.getAttribute('is-html');
343 let html = isHtml === 'true' ? true : false;
344 token.replace('<replace>', { html });
345 } else if (token.tagName === 'set-inner-content') {
346 let isHtml = token.getAttribute('is-html');
347 let html = isHtml === 'true' ? true : false;
348 token.setInnerContent('<set-inner-content>', { html });
349 } else if (token.tagName === 'set-attribute') {
350 if (!token.hasAttribute('foo')) {
351 throw new Error('element should have had attribute');
352 }
353 let attr = token.getAttribute('foo');
354 if (attr !== '') {
355 throw new Error('element attribute should have been empty');
356 }
357 token.setAttribute('foo', 'bar');
358 
359 if (token.getAttribute('nonexistent')) {
360 throw new Error('attribute should not exist');
361 }
362 }
363 }
364 
365 comments(token) {
366 if (!this.deadTokens.comment) {
367 this.deadTokens.comment = token;
368 }
369 ++this.commentCount;
370 
371 // Exercise all the different methods on Comment.
372 if (token.text === ' SET TEXT PROPERTY ') {
373 token.text = ' text property has been set ';
374 } else if (token.text === ' REMOVE ME ') {
375 if (token.removed) {
376 throw new Error("Shouldn't be removed yet");
377 }
378 
379 token.remove();
380 
381 if (!token.removed) {
382 throw new Error('Should be removed now');
383 }
384 } else if (token.text === ' REPLACE ME ') {
385 if (token.removed) {
386 throw new Error("Shouldn't be removed yet");
387 }
388 
389 token.replace('this will get overwritten');
390 
391 if (!token.removed) {
392 throw new Error('Should be removed now');
393 }
394 
395 token.replace('<REPLACED>', null);
396 
397 if (!token.removed) {
398 throw new Error('Should still be removed');
399 }
400 
401 token.before('<!-- ', { html: true });
402 token.after(' -->', { html: true });
403 }
404 }
405 
406 text(token) {
407 if (!this.deadTokens.text) {
408 this.deadTokens.text = token;
409 }
410 ++this.textCount;
411 
412 if (token.lastInTextNode && token.text.length > 0) {
413 throw new Error('last text chunk has non-zero length');
414 } else if (!token.lastInTextNode && token.text.length === 0) {
415 throw new Error('non-last text chunk has zero length');
416 }
417 
418 if (token.text === 'world') {
419 token.before('again, ');
420 token.after('...');
421 
422 if (token.removed) {
423 throw new Error("Shouldn't be removed yet");
424 }
425 
426 token.replace('this will get overwritten');
427 
428 if (!token.removed) {
429 throw new Error('Should be removed now');
430 }
431 
432 token.replace('<WORLD>', { html: true });
433 
434 if (!token.removed) {
435 throw new Error('Should still be removed');
436 }
437 } else if (token.text === 'REMOVE ME\n') {
438 if (token.removed) {
439 throw new Error("Shouldn't be removed yet");
440 }
441 
442 token.remove();
443 
444 if (!token.removed) {
445 throw new Error('Should be removed now');
446 }
447 }
448 }
449 }
450 
451 let documentHandlers = new DocumentContentHandlers();
452 let elementHandlers = new ElementContentHandlers();
453 
454 const rewriter = new HTMLRewriter()
455 .onDocument(documentHandlers)
456 .on('*', elementHandlers);
457 
458 let _count = 0;
459 
460 const enc = new TextEncoder();
461 const kInput = [
462 '<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01//EN">',
463 '<!-- document-level comment -->',
464 'document-level text',
465 '<body foo="bar" an-attr="ibute?">world<p>REMOVE ME',
466 '<remove mode="all">inner content</remove>',
467 '<remove mode="keep">inner content</remove>',
468 '<after is-html="true">inner content</after>',
469 '<after is-html="false">inner content</after>',
470 '<append is-html="true">inner content</append>',
471 '<append is-html="false">inner content</append>',
472 '<replace is-html="true">inner content</replace>',
473 '<replace is-html="false">inner content</replace>',
474 '<set-inner-content is-html="true">inner content</set-inner-content>',
475 '<set-inner-content is-html="false">inner content</set-inner-content>',
476 '<set-attribute foo></set-attribute>',
477 '<set-attribute foo=></set-attribute>',
478 '<set-attribute foo=""></set-attribute>',
479 '<!-- REMOVE ME -->',
480 '<!-- REPLACE ME -->',
481 '<!-- SET TEXT PROPERTY -->',
482 '</body>',
483 ];
484 
485 const kResult = `<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01//EN"><!-- document-level comment -->document-level text&lt;1&gt;&lt;2&gt;&lt;3&gt;
486<html><tail an-attr="ibute?" baz="qux">
487&lt;4&gt;&lt;5&gt;&lt;6&gt;
488<em>hello</em>, again, <WORLD>...<p>REMOVE MEinner content<after is-html="true">inner content</after><after><after is-html="false">inner content</after>&lt;after&gt;<append is-html="true">inner content<append></append><append is-html="false">inner content&lt;append&gt;</append><replace>&lt;replace&gt;<set-inner-content is-html="true"><set-inner-content></set-inner-content><set-inner-content is-html="false">&lt;set-inner-content&gt;</set-inner-content><set-attribute foo="bar"></set-attribute><set-attribute foo="bar"></set-attribute><set-attribute foo="bar"></set-attribute><!-- &lt;REPLACED&gt; --><!-- text property has been set --></tail>`;
489 
490 const readable = new ReadableStream({
491 async pull(controller) {
492 await scheduler.wait(1);
493 if (kInput.length > 0) {
494 controller.enqueue(enc.encode(kInput.shift()));
495 } else {
496 controller.close();
497 }
498 },
499 });
500 
501 const response = rewriter.transform(new Response(readable));
502 
503 // At this point, we should not have seen any tokens.
504 strictEqual(elementHandlers.deadTokens.element, undefined);
505 
506 strictEqual(await response.text(), kResult);
507 
508 // Now we've seen tokens
509 notStrictEqual(elementHandlers.deadTokens.element, undefined);
510 
511 // Verify that tokens are invalidated outside handler execution scope.
512 throws(() => documentHandlers.deadTokens.doctype.publicId, {
513 message:
514 'This content token is no longer valid. Content tokens are only ' +
515 'valid during the execution of the relevant content handler.',
516 });
517 throws(() => documentHandlers.deadTokens.comment.text, {
518 message:
519 'This content token is no longer valid. Content tokens are only ' +
520 'valid during the execution of the relevant content handler.',
521 });
522 throws(() => documentHandlers.deadTokens.text.text, {
523 message:
524 'This content token is no longer valid. Content tokens are only ' +
525 'valid during the execution of the relevant content handler.',
526 });
527 throws(() => elementHandlers.deadTokens.element.getAttribute('foo'), {
528 message:
529 'This content token is no longer valid. Content tokens are only ' +
530 'valid during the execution of the relevant content handler.',
531 });
532 throws(() => elementHandlers.deadTokens.attributesIterator.next(), {
533 message:
534 'This content token is no longer valid. Content tokens are only ' +
535 'valid during the execution of the relevant content handler.',
536 });
537 throws(() => elementHandlers.deadTokens.comment.text, {
538 message:
539 'This content token is no longer valid. Content tokens are only ' +
540 'valid during the execution of the relevant content handler.',
541 });
542 throws(() => elementHandlers.deadTokens.text.text, {
543 message:
544 'This content token is no longer valid. Content tokens are only ' +
545 'valid during the execution of the relevant content handler.',
546 });
547 
548 strictEqual(documentHandlers.doctypeCount, 1);
549 strictEqual(documentHandlers.commentCount, 4);
550 strictEqual(documentHandlers.textCount, 26);
551 strictEqual(documentHandlers.reachedEnd, true);
552 strictEqual(elementHandlers.elementCount, 15);
553 strictEqual(elementHandlers.commentCount, 3);
554 strictEqual(elementHandlers.textCount, 24);
555 deepStrictEqual(elementHandlers.expectedErrors, [
556 'Parser error: ` ` character is forbidden in the tag name',
557 ]);
558 },
559};
560 
561export const manualWriting = {
562 async test() {
563 const { readable, writable } = new IdentityTransformStream();
564 
565 const response = new HTMLRewriter()
566 .on('*', {
567 element(element) {
568 element.prepend('foo ');
569 },
570 })
571 .transform(new Response(readable));
572 
573 const writer = writable.getWriter();
574 const encoder = new TextEncoder();
575 
576 // Because this variation uses IdentityTransformStream, we must
577 // initiate the read before doing the writes.
578 const promise = response.text();
579 
580 await writer.write(encoder.encode('<html>'));
581 await writer.write(encoder.encode('bar'));
582 await writer.write(encoder.encode('</html>'));
583 await writer.close();
584 
585 strictEqual(await promise, '<html>foo bar</html>');
586 },
587};
588 
589export const manualWriting2 = {
590 async test() {
591 const { readable, writable } = new TransformStream();
592 
593 const response = new HTMLRewriter()
594 .on('*', {
595 element(element) {
596 element.prepend('foo ');
597 },
598 })
599 .transform(new Response(readable));
600 
601 const writer = writable.getWriter();
602 const encoder = new TextEncoder();
603 
604 await writer.write(encoder.encode('<html>'));
605 await writer.write(encoder.encode('bar'));
606 await writer.write(encoder.encode('</html>'));
607 await writer.close();
608 
609 // This variation uses the JavaScript TransformStream, so we can
610 // initiate the read after doing the writes.
611 const promise = response.text();
612 
613 strictEqual(await promise, '<html>foo bar</html>');
614 },
615};
616 
617export const streamingReplacement = {
618 async test() {
619 const { readable, writable } = new TransformStream();
620 
621 const response = new HTMLRewriter()
622 .on('*', {
623 async element(element) {
624 const dataStream = (
625 await fetch('data:,the quick brown fox jumped over the lazy dog%20')
626 ).body;
627 element.prepend(dataStream, { html: false });
628 },
629 })
630 .transform(new Response(readable));
631 
632 const writer = writable.getWriter();
633 const encoder = new TextEncoder();
634 
635 await writer.write(encoder.encode('<html>'));
636 await writer.write(encoder.encode('bar'));
637 await writer.write(encoder.encode('</html>'));
638 await writer.close();
639 
640 // This variation uses the JavaScript TransformStream, so we can
641 // initiate the read after doing the writes.
642 const promise = response.text();
643 strictEqual(
644 await promise,
645 `<html>the quick brown fox jumped over the lazy dog bar</html>`
646 );
647 },
648};
649 
650export const streamingReplacementHTML = {
651 async test() {
652 const { readable, writable } = new TransformStream();
653 
654 const response = new HTMLRewriter()
655 .on('*', {
656 async element(element) {
657 const dataStream = (
658 await fetch('data:,<b>such markup <i>much wow</i></b> ')
659 ).body;
660 element.prepend(dataStream, { html: true });
661 },
662 })
663 .transform(new Response(readable));
664 
665 const writer = writable.getWriter();
666 const encoder = new TextEncoder();
667 
668 await writer.write(encoder.encode('<html>'));
669 await writer.write(encoder.encode('bar'));
670 await writer.write(encoder.encode('</html>'));
671 await writer.close();
672 
673 // This variation uses the JavaScript TransformStream, so we can
674 // initiate the read after doing the writes.
675 const promise = response.text();
676 strictEqual(
677 await promise,
678 `<html><b>such markup <i>much wow</i></b>bar</html>`
679 );
680 },
681};
682 
683export const streamingReplacementReplace = {
684 async test() {
685 const { readable, writable } = new TransformStream();
686 
687 const response = new HTMLRewriter()
688 .on('.dinosaur', {
689 async element(element) {
690 const dataStream = (await fetch('data:,goodbye world')).body;
691 element.replace(dataStream, { html: false });
692 },
693 })
694 .transform(new Response(readable));
695 
696 const writer = writable.getWriter();
697 const encoder = new TextEncoder();
698 
699 await writer.write(encoder.encode('<html>'));
700 await writer.write(
701 encoder.encode('<div class="dinosaur">hello world</div>')
702 );
703 await writer.write(encoder.encode('</html>'));
704 await writer.close();
705 
706 // This variation uses the JavaScript TransformStream, so we can
707 // initiate the read after doing the writes.
708 const promise = response.text();
709 strictEqual(await promise, `<html>goodbye world</html>`);
710 },
711};
712 
713export const streamingReplacementMultiple = {
714 async test() {
715 const { readable, writable } = new TransformStream();
716 
717 const response = new HTMLRewriter()
718 .on('*', {
719 async element(element) {
720 element.prepend(await fetch('data:,alpha%20'));
721 element.append(await fetch('data:,%20gamma'));
722 },
723 })
724 .transform(new Response(readable));
725 
726 const writer = writable.getWriter();
727 const encoder = new TextEncoder();
728 
729 await writer.write(encoder.encode('<html>'));
730 await writer.write(encoder.encode('beta'));
731 await writer.write(encoder.encode('</html>'));
732 await writer.close();
733 
734 // This variation uses the JavaScript TransformStream, so we can
735 // initiate the read after doing the writes.
736 const promise = response.text();
737 strictEqual(await promise, `<html>alpha beta gamma</html>`);
738 },
739};
740 
741export const streamingReplacementBadUTF8 = {
742 async test() {
743 const { readable, writable } = new TransformStream();
744 
745 const response = new HTMLRewriter()
746 .on('*', {
747 async element(element) {
748 element.prepend(await fetch('data:,garbage%e2%28%a1'));
749 },
750 })
751 .transform(new Response(readable));
752 
753 const writer = writable.getWriter();
754 const encoder = new TextEncoder();
755 
756 await writer.write(encoder.encode('<html>'));
757 await writer.write(encoder.encode('bar'));
758 await writer.write(encoder.encode('</html>'));
759 await writer.close();
760 
761 // This variation uses the JavaScript TransformStream, so we can
762 // initiate the read after doing the writes.
763 await rejects(response.text(), { message: 'Parser error: Invalid UTF-8' });
764 },
765};
766 
767export const appendOnEnd = {
768 async test() {
769 const kInput =
770 '<!doctype html><html><head></head><body><!----></body></html>';
771 const kSuffix = '<!-- an end comment -->';
772 const result = new HTMLRewriter()
773 .onDocument({
774 end(end) {
775 end.append(kSuffix, { html: true });
776 },
777 })
778 .transform(new Response(kInput));
779 strictEqual(await result.text(), kInput + kSuffix);
780 },
781};
782 
783export const interleavedAsyncHandlers = {
784 async test() {
785 class OneTimeBarrier {
786 constructor(limit) {
787 this.limit = limit;
788 this.current = 0;
789 let resolve;
790 this.promise = new Promise((r) => (resolve = r));
791 this.resolver = resolve;
792 }
793 
794 async wait() {
795 this.current += 1;
796 if (this.current >= this.limit) {
797 this.resolver();
798 } else {
799 await this.promise;
800 }
801 }
802 }
803 
804 const barrier = new OneTimeBarrier(2);
805 const responses = await Promise.all([
806 new HTMLRewriter()
807 .on('body', {
808 async element(e) {
809 await barrier.wait();
810 e.setInnerContent('foo bar');
811 },
812 })
813 .transform(new Response('<body>body value</body>'))
814 .text(),
815 new HTMLRewriter()
816 .on('body', {
817 async element(e) {
818 await barrier.wait();
819 e.remove();
820 },
821 })
822 .transform(
823 new Response('<!doctype html><html><head></head><body></body></html>')
824 )
825 .arrayBuffer(),
826 ]);
827 
828 const body = responses[0];
829 const blank = responses[1];
830 
831 const inserted = new HTMLRewriter()
832 .on('html', {
833 element(e) {
834 e.append(body, { html: true });
835 },
836 })
837 .transform(new Response(blank));
838 
839 strictEqual(
840 await inserted.text(),
841 '<!doctype html><html><head></head><body>foo bar</body></html>'
842 );
843 },
844};
845 
846export const exceptionInHandler = {
847 async test() {
848 const response = new HTMLRewriter()
849 .on('*', {
850 text() {
851 throw new Error('boom');
852 },
853 })
854 .transform(new Response('<body>hello</body>'));
855 
856 await rejects(response.text(), {
857 message: 'boom',
858 });
859 },
860};
861 
862export const exceptionInAsyncHandler = {
863 async test() {
864 const response = new HTMLRewriter()
865 .on('*', {
866 async text() {
867 throw new Error('boom');
868 },
869 })
870 .transform(new Response('<body>hello</body>'));
871 
872 await rejects(response.text(), {
873 message: 'boom',
874 });
875 },
876};
877 
878export const invalidEncoding = {
879 async test() {
880 const response = new Response('hello', {
881 headers: {
882 'content-type': 'text/html; charset=invalid',
883 },
884 });
885 
886 throws(
887 () => {
888 new HTMLRewriter().on('*', {}).transform(response);
889 },
890 {
891 message: 'Parser error: Unknown character encoding has been provided.',
892 }
893 );
894 },
895};
896 
897export const exceptionPropagation = {
898 async test() {
899 const { readable, writable } = new IdentityTransformStream();
900 const response = new HTMLRewriter().transform(new Response(readable));
901 response.body.cancel(new Error('boom'));
902 
903 const writer = writable.getWriter();
904 
905 const enc = new TextEncoder();
906 
907 await writer.write(enc.encode('test'));
908 
909 rejects(writer.write(enc.encode('test')), {
910 message: 'boom',
911 });
912 },
913};
914 
915// handled HTMLRewriter errors must not poison the IoContext.
916export const continueAfterException = {
917 async test() {
918 const errorResponse = new HTMLRewriter()
919 .on('*', {
920 element() {
921 throw new Error('intentional error for testing');
922 },
923 })
924 .transform(new Response('<div>test</div>'));
925 
926 await rejects(errorResponse.text(), {
927 message: 'intentional error for testing',
928 });
929 
930 const successResponse = new HTMLRewriter()
931 .on('div', {
932 element(el) {
933 el.setInnerContent('success');
934 },
935 })
936 .transform(new Response('<div>original</div>'));
937 
938 strictEqual(await successResponse.text(), '<div>success</div>');
939 },
940};
941 
942export const continueAfterException2 = {
943 async test() {
944 const errorResponse = new HTMLRewriter()
945 .on('*', {
946 element() {
947 throw new Error('intentional error for pipeTo testing');
948 },
949 })
950 .transform(new Response('<div>test</div>'));
951 
952 const { writable } = new TransformStream();
953 
954 await rejects(errorResponse.body.pipeTo(writable), {
955 message: 'intentional error for pipeTo testing',
956 });
957 
958 const successResponse = new HTMLRewriter()
959 .on('div', {
960 element(el) {
961 el.setInnerContent('success after pipeTo');
962 },
963 })
964 .transform(new Response('<div>original</div>'));
965 
966 strictEqual(
967 await successResponse.text(),
968 '<div>success after pipeTo</div>'
969 );
970 },
971};
972 
973export const sameToken = {
974 async test() {
975 const obj = {};
976 let element;
977 
978 const r = new HTMLRewriter()
979 .on('*', {
980 element(e) {
981 element = e;
982 strictEqual(e.hi, undefined);
983 
984 e.hi = 'test';
985 strictEqual(e.hi, 'test');
986 
987 e.hi = 'hi';
988 e.obj = obj;
989 
990 e.replace('foo');
991 },
992 })
993 .on('img', {
994 element(e) {
995 notStrictEqual(e, element);
996 notStrictEqual(e.hi, 'hi');
997 notStrictEqual(e.obj, obj);
998 
999 // The HTMLRewriter creates a fresh new Element/Doctype/Text
1000 // object for each handler, thus `e.hi` will yield undefined even if we
1001 // assigned it in the first handler.
1002 // See https://jira.cfdata.org/browse/EW-2200.
1003 e.replace(e.hi);
1004 },
1005 })
1006 .transform(new Response('<img />'))
1007 .text();
1008 
1009 await r;
1010 },
1011};
1012 
1013// Regression test for VULN-122672: HTMLRewriter AttributesIterator UAF.
1014// When element attributes are modified during iteration (adding new attributes
1015// that cause the underlying Vec to reallocate), the iterator's stale pointers
1016// would read from freed memory. The fix invalidates all live iterators when
1017// setAttribute() or removeAttribute() is called.
1018export const attributesIteratorInvalidatedOnSetAttribute = {
1019 async test() {
1020 const html = `<div a="1" b="2" c="3">test</div>`;
1021 
1022 const rewriter = new HTMLRewriter().on('div', {
1023 element(el) {
1024 const iter = el.attributes[Symbol.iterator]();
1025 
1026 // Read first attribute - valid.
1027 const first = iter.next();
1028 strictEqual(first.done, false);
1029 
1030 // Mutate attributes — this must invalidate the iterator.
1031 el.setAttribute('newattr', 'value');
1032 
1033 // Subsequent next() must throw, not read freed memory.
1034 throws(() => iter.next(), {
1035 message:
1036 'The attributes of this element have been modified during iteration. ' +
1037 'You must create a new iterator after modifying attributes.',
1038 });
1039 },
1040 });
1041 
1042 await rewriter.transform(new Response(html)).text();
1043 },
1044};
1045 
1046export const attributesIteratorInvalidatedOnRemoveAttribute = {
1047 async test() {
1048 const html = `<div a="1" b="2" c="3">test</div>`;
1049 
1050 const rewriter = new HTMLRewriter().on('div', {
1051 element(el) {
1052 const iter = el.attributes[Symbol.iterator]();
1053 iter.next(); // consume first
1054 
1055 // removeAttribute also must invalidate the iterator.
1056 el.removeAttribute('b');
1057 
1058 throws(() => iter.next(), {
1059 message:
1060 'The attributes of this element have been modified during iteration. ' +
1061 'You must create a new iterator after modifying attributes.',
1062 });
1063 },
1064 });
1065 
1066 await rewriter.transform(new Response(html)).text();
1067 },
1068};
1069 
1070// After mutation, creating a fresh iterator must work normally.
1071export const attributesIteratorFreshAfterMutation = {
1072 async test() {
1073 const html = `<div a="1">test</div>`;
1074 let attrs = [];
1075 
1076 const rewriter = new HTMLRewriter().on('div', {
1077 element(el) {
1078 el.setAttribute('b', '2');
1079 
1080 // A new iterator created after mutation should work fine.
1081 for (const [name, value] of el.attributes) {
1082 attrs.push([name, value]);
1083 }
1084 },
1085 });
1086 
1087 await rewriter.transform(new Response(html)).text();
1088 
1089 // Should see both original and newly-set attribute.
1090 strictEqual(attrs.length, 2);
1091 deepStrictEqual(attrs[0], ['a', '1']);
1092 deepStrictEqual(attrs[1], ['b', '2']);
1093 },
1094};
1095 
1096// Iterating without mutation must still work as before.
1097export const attributesIteratorNormalIteration = {
1098 async test() {
1099 const html = `<div x="1" y="2" z="3">test</div>`;
1100 let attrs = [];
1101 
1102 const rewriter = new HTMLRewriter().on('div', {
1103 element(el) {
1104 for (const [name, value] of el.attributes) {
1105 attrs.push([name, value]);
1106 }
1107 },
1108 });
1109 
1110 await rewriter.transform(new Response(html)).text();
1111 
1112 deepStrictEqual(attrs, [
1113 ['x', '1'],
1114 ['y', '2'],
1115 ['z', '3'],
1116 ]);
1117 },
1118};
1119 
1120export const svgNamespace = {
1121 async test() {
1122 const response = new Response(`
1123 <svg><a /></svg>
1124 `);
1125 let namespace;
1126 
1127 await new HTMLRewriter()
1128 .on('a', {
1129 element(e) {
1130 namespace = e.namespaceURI;
1131 },
1132 })
1133 .transform(response)
1134 .text();
1135 
1136 strictEqual(namespace, 'http://www.w3.org/2000/svg');
1137 },
1138};
1139 
1140export const handlerPerformingManySmallWrites = {
1141 async test() {
1142 const promiseDepth = 128 * 100;
1143 const numReads = 2;
1144 const input = new Response('<div></div>');
1145 
1146 const output = new HTMLRewriter()
1147 .onDocument({
1148 end(e) {
1149 for (let i = 0; i < promiseDepth; i++) {
1150 e.append('<div></div>', { html: true });
1151 }
1152 },
1153 })
1154 .transform(input);
1155 
1156 // Begin reading but do not fully consume
1157 const reader = output.body.getReader();
1158 for (let i = 0; i < numReads; i++) {
1159 await reader.read();
1160 }
1161 },
1162};
1163 
1164export const hugeNumberOfHandlersForAnElement = {
1165 // Many small handlers
1166 async test() {
1167 const promiseDepth = 128 * 100;
1168 const numReads = promiseDepth;
1169 
1170 const input = new Response('<div></div>'.repeat(promiseDepth));
1171 
1172 const output = new HTMLRewriter()
1173 .on('div', {
1174 element(e) {
1175 e.append('<div></div>', { html: true });
1176 },
1177 })
1178 .transform(input);
1179 
1180 // Begin reading but do not fully consume
1181 const reader = output.body.getReader();
1182 for (let i = 0; i < numReads; i++) {
1183 await reader.read();
1184 }
1185 },
1186};
1187 
1188// Test HTMLRewriter with JS-backed ReadableStream
1189// This test was moved from streams-respond-test.js because it triggers
1190// a flaky ASAN failure related to V8's cppgc memory validation.
1191export const htmlRewriterStream = {
1192 async test() {
1193 const enc = new TextEncoder();
1194 
1195 const readable = new ReadableStream({
1196 pull(controller) {
1197 controller.enqueue(enc.encode('Hello World!'));
1198 controller.close();
1199 },
1200 });
1201 
1202 let response = new Response(readable, {
1203 status: 200,
1204 headers: {
1205 'Content-Type': 'text/html; charset=utf-8',
1206 },
1207 });
1208 
1209 response = new HTMLRewriter().transform(response);
1210 
1211 strictEqual(await response.text(), 'Hello World!');
1212 },
1213};