1// Tests of the live job log of static/js/log.js (window.ermiteLog) with a stub
2// EventSource on the tiny hand-rolled DOM. Run with
3// node --test 'internal/adapters/web/jstest/*.test.mjs'
4import assert from 'node:assert/strict';
5import { test } from 'node:test';
6import { el, installDom } from './dom.mjs';
7
8// A line is "text" or "+text" for a piece that continues the line before it.
9function line(text) {
10 const cont = text.startsWith('+');
11 const li = el('li', { cls: 'lg', attrs: cont ? { 'data-cont': '' } : {} }, el('span', { cls: 't', text: cont ? text.slice(1) : text }));
12 return li;
13}
14
15installDom({ parseHTML: (data) => data.split('|').filter(Boolean).map(line) });
16globalThis.document.addEventListener = () => {}; // log.js starts itself on DOMContentLoaded
17
18class StubEventSource {
19 static CLOSED = 2;
20 static last = null;
21 constructor(url) {
22 this.url = url;
23 this.readyState = 1;
24 this.handlers = new Map();
25 StubEventSource.last = this;
26 }
27 addEventListener(name, fn) { this.handlers.set(name, fn); }
28 close() { this.readyState = StubEventSource.CLOSED; }
29 emit(name, data) { this.handlers.get(name)?.({ data }); }
30}
31globalThis.EventSource = StubEventSource;
32
33let scrolls = [];
34globalThis.scrollTo = (x, y) => scrolls.push(y);
35Object.assign(globalThis, { innerHeight: 800, scrollY: 0 });
36
37await import('../static/js/log.js');
38const { follow } = window.ermiteLog;
39
40// page makes a job page body: the box with its list and the lines already there.
41function page(attrs, ...lines) {
42 const list = el('ol', { cls: 'log' }, ...lines.map(line));
43 const box = el('div', { id: 'job-log', attrs }, list);
44 const body = document.body;
45 body.replaceChildren(box);
46 Object.defineProperty(globalThis.document.body, 'scrollHeight', { value: 1000, configurable: true });
47 return { box, list };
48}
49const running = { 'data-follow': '', 'data-stream': '/r/job/stream', 'data-next': '3' };
50const texts = (list) => list.children.map((li) => li.querySelector('.t').textContent);
51
52test('it does nothing for a job that is over, or a page without a feed', () => {
53 assert.equal(follow(null), null);
54 assert.equal(follow(page({ 'data-stream': '/s' }).box), null, 'no data-follow: the job ended');
55 assert.equal(follow(page({ 'data-follow': '' }).box), null, 'no stream URL');
56});
57
58test('it opens the feed after the last line the page holds', () => {
59 const { box } = page(running, 'a', 'b', 'c');
60 const es = follow(box);
61 assert.equal(es.url, '/r/job/stream?from=3');
62});
63
64test('it appends the lines of a batch', () => {
65 const { box, list } = page(running, 'a');
66 const es = follow(box);
67 es.emit('ci-log', 'b|c');
68 es.emit('ci-log', 'd');
69 assert.deepEqual(texts(list), ['a', 'b', 'c', 'd']);
70});
71
72test('a piece that continues a line of an earlier batch joins that line', () => {
73 const { box, list } = page(running, 'start of a long');
74 const es = follow(box);
75 es.emit('ci-log', '+ line|next');
76 assert.deepEqual(texts(list), ['start of a long line', 'next']);
77});
78
79test('it scrolls with the log only while the reader is at its end', () => {
80 const { box } = page(running, 'a');
81 const es = follow(box);
82 scrolls = [];
83 Object.assign(globalThis, { scrollY: 190 }); // 800 + 190 >= 1000 - 80: at the end
84 es.emit('ci-log', 'b');
85 assert.deepEqual(scrolls, [1000]);
86 scrolls = [];
87 Object.assign(globalThis, { scrollY: 0 }); // reading above
88 es.emit('ci-log', 'c');
89 assert.deepEqual(scrolls, []);
90});
91
92test('ci-end closes the feed, and a closed connection stays closed', () => {
93 const { box } = page(running, 'a');
94 const es = follow(box);
95 assert.equal(es.readyState, 1);
96 es.emit('ci-end', 'end');
97 assert.equal(es.readyState, StubEventSource.CLOSED);
98
99 const again = follow(page(running, 'a').box);
100 again.readyState = StubEventSource.CLOSED;
101 again.onerror();
102 assert.equal(again.readyState, StubEventSource.CLOSED);
103});