char/avril

(wip) why'd you have to go and make things so complicaaaaated?

git clone https://git.t4t.associates/char/avril

Charlotte Somtests: remove flaky eventsource testa7f08a1

main
33.4 KiB890 linesraw
1mod common;
2
3use std::io::{self, Read, Write};
4use std::net::{TcpListener, TcpStream};
5use std::sync::mpsc;
6use std::thread::JoinHandle;
7use std::time::{Duration, Instant};
8
9struct Request {
10    path: String,
11    headers: String,
12}
13
14fn request(stream: &mut TcpStream) -> io::Result<Request> {
15    stream
16        .set_read_timeout(Some(Duration::from_secs(5)))
17        .unwrap();
18    let mut buffer = Vec::new();
19    let mut byte = [0; 1];
20    loop {
21        match stream.read(&mut byte) {
22            Ok(0) | Err(_) => break,
23            Ok(_) => {
24                buffer.push(byte[0]);
25                if buffer.ends_with(b"\r\n\r\n") {
26                    break;
27                }
28            }
29        }
30    }
31    let request = String::from_utf8_lossy(&buffer);
32    // Header names are matched case-insensitively; values are not.
33    let lines = request
34        .lines()
35        .map(|line| match line.split_once(':') {
36            Some((name, value)) => format!("{}:{value}", name.to_ascii_lowercase()),
37            None => line.to_owned(),
38        })
39        .collect::<Vec<_>>();
40    let first = lines.first().map(String::as_str).unwrap_or_default();
41    let mut words = first.split_whitespace();
42    match words.next() {
43        None => return Err(io::ErrorKind::UnexpectedEof.into()),
44        method => assert_eq!(method, Some("GET")),
45    }
46    Ok(Request {
47        path: words.next().unwrap_or_default().to_owned(),
48        headers: lines[1..].join("\n"),
49    })
50}
51
52fn respond(stream: &mut TcpStream, status: &str, headers: &str, body: &[u8]) -> io::Result<()> {
53    let mut response = format!("HTTP/1.1 {status}\r\n{headers}\r\n").into_bytes();
54    response.extend_from_slice(body);
55    stream.write_all(&response)
56}
57
58/// Accept `count` connections; each handler receives the request and may
59/// stream the body in stages before returning, which ends the connection.
60fn server(
61    count: usize,
62    handler: impl Fn(Request, &mut TcpStream) -> io::Result<()> + Send + 'static,
63) -> (String, JoinHandle<()>) {
64    let listener = TcpListener::bind("127.0.0.1:0").unwrap();
65    let address = listener.local_addr().unwrap().to_string();
66    listener.set_nonblocking(true).unwrap();
67    let thread = std::thread::spawn(move || {
68        let deadline = Instant::now() + Duration::from_secs(5);
69        let mut remaining = count;
70        while remaining != 0 {
71            let (mut socket, _) = loop {
72                match listener.accept() {
73                    Ok(connection) => break connection,
74                    Err(error) if error.kind() == io::ErrorKind::WouldBlock => {
75                        assert!(Instant::now() < deadline, "Missing EventSource request");
76                        std::thread::sleep(Duration::from_millis(5));
77                    }
78                    Err(error) => panic!("{error}"),
79                }
80            };
81            let Ok(request) = request(&mut socket) else {
82                continue;
83            };
84            remaining -= 1;
85            if handler(request, &mut socket).is_err() {
86                continue;
87            }
88        }
89    });
90    (format!("http://{address}"), thread)
91}
92
93fn event_stream_headers() -> &'static str {
94    "Content-Type: text/event-stream\r\nConnection: close\r\n"
95}
96
97/// Stream `chunks` with pauses so chunk boundaries land where the test
98/// intends; coalesced reads parse identically either way.
99fn stream_chunks(stream: &mut TcpStream, chunks: &[&[u8]]) -> io::Result<()> {
100    respond(stream, "200 OK", event_stream_headers(), b"")?;
101    for chunk in chunks {
102        stream.write_all(chunk)?;
103        stream.flush()?;
104        std::thread::sleep(Duration::from_millis(30));
105    }
106    Ok(())
107}
108
109fn header(headers: &str, name: &str) -> Option<String> {
110    headers.lines().find_map(|line| {
111        let (field, value) = line.split_once(':')?;
112        (field.trim() == name).then(|| value.trim().to_owned())
113    })
114}
115
116#[test]
117fn user_agent_defaults_follow_navigator_on_connect_and_reconnect() {
118    let (url, thread) = server(3, |request, stream| {
119        let ua = header(&request.headers, "user-agent").expect("Missing User-Agent");
120        respond(
121            stream,
122            "200 OK",
123            event_stream_headers(),
124            format!("retry: 0\ndata: {ua}\n\n").as_bytes(),
125        )
126    });
127    let source = r#"
128        const original = navigator.userAgent;
129        await new Promise((resolve, reject) => {
130            const source = new EventSource('URL/default');
131            source.onmessage = event => {
132                source.close();
133                if (event.data !== original) reject(Error('Initial UA'));
134                else resolve();
135            };
136            source.onerror = () => {source.close(); reject(Error('Initial connection failed'));};
137        });
138        navigator.userAgent = 'custom/1.0';
139        await new Promise((resolve, reject) => {
140            const source = new EventSource('URL/custom');
141            let count = 0;
142            source.onmessage = event => {
143                const expected = count++ === 0 ? 'custom/1.0' : 'reconnected/2.0';
144                if (event.data !== expected) {
145                    source.close();
146                    reject(Error('Expected UA ' + expected + ', got ' + event.data));
147                } else if (count === 1) {
148                    navigator.userAgent = 'reconnected/2.0';
149                } else {
150                    source.close();
151                    resolve();
152                }
153            };
154        });
155        console.log('EventSource UA');
156    "#
157    .replace("URL", &url);
158    assert_eq!(common::run(&source, &[]).stdout, b"EventSource UA\n");
159    thread.join().unwrap();
160}
161
162#[test]
163fn surface_brands_coercion_and_clone_rejection() {
164    let out = common::run(
165        r#"
166        const original = 'HTTP://Example.com/plain%20path?query=1#frag';
167        const source = new EventSource(original);
168        if (source.url !== 'http://example.com/plain%20path?query=1#frag') throw Error('URL serialization');
169        if (source.readyState !== 0 || source.withCredentials !== false) throw Error('Initial attributes');
170        const credentialed = new EventSource('http://127.0.0.1:1/cred', {withCredentials: true});
171        if (credentialed.withCredentials !== true) throw Error('withCredentials option');
172        const falsy = new EventSource('http://127.0.0.1:1/falsy', {withCredentials: 0});
173        if (falsy.withCredentials !== false) throw Error('Falsy withCredentials');
174        const nulled = new EventSource('http://127.0.0.1:1/n', null);
175        if (nulled.withCredentials !== false || nulled.readyState !== 0) throw Error('Null options');
176        for (const constant of ['CONNECTING', 'OPEN', 'CLOSED']) {
177            const descriptor = Object.getOwnPropertyDescriptor(EventSource.prototype, constant);
178            if (!descriptor.enumerable || descriptor.writable || descriptor.get) throw Error('Prototype constant ' + constant);
179            const staticDescriptor = Object.getOwnPropertyDescriptor(EventSource, constant);
180            if (!staticDescriptor.enumerable || staticDescriptor.writable) throw Error('Static constant ' + constant);
181            if (source[constant] !== staticDescriptor.value) throw Error('Constant value ' + constant);
182        }
183        if (Object.prototype.toString.call(source) !== '[object EventSource]') throw Error('toStringTag');
184        class Sub extends EventSource {}
185        const sub = new Sub('http://127.0.0.1:1/sub');
186        if (!(sub instanceof Sub) || !(sub instanceof EventSource) || !(sub instanceof EventTarget)) throw Error('Subclass');
187        if (sub.readyState !== 0 || sub.withCredentials !== false) throw Error('Subclass attributes');
188        if (source.onopen !== null || source.onmessage !== null || source.onerror !== null) throw Error('Default handlers');
189        const illegal = (operation) => {
190            try {
191                operation();
192                throw Error('Accepted illegal invocation');
193            } catch (error) {
194                if (!(error instanceof TypeError)) throw error;
195            }
196        };
197        illegal(() => Object.getOwnPropertyDescriptor(EventSource.prototype, 'readyState').get.call({}));
198        illegal(() => EventSource.prototype.close.call({}));
199        illegal(() => Object.getOwnPropertyDescriptor(EventSource.prototype, 'url').get.call(new EventTarget()));
200        illegal(() => Object.getOwnPropertyDescriptor(EventSource.prototype, 'withCredentials').get.call({}));
201        for (const bad of [() => new EventSource(), () => new EventSource(Symbol()), () => new EventSource('http://127.0.0.1:1/x', Symbol())]) {
202            try {
203                bad();
204                throw Error('Accepted invalid constructor argument');
205            } catch (error) {
206                if (!(error instanceof TypeError)) throw error;
207            }
208        }
209        const syntax = (operation) => {
210            try {
211                operation();
212                throw Error('Accepted invalid URL');
213            } catch (error) {
214                if (!(error instanceof DOMException) || error.name !== 'SyntaxError') throw error;
215            }
216        };
217        syntax(() => new EventSource('http://['));
218        const relative = new EventSource('./events');
219        if (relative.url !== new URL('./events', import.meta.url).href) throw Error('Relative URL');
220        relative.close();
221        const marker = {};
222        try {
223            new EventSource({toString() { throw marker; }});
224            throw Error('Swallowed coercion failure');
225        } catch (error) {
226            if (error !== marker) throw error;
227        }
228        for (const clone of [
229            () => structuredClone(source),
230            () => new MessageChannel().port1.postMessage(source),
231        ]) {
232            try {
233                clone();
234                throw Error('Accepted a clone');
235            } catch (error) {
236                if (!(error instanceof DOMException) || error.name !== 'DataCloneError') throw error;
237            }
238        }
239        credentialed.close();
240        falsy.close();
241        nulled.close();
242        sub.close();
243        source.close();
244        if (source.readyState !== 2) throw Error('Closed ready state');
245        source.close();
246        if (source.readyState !== 2) throw Error('Second close');
247        console.log('surface ok');
248    "#,
249        &[],
250    );
251    assert_eq!(out.stdout, b"surface ok\n");
252}
253
254#[test]
255fn streaming_line_endings_chunk_boundaries_and_fields() {
256    let (url, thread) = server(1, |_, stream| {
257        stream_chunks(
258            stream,
259            &[
260                // CR terminated comment split from its line terminator.
261                b": comment\r",
262                b"\ndata: first\n",
263                b"data: second\r\n",
264                b"data:third\n",
265                b"data:  spaced\n",
266                b"event: custom\n",
267                b"\n",
268                // An incomplete event that the stream end must discard.
269                b"data: dropped",
270            ],
271        )
272    });
273    let out = common::run(
274        &format!(
275            r#"
276        const log = [];
277        const es = new EventSource('{url}');
278        es.addEventListener('custom', (event) => {{
279            log.push(`custom:${{JSON.stringify(event.data)}}:${{event.lastEventId}}`);
280        }});
281        es.addEventListener('message', () => log.push('unexpected message'));
282        es.addEventListener('error', () => {{
283            if (es.readyState !== 0) throw Error('Reconnecting ready state');
284            es.close();
285            console.log(log.join(' | '));
286        }});
287    "#
288        )
289        .replace("{url}", &url),
290        &[],
291    );
292    assert_eq!(
293        out.stdout,
294        "custom:\"first\\nsecond\\nthird\\n spaced\":\n".as_bytes()
295    );
296    thread.join().unwrap();
297}
298
299#[test]
300fn byte_order_marks_utf8_splits_and_field_edge_cases() {
301    let (url, thread) = server(3, |request, stream| {
302        match request.path.as_str() {
303            "/bom" => stream_chunks(
304                stream,
305                &[
306                    // The BOM must be stripped exactly once even when split.
307                    b"\xef",
308                    b"\xbb\xbfdata: bom\n\n",
309                    // A second BOM becomes U+FEFF content.
310                    b"data: \xef\xbb\xbfkept\n\n",
311                    // A UTF-8 sequence split across chunk boundaries.
312                    b"id: h\xc3",
313                    b"\xa9llo\r\ndata: caf\xc3",
314                    b"\xa9\n\n",
315                ],
316            ),
317            "/ids" => stream_chunks(
318                stream,
319                &[
320                    b"id: a\n\ndata: one\n\n",
321                    // NUL rejects the id; the buffer is unchanged.
322                    b"id: b\x00\ndata: two\n\n",
323                    // A blank line commits the id buffer without data.
324                    b"id: c\n\n",
325                    // An empty id resets it for the next commit.
326                    b"id:\n\ndata: three\n\n",
327                ],
328            ),
329            _ => stream_chunks(
330                stream,
331                &[
332                    // Field names are case-sensitive and unknown fields are ignored.
333                    b"DATA: upper\neVENT: mixed\nrandom: value\n",
334                    b"event: one\nevent: two\ndata: replaced\n\n",
335                    // The type buffer resets after a dispatch.
336                    b"data: back to message\n\n",
337                    // Empty data values still dispatch.
338                    b"data:\n\n",
339                    b"data: \n\n",
340                    // retry only accepts plain ASCII digits.
341                    b"retry: +5\nretry: 1 2\nretry: 0x10\nretry: \nretry: -1\n",
342                    b"data: after retry\n\n",
343                ],
344            ),
345        }
346    });
347    let out = common::run(
348        &format!(
349            r#"
350        const log = [];
351        const events = (source, types) => {{
352            for (const type of types) {{
353                if (type === 'error') continue;
354                source.addEventListener(type, (event) => {{
355                    log.push(`${{type}}:${{JSON.stringify(event.data)}}:${{JSON.stringify(event.lastEventId)}}`);
356                }});
357            }}
358        }};
359        function next(path) {{
360            const source = new EventSource('{url}/' + path);
361            events(source, path === 'cases' ? ['message', 'two', 'error'] : ['message', 'error']);
362            source.addEventListener('error', () => {{
363                source.close();
364                if (path === 'bom') next('ids');
365                else if (path === 'ids') next('cases');
366                else console.log(log.join(' | '));
367            }});
368        }}
369        next('bom');
370    "#
371        )
372        .replace("{url}", &url),
373        &[],
374    );
375    let expected = "message:\"bom\":\"\" | message:\"\u{feff}kept\":\"\" | message:\"café\":\"héllo\" \
376         | message:\"one\":\"a\" | message:\"two\":\"a\" | message:\"three\":\"\" \
377         | two:\"replaced\":\"\" | message:\"back to message\":\"\" | message:\"\":\"\" \
378         | message:\"\":\"\" | message:\"after retry\":\"\"\n";
379    assert_eq!(out.stdout, expected.as_bytes());
380    thread.join().unwrap();
381}
382
383#[test]
384fn eof_discards_incomplete_event_without_dispatching() {
385    let (url, thread) = server(1, |_, stream| {
386        respond(
387            stream,
388            "200 OK",
389            event_stream_headers(),
390            b"id: lost\ndata: incomplete\nevent: never",
391        )
392    });
393    let out = common::run(
394        &format!(
395            r#"
396        const log = [];
397        const es = new EventSource('{url}');
398        es.addEventListener('message', () => log.push('message'));
399        es.addEventListener('open', () => log.push('open'));
400        es.addEventListener('error', () => {{
401            if (es.readyState !== 0) throw Error('Ready state after EOF');
402            log.push('error');
403            es.close();
404            console.log(log.join(' | '));
405        }});
406    "#
407        )
408        .replace("{url}", &url),
409        &[],
410    );
411    assert_eq!(out.stdout, b"open | error\n");
412    thread.join().unwrap();
413}
414
415#[test]
416fn events_run_in_tasks_and_close_suppresses_queued_messages() {
417    let (url, thread) = server(1, |_, stream| {
418        respond(
419            stream,
420            "200 OK",
421            event_stream_headers(),
422            b"data: one\n\ndata: two\n\ndata: three\n\n",
423        )
424    });
425    let out = common::run(
426        &format!(
427            r#"
428        const log = [];
429        queueMicrotask(() => log.push('microtask'));
430        Promise.resolve().then(() => log.push('promise'));
431        let closed = false;
432        const es = new EventSource('{url}');
433        es.onmessage = (event) => {{
434            log.push(`message:${{event.data}}`);
435            if (!closed) {{
436                closed = true;
437                es.close();
438                if (es.readyState !== 2) throw Error('Ready state inside handler');
439            }}
440        }};
441        setTimeout(() => console.log(log.join(' | ')), 300);
442    "#
443        )
444        .replace("{url}", &url),
445        &[],
446    );
447    assert_eq!(out.stdout, b"microtask | promise | message:one\n");
448    thread.join().unwrap();
449}
450
451#[test]
452fn reconnect_honors_retry_and_sends_last_event_id() {
453    let (times_sender, times) = mpsc::channel();
454    let (received_sender, received) = mpsc::channel();
455    let (url, thread) = {
456        let times = times_sender.clone();
457        let received = received_sender.clone();
458        server(2, move |request, stream| {
459            times.send(Instant::now()).unwrap();
460            let id = header(&request.headers, "last-event-id");
461            received.send(id.clone()).unwrap();
462            if id.is_none() {
463                stream_chunks(
464                    stream,
465                    &[b"retry: 100\r\nid: \xf0\x9f\x92\xa9\r\ndata: hello\r\n\r\n"],
466                )
467            } else {
468                stream_chunks(stream, &[b"data: again\r\n\r\n"])
469            }
470        })
471    };
472    let out = common::run(
473        &format!(
474            r#"
475        const log = [];
476        const es = new EventSource('{url}');
477        es.addEventListener('message', (event) => {{
478            if (event.origin !== '{url}') throw Error('Origin: ' + event.origin);
479            log.push(`message:${{event.data}}:${{event.lastEventId}}`);
480            if (event.data === 'again') es.close();
481        }});
482        es.addEventListener('error', () => {{
483            if (es.readyState !== 0) throw Error('Ready state while waiting to reconnect');
484            log.push('error');
485        }});
486        es.addEventListener('open', () => {{
487            log.push(`open:${{es.readyState}}`);
488        }});
489        setTimeout(() => console.log(log.join(' | ')), 1500);
490    "#
491        )
492        .replace("{url}", &url),
493        &[],
494    );
495    assert_eq!(
496        out.stdout,
497        "open:1 | message:hello:\u{1f4a9} | error | open:1 | message:again:\u{1f4a9}\n".as_bytes(),
498    );
499    thread.join().unwrap();
500    assert!(
501        received.recv().unwrap().is_none(),
502        "the first request must not carry Last-Event-ID"
503    );
504    assert_eq!(
505        received.recv().unwrap().unwrap(),
506        "\u{1f4a9}",
507        "the reconnect must send the raw UTF-8 Last-Event-ID"
508    );
509    let first = times.recv().unwrap();
510    let second = times.recv().unwrap();
511    let elapsed = second.duration_since(first);
512    assert!(
513        elapsed >= Duration::from_millis(100),
514        "reconnected after {elapsed:?}"
515    );
516    assert!(
517        elapsed < Duration::from_millis(2500),
518        "reconnection took {elapsed:?}"
519    );
520}
521
522#[test]
523fn non_200_bad_mime_and_204_terminate_without_retry() {
524    let (url, thread) = server(3, |request, stream| match request.path.as_str() {
525        "/status" => respond(stream, "404 Not Found", "Connection: close\r\n", b""),
526        "/mime" => respond(
527            stream,
528            "200 OK",
529            "Content-Type: text/plain\r\nConnection: close\r\n",
530            b"data: rejected\n\n",
531        ),
532        _ => respond(stream, "204 No Content", "Connection: close\r\n", b""),
533    });
534    let out = common::run(
535        &format!(
536            r#"
537        const log = [];
538        for (const path of ['status', 'mime', 'empty']) {{
539            await new Promise((resolve) => {{
540                const es = new EventSource('{url}/' + path);
541                es.addEventListener('open', () => log.push(`${{path}}:open`));
542                es.addEventListener('message', () => log.push(`${{path}}:message`));
543                es.addEventListener('error', (event) => {{
544                    if (es.readyState !== 2) throw Error('Ready state after terminal failure');
545                    if (!(event instanceof Event) || event instanceof ErrorEvent) throw Error('Error event interface');
546                    log.push(`${{path}}:error`);
547                    resolve();
548                }});
549            }});
550        }}
551        console.log(log.join(' | '));
552    "#
553        )
554        .replace("{url}", &url),
555        &[],
556    );
557    assert_eq!(out.stdout, b"status:error | mime:error | empty:error\n");
558    thread.join().unwrap();
559}
560
561#[test]
562fn redirects_keep_the_original_url_and_use_the_final_origin() {
563    let (target_url, target_thread) = server(1, |_, stream| {
564        respond(
565            stream,
566            "200 OK",
567            event_stream_headers(),
568            b"data: redirected\n\n",
569        )
570    });
571    let (redirect_url, redirect_thread) = {
572        let target_url = target_url.clone();
573        server(1, move |_, stream| {
574            respond(
575                stream,
576                "302 Found",
577                &format!("Location: {target_url}\r\nConnection: close\r\n"),
578                b"",
579            )
580        })
581    };
582    let out = common::run(
583        &format!(
584            r#"
585        const es = new EventSource('{redirect_url}');
586        if (es.url !== '{redirect_url}/') throw Error('Original URL changed');
587        es.addEventListener('message', (event) => {{
588            if (event.origin !== '{target_url}') throw Error('Final origin: ' + event.origin);
589            if (es.url !== '{redirect_url}/') throw Error('URL after redirect');
590            if (event.data !== 'redirected') throw Error('Redirected data');
591            es.close();
592            console.log('redirect ok');
593        }});
594        es.addEventListener('error', (event) => {{
595            throw Error('Unexpected error: ' + event.message);
596        }});
597    "#
598        )
599        .replace("{redirect_url}", &redirect_url)
600        .replace("{target_url}", &target_url),
601        &[],
602    );
603    assert_eq!(out.stdout, b"redirect ok\n");
604    redirect_thread.join().unwrap();
605    target_thread.join().unwrap();
606}
607
608#[test]
609fn net_permission_failures_are_asynchronous() {
610    let out = common::run(
611        r#"
612        import { pledge } from 'avril:std';
613        pledge({ net: false });
614        const log = [];
615        const es = new EventSource('http://127.0.0.1:1/');
616        if (es.readyState !== 0) throw Error('Synchronous failure');
617        log.push('connecting');
618        es.addEventListener('error', (event) => {
619            if (es.readyState !== 0) throw Error('Ready state after denied connection');
620            if (event instanceof ErrorEvent) throw Error('Wrong error event');
621            log.push('denied');
622            es.close();
623            console.log(log.join(' | '));
624        });
625    "#,
626        &[],
627    );
628    assert_eq!(out.stdout, b"connecting | denied\n");
629}
630
631#[test]
632fn call_time_primordials_survive_guest_monkeypatching() {
633    let (url, thread) = server(1, |_, stream| {
634        stream_chunks(
635            stream,
636            &[b"event: hello\ndata: bonjour\nid: id\xf0\x9f\x92\xa9x\n\n"],
637        )
638    });
639    let out = common::run(
640        &format!(
641            r#"
642        const poison = () => {{ throw Error('Guest replacement invoked'); }};
643        Math.floor = poison;
644        Math.min = poison;
645        Number.parseInt = poison;
646        Promise.prototype.then = poison;
647        Object.defineProperty(Promise.prototype, 'constructor', {{ get: poison }});
648        Object.defineProperty(String.prototype, 'indexOf', {{ value: poison }});
649        Object.defineProperty(String.prototype, 'charCodeAt', {{ value: poison }});
650        Object.defineProperty(String.prototype, 'slice', {{ value: poison }});
651        Object.defineProperty(String.prototype, 'toLowerCase', {{ value: poison }});
652        RegExp.prototype.exec = poison;
653        Object.defineProperty(URL.prototype, 'href', {{ get: poison }});
654        Object.defineProperty(URL.prototype, 'origin', {{ get: poison }});
655        Object.defineProperty(URL.prototype, 'protocol', {{ get: poison }});
656        Object.defineProperty(Object.getPrototypeOf(Uint8Array.prototype), 'length', {{ get: poison }});
657        TextDecoder.prototype.decode = poison;
658        TextEncoder.prototype.encode = poison;
659        const addEventListener = EventTarget.prototype.addEventListener;
660        EventTarget.prototype.addEventListener = poison;
661        const es = new EventSource('{url}');
662        addEventListener.call(es, 'message', () => {{ throw Error('message event should not fire'); }});
663        addEventListener.call(es, 'hello', (event) => {{
664            if (event.data !== 'bonjour') throw Error('Custom event');
665            es.close();
666            console.log('primordials safe');
667        }});
668    "#
669        )
670        .replace("{url}", &url),
671        &[],
672    );
673    assert_eq!(out.stdout, b"primordials safe\n");
674    thread.join().unwrap();
675}
676
677#[test]
678fn workers_expose_event_source() {
679    let dir = tempfile::tempdir().unwrap();
680    let worker = dir.path().join("worker.js");
681    std::fs::write(
682        &worker,
683        r#"
684        if (typeof EventSource !== 'function') throw Error('Missing EventSource');
685        const es = new EventSource('http://127.0.0.1:1/worker');
686        if (!(es instanceof EventTarget) || es.readyState !== 0) throw Error('Worker EventSource');
687        es.addEventListener('error', (event) => {
688            if (es.readyState !== 0) throw Error('Ready state after denied connection');
689            es.close();
690            postMessage('worker eventsource ok');
691            close();
692        });
693    "#,
694    )
695    .unwrap();
696    let main = r#"
697        if (typeof EventSource !== 'function') throw Error('Missing EventSource in main');
698        const worker = new Worker(new URL('./worker.js', import.meta.url), {type: 'module', name: 'es'});
699        const answer = new Promise((resolve, reject) => {worker.onmessage = e => resolve(e.data); worker.onerror = reject;});
700        console.log(await answer);
701    "#;
702    std::fs::write(dir.path().join("main.js"), main).unwrap();
703    let output = std::process::Command::new(env!("CARGO_BIN_EXE_avril"))
704        .arg("run")
705        .arg(dir.path().join("main.js"))
706        .stdout(std::process::Stdio::piped())
707        .stderr(std::process::Stdio::piped())
708        .output()
709        .unwrap();
710    assert!(
711        output.status.success(),
712        "{}",
713        String::from_utf8_lossy(&output.stderr)
714    );
715    assert_eq!(output.stdout, b"worker eventsource ok\n");
716}
717
718#[test]
719fn reconnects_preserve_the_resume_cursor_until_an_explicit_reset() {
720    let count = std::sync::atomic::AtomicUsize::new(0);
721    let (url, thread) = server(4, move |request, stream| {
722        let attempt = count.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
723        assert_eq!(
724            header(&request.headers, "cache-control").as_deref(),
725            Some("no-cache")
726        );
727        assert_eq!(
728            header(&request.headers, "pragma").as_deref(),
729            Some("no-cache")
730        );
731        assert_eq!(
732            header(&request.headers, "last-event-id").as_deref(),
733            if attempt == 1 || attempt == 2 {
734                Some("cursor")
735            } else {
736                None
737            }
738        );
739        let body: &[u8] = match attempt {
740            0 => b"retry: 5\nid: cursor\ndata: first\n\n",
741            1 => b"data: second\n\n",
742            2 => b"id:\ndata: reset\n\n",
743            _ => b"data: final\n\n",
744        };
745        respond(stream, "200 OK", event_stream_headers(), body)
746    });
747    let output = common::run(
748        &format!(
749            r#"
750        const es = new EventSource('{url}'), seen = [];
751        const completed = new Promise(resolve => es.onmessage = event => {{
752            seen.push([event.data, event.lastEventId]);
753            if (event.data === 'final') {{ es.close(); resolve(); }}
754        }});
755        await completed;
756        console.log(JSON.stringify(seen));
757    "#
758        ),
759        &[],
760    );
761    assert_eq!(
762        output.stdout,
763        b"[[\"first\",\"cursor\"],[\"second\",\"cursor\"],[\"reset\",\"\"],[\"final\",\"\"]]\n"
764    );
765    thread.join().unwrap();
766}
767
768#[test]
769fn long_fragmented_lines_stream_without_rescanning_the_prefix() {
770    const LENGTH: usize = 16 * 1024 * 1024;
771    let (url, thread) = server(1, |_, stream| {
772        respond(stream, "200 OK", event_stream_headers(), b"data: ")?;
773        for _ in 0..LENGTH / 1024 {
774            stream.write_all(&[b'x'; 1024])?;
775        }
776        stream.write_all(b"\n\n")
777    });
778    let output = common::run(
779        &format!(
780            r#"
781        const es = new EventSource('{url}');
782        await new Promise(resolve => es.onmessage = event => {{
783            if (event.data.length !== {LENGTH} || event.data[0] !== 'x' || event.data.at(-1) !== 'x') throw Error('Long line');
784            es.close(); resolve();
785        }});
786        console.log('long line');
787    "#
788        ),
789        &[],
790    );
791    assert_eq!(output.stdout, b"long line\n");
792    thread.join().unwrap();
793}
794
795#[test]
796fn control_character_ids_do_not_prevent_reconnection() {
797    let count = std::sync::atomic::AtomicUsize::new(0);
798    let (url, thread) = server(5, move |request, stream| {
799        let attempt = count.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
800        assert_eq!(
801            header(&request.headers, "last-event-id").as_deref(),
802            if attempt == 3 {
803                Some("valid\tid")
804            } else {
805                None
806            }
807        );
808        let body: &[u8] = match attempt {
809            0 => b"retry: 5\nid: a\x01b\ndata: first\n\n",
810            1 => b"data: persisted\n\n",
811            2 => b"id: valid\tid\ndata: valid\n\n",
812            3 => b"id: a\x7fb\ndata: second\n\n",
813            _ => b"data: final\n\n",
814        };
815        respond(stream, "200 OK", event_stream_headers(), body)
816    });
817    let output = common::run(
818        &format!(
819            r#"
820        const es = new EventSource('{url}'), ids = [];
821        await new Promise(resolve => es.onmessage = event => {{
822            ids.push(event.lastEventId);
823            if (event.data === 'final') {{es.close(); resolve();}}
824        }});
825        if (JSON.stringify(ids) !== JSON.stringify(['a\x01b','a\x01b','valid\tid','a\x7fb','a\x7fb'])) throw Error('Cursor changed');
826        console.log('controls');
827    "#
828        ),
829        &[],
830    );
831    assert_eq!(output.stdout, b"controls\n");
832    thread.join().unwrap();
833}
834
835#[test]
836fn reflected_construction_and_live_handlers_survive_replaced_public_bases() {
837    let output = common::run(
838        r#"
839        const Ctor = EventSource, Parent = EventTarget;
840        const add = Parent.prototype.addEventListener, dispatch = Parent.prototype.dispatchEvent;
841        const poison = () => {throw Error('Guest replacement invoked');};
842        Object.setPrototypeOf(Ctor, function() {throw Error('Dynamic super invoked');});
843        Parent.prototype.addEventListener = Parent.prototype.removeEventListener = poison;
844        Event.prototype.preventDefault = poison;
845        globalThis.EventTarget = poison;
846        class Sub extends Ctor {}
847        const order = [], options = {
848            get withCredentials() {order.push('options'); return true;}
849        };
850        const source = new Sub({toString() {order.push('url'); return 'data:text/plain,events';}}, options);
851        source.close();
852        if (order.join('|') !== 'url|options' || !source.withCredentials) throw Error('Conversion order');
853        if (!(source instanceof Sub) || !(source instanceof Ctor) || !(source instanceof Parent)) {
854            throw Error('Reflected inheritance');
855        }
856        const log = [];
857        source.onopen = function(event) {
858            if (this !== source || event.target !== source) throw Error('Handler receiver');
859            log.push('first');
860            source.onopen = function() {log.push('second');};
861            return false;
862        };
863        add.call(source, 'open', () => log.push('observer'));
864        const canceled = new Event('open', {cancelable: true});
865        if (dispatch.call(source, canceled) || !canceled.defaultPrevented) throw Error('Handler cancellation');
866        if (!dispatch.call(source, new Event('open', {cancelable: true}))) throw Error('Replacement handler');
867        source.onopen = {handleEvent: poison};
868        if (source.onopen !== null) throw Error('Non-callable handler');
869        dispatch.call(source, new Event('open'));
870        source.onopen = () => log.push('third');
871        dispatch.call(source, new Event('open'));
872        if (log.join('|') !== 'first|observer|second|observer|observer|observer|third') {
873            throw Error('Handler replacement or removal changed ordering');
874        }
875        if (Reflect.ownKeys(source).length !== 0) throw Error('Unexpected instance fields');
876        for (const name of ['CONNECTING', 'OPEN', 'CLOSED']) {
877            for (const owner of [Ctor, Ctor.prototype]) {
878                const d = Object.getOwnPropertyDescriptor(owner, name);
879                if (d.writable || d.configurable || !d.enumerable) throw Error('Constant descriptor');
880            }
881        }
882        const handler = Object.getOwnPropertyDescriptor(Ctor.prototype, 'onopen');
883        if (Ctor.length !== 1 || fetch.length !== 1 || Ctor.prototype.close.length !== 0 ||
884            handler.get.length !== 0 || handler.set.length !== 1) throw Error('Callable shape');
885        console.log('reflected construction and handlers');
886        "#,
887        &[],
888    );
889    assert_eq!(output.stdout, b"reflected construction and handlers\n");
890}