| author | |
| committer | |
| log | 6145522a83caa33ce59165003358338da313a290 |
| tree | b8eafabf16654ac3497caa620660701b1bc0a56a |
| parent | 496bdc100f7d96bdea16152c2edcc12b9b652fb3 |
| signature | Signed by SSH key SHA256:xbd+BjjhyBfwk7GVoURf9Yx0gzDerHbvYv7SddNWmAs |
the way that `process.stdout.write` is patched, you can't actually write
to it. now, a much more complicated system of locks is actually used,
alongside changes to the headless widget system, which is now just
renamed from `HeadlessWidgetHost` to `WidgetHost`.
this was good to get completed now since it finalized the shape of
`lockTerminal`, which needed to change `getDrawLock` so that one could
indicate if the cursor was at the start or middle of the line. the
default `node:process` widget host patches the global process write
function to trace if the buffer ends in a newline, which ensures.
since patching can be undesirable, you *can* opt out of it:
const host = log.simpleNodeProcessWidgetHost(process);
log.replaceGlobalWidgetHost(host);
but i want to make sure that the built in system is perfect so that no
user actually hits a bug that forces them to do this. there are some
known TODOs that are in scope for handling before the progress post:
- support global widgets started from different bundled versions of the
clover library. they should coordinate using a symbol global.
- handle when other sources invasivly patch the terminal, including when
it isn't possible for clover library to un-patch things. right now
this just disables the function wrapper, but it should be able to
re-use the patched implementation.
this patch has no issue right now, but it's a consequence of doing #70.10 files changed, 607 insertions(+), 145 deletions(-)
framework/incremental.ts+5-2| ... | ... | @@ -102,11 +102,12 @@ export async function compile<T>( |
| 102 | 102 | p: progress.Ref = progress, |
| 103 | 103 | ) { |
| 104 | 104 | ASSERT(!running, `Cannot run twice at the same time.`); |
| 105 | using node = p.start("sitegen!"); | |
| 105 | const startDate = new Date(); | |
| 106 | const start = performance.now(); | |
| 107 | const node = p.start("sitegen!"); | |
| 106 | 108 | try { |
| 107 | 109 | running = true; |
| 108 | 110 | ASSERT(jobs === 0); |
| 109 | const start = performance.now(); | |
| 110 | 111 | |
| 111 | 112 | const value = await compiler(node); |
| 112 | 113 | ASSERT(jobs === 0); |
| ... | ... | @@ -120,6 +121,8 @@ export async function compile<T>( |
| 120 | 121 | newAssets: !Array.from(seenWrites).some((x) => x.startsWith("a:")), |
| 121 | 122 | }; |
| 122 | 123 | } finally { |
| 124 | node.text = | |
| 125 | `sitegen at ${startDate.toLocaleDateString()} ${startDate.toLocaleTimeString()}`; | |
| 123 | 126 | node.end(); |
| 124 | 127 | running = false; |
| 125 | 128 | newKeys = 0; |
lib/assert.ts+2-2| ... | ... | @@ -1,6 +1,6 @@ |
| 1 | 1 | /** |
| 2 | * assertions and type narrowing helpers. | |
| 3 | * intended to be imported by symbol. | |
| 2 | * assertions and type narrowing helpers. intended to be imported per symbol. | |
| 3 | * functions are capitalized to make them stand out in a codebase. | |
| 4 | 4 | * @module |
| 5 | 5 | */ |
| 6 | 6 | /* node:coverage disable */ |
lib/examples/progress-log-stream.ts created+67| ... | ... | @@ -0,0 +1,67 @@ |
| 1 | const lorem1 = | |
| 2 | `Lorem ipsum dolor sit amet, consectetur adipiscing elit. Ut scelerisque elit ut ligula feugiat, at sagittis metus eleifend. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Aliquam aliquam, lorem volutpat sagittis efficitur, dui lectus rhoncus nunc, id porttitor sapien velit vel nunc. Praesent laoreet neque eu fringilla fermentum. Fusce eget nisl non libero porta sagittis. Duis diam neque, iaculis quis dignissim sit amet, iaculis eu ex. Aliquam suscipit nibh et arcu accumsan semper. Sed et nibh at sem pretium interdum. | |
| 3 | ||
| 4 | Phasellus nec dapibus diam. Duis feugiat fermentum erat, eu vulputate metus dapibus non. In aliquam venenatis accumsan. Pellentesque non libero dapibus, sagittis sem sed, lacinia augue. Cras vitae neque ac velit lobortis pulvinar. Suspendisse et risus eget urna rutrum ornare at at tortor. Praesent vulputate commodo dolor, ut mattis mauris tincidunt interdum. | |
| 5 | ||
| 6 | Praesent sapien leo, efficitur vitae porttitor at, iaculis et neque. Aliquam vel magna vitae odio imperdiet convallis sit amet a ante. Ut mollis lacus vel erat accumsan tempus. Ut ullamcorper neque vel quam egestas, nec rhoncus dolor vehicula. Donec ornare tempor leo sit amet consequat. Quisque dapibus leo sapien, ut fringilla mi scelerisque quis. Praesent id leo a arcu pulvinar ornare id vitae enim. Phasellus ut imperdiet nunc. Fusce sagittis sapien elit, sit amet tristique dui cursus eget. Integer nec dui felis. Aenean sodales ligula congue, convallis ex at, porta est. Class aptent taciti sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos. Fusce lacinia dolor vitae mi porta, et imperdiet erat suscipit. Phasellus dictum lorem nec eros volutpat interdum. Cras fermentum fringilla felis, in bibendum metus. Suspendisse vitae justo at odio tincidunt luctus. | |
| 7 | ||
| 8 | Curabitur vitae dignissim sem, nec viverra libero. Nunc rhoncus semper lacus, ac interdum nisl sagittis in. Sed eget volutpat nisi, id volutpat orci. Nulla ut urna elementum, tempus lectus sed, scelerisque elit. Phasellus viverra justo sed gravida rutrum. Sed fermentum sapien nibh, id tincidunt nisl porta in. Cras malesuada mauris eget quam aliquam, nec laoreet lacus aliquet. Fusce id felis quis lectus laoreet interdum non ac lacus. Aliquam lobortis vestibulum orci, id pharetra velit vulputate non. Sed hendrerit sem risus, nec finibus mi efficitur non. Vivamus eu dui at nisl lacinia molestie ac et odio. Cras quis tristique dui. Pellentesque maximus auctor erat. Suspendisse tincidunt, purus eu dictum ultrices, metus libero tristique ante, eu aliquam odio augue id nisi. | |
| 9 | ||
| 10 | Sed egestas nisl vitae metus consectetur aliquam. Aliquam erat volutpat. Nullam nunc turpis, pretium ut odio in, cursus scelerisque velit. Integer non odio sed nunc sodales suscipit non et lectus. Suspendisse aliquet nisl in justo feugiat, et blandit justo tincidunt. Proin id condimentum tortor. Curabitur scelerisque erat quis feugiat fringilla. Maecenas faucibus elit a neque tempor, vitae interdum odio feugiat. Donec ut malesuada nunc. Quisque at ultrices neque. Nunc odio ante, sagittis id nulla et, finibus cursus lectus. Interdum et malesuada fames ac ante ipsum primis in faucibus. Sed tortor turpis, blandit sit amet erat a, fringilla suscipit ligula. Proin at ante eu nibh tristique tincidunt. Nam sagittis lectus ac erat dictum lacinia.` | |
| 11 | .split(" "); | |
| 12 | const lorem2 = | |
| 13 | `Phasellus vitae erat ac diam lacinia feugiat vitae vitae dolor. Etiam neque metus, cursus ut feugiat eu, aliquam eu nisl. Curabitur rutrum, purus ac vulputate consectetur, lectus urna maximus tortor, vel blandit sapien mauris nec justo. Aenean eget blandit urna, et blandit risus. Sed convallis in nisl bibendum luctus. Fusce placerat ante eget nisl accumsan, nec tincidunt turpis hendrerit. Sed efficitur vulputate nisi quis auctor. Ut quis mauris magna. Cras nulla magna, vehicula a elementum ac, facilisis eu mi. Etiam malesuada justo eget eleifend sagittis. Nunc vulputate lorem ante, in luctus justo pellentesque cursus. Aliquam auctor urna ut vulputate blandit. Morbi eget pharetra risus. Phasellus accumsan, quam facilisis facilisis congue, augue enim vestibulum mauris, ut ultrices erat lacus id est. | |
| 14 | ||
| 15 | Sed at felis nec velit laoreet tincidunt. Integer gravida congue tellus ac consectetur. Etiam vitae nisi nec ante dapibus dictum a at nunc. Phasellus nec ligula non elit dignissim sagittis non ac justo. Praesent feugiat metus ligula. Nunc imperdiet, dolor iaculis vulputate auctor, orci odio tincidunt odio, a iaculis mi nisi vitae nibh. Nulla sit amet efficitur mi, a suscipit purus. Vestibulum suscipit diam at arcu tincidunt tempus. Donec finibus lectus laoreet condimentum pharetra. Nullam et libero ac orci aliquam luctus sed sed quam. | |
| 16 | ||
| 17 | Aliquam lacinia lacus ipsum. Aenean ornare semper lacus vel ullamcorper. Sed fringilla convallis dolor, at semper velit facilisis non. Morbi et ligula efficitur, condimentum turpis id, dignissim dolor. Donec suscipit pretium euismod. Duis tempor interdum ligula sit amet vehicula. Aenean iaculis, magna sed cursus faucibus, ipsum libero fringilla erat, eu tincidunt nibh nisl non libero. Nulla facilisi. In a quam ut neque euismod scelerisque eu a diam. Aenean lobortis consequat felis. Praesent et consequat nunc. | |
| 18 | ||
| 19 | Fusce at enim tempus, facilisis justo eget, laoreet elit. Proin sit amet ultrices sem. Suspendisse porta vitae lectus sed vehicula. Quisque tempor eros non sem malesuada hendrerit. Nullam eget neque in lacus consectetur feugiat non sed erat. Cras cursus aliquam felis, non maximus velit auctor in. Donec velit nisi, imperdiet a odio eget, consectetur vehicula elit. | |
| 20 | ||
| 21 | Maecenas fringilla maximus felis. Donec consequat sapien vitae consequat tempor. Suspendisse suscipit, magna nec feugiat gravida, neque arcu pretium lorem, ac interdum magna ligula et massa. Vivamus lobortis turpis ipsum, sed imperdiet lectus finibus vel. Maecenas non quam erat. Nunc vulputate libero ut justo fringilla, ut fringilla nisl facilisis. Aliquam id ligula sit amet nunc maximus condimentum. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia curae; Phasellus rhoncus molestie efficitur. Suspendisse est felis, dapibus at lacinia a, vestibulum sit amet turpis.` | |
| 22 | .split(" "); | |
| 23 | ||
| 24 | log.startWidget({ format: () => "" }) | |
| 25 | ||
| 26 | async.delay(500).then(async () => { | |
| 27 | const root = progress.start("generation"); | |
| 28 | const a = root.start("lorem ipsum generator 1"); | |
| 29 | const b = root.start("the second generator"); | |
| 30 | ||
| 31 | (async () => { | |
| 32 | let first = true; | |
| 33 | let wrap = 0; | |
| 34 | for (const word of lorem1) { | |
| 35 | await async.delay(30 + Math.random() * 50); | |
| 36 | if ((wrap + word.length) >= 80) { | |
| 37 | a.log.write("\n"); | |
| 38 | first = true; | |
| 39 | wrap = 0; | |
| 40 | } | |
| 41 | if (!first) a.log.write(" "); | |
| 42 | first = false; | |
| 43 | a.log.write(word); | |
| 44 | wrap += word.length + " ".length; | |
| 45 | } | |
| 46 | })(); | |
| 47 | (async () => { | |
| 48 | let first = true; | |
| 49 | let wrap = 0; | |
| 50 | for (const word of lorem2) { | |
| 51 | await async.delay(30 + Math.random() * 50); | |
| 52 | if ((wrap + word.length) >= 80) { | |
| 53 | b.log.write("\n"); | |
| 54 | first = true; | |
| 55 | wrap = 0; | |
| 56 | } | |
| 57 | if (!first) b.log.write(" "); | |
| 58 | first = false; | |
| 59 | b.log.write(word); | |
| 60 | wrap += word.length + " ".length; | |
| 61 | } | |
| 62 | })(); | |
| 63 | }); | |
| 64 | ||
| 65 | import * as async from "../async.ts"; | |
| 66 | import * as progress from "../progress.ts"; | |
| 67 | import * as log from "../log.ts"; |
lib/examples/progress-stdout-stream.ts created+39| ... | ... | @@ -0,0 +1,39 @@ |
| 1 | const lorem1 = | |
| 2 | `Lorem ipsum dolor sit amet, consectetur adipiscing elit. Ut scelerisque elit ut ligula feugiat, at sagittis metus eleifend. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Aliquam aliquam, lorem volutpat sagittis efficitur, dui lectus rhoncus nunc, id porttitor sapien velit vel nunc. Praesent laoreet neque eu fringilla fermentum. Fusce eget nisl non libero porta sagittis. Duis diam neque, iaculis quis dignissim sit amet, iaculis eu ex. Aliquam suscipit nibh et arcu accumsan semper. Sed et nibh at sem pretium interdum. | |
| 3 | ||
| 4 | Phasellus nec dapibus diam. Duis feugiat fermentum erat, eu vulputate metus dapibus non. In aliquam venenatis accumsan. Pellentesque non libero dapibus, sagittis sem sed, lacinia augue. Cras vitae neque ac velit lobortis pulvinar. Suspendisse et risus eget urna rutrum ornare at at tortor. Praesent vulputate commodo dolor, ut mattis mauris tincidunt interdum. | |
| 5 | ||
| 6 | Praesent sapien leo, efficitur vitae porttitor at, iaculis et neque. Aliquam vel magna vitae odio imperdiet convallis sit amet a ante. Ut mollis lacus vel erat accumsan tempus. Ut ullamcorper neque vel quam egestas, nec rhoncus dolor vehicula. Donec ornare tempor leo sit amet consequat. Quisque dapibus leo sapien, ut fringilla mi scelerisque quis. Praesent id leo a arcu pulvinar ornare id vitae enim. Phasellus ut imperdiet nunc. Fusce sagittis sapien elit, sit amet tristique dui cursus eget. Integer nec dui felis. Aenean sodales ligula congue, convallis ex at, porta est. Class aptent taciti sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos. Fusce lacinia dolor vitae mi porta, et imperdiet erat suscipit. Phasellus dictum lorem nec eros volutpat interdum. Cras fermentum fringilla felis, in bibendum metus. Suspendisse vitae justo at odio tincidunt luctus. | |
| 7 | ||
| 8 | Curabitur vitae dignissim sem, nec viverra libero. Nunc rhoncus semper lacus, ac interdum nisl sagittis in. Sed eget volutpat nisi, id volutpat orci. Nulla ut urna elementum, tempus lectus sed, scelerisque elit. Phasellus viverra justo sed gravida rutrum. Sed fermentum sapien nibh, id tincidunt nisl porta in. Cras malesuada mauris eget quam aliquam, nec laoreet lacus aliquet. Fusce id felis quis lectus laoreet interdum non ac lacus. Aliquam lobortis vestibulum orci, id pharetra velit vulputate non. Sed hendrerit sem risus, nec finibus mi efficitur non. Vivamus eu dui at nisl lacinia molestie ac et odio. Cras quis tristique dui. Pellentesque maximus auctor erat. Suspendisse tincidunt, purus eu dictum ultrices, metus libero tristique ante, eu aliquam odio augue id nisi. | |
| 9 | ||
| 10 | Sed egestas nisl vitae metus consectetur aliquam. Aliquam erat volutpat. Nullam nunc turpis, pretium ut odio in, cursus scelerisque velit. Integer non odio sed nunc sodales suscipit non et lectus. Suspendisse aliquet nisl in justo feugiat, et blandit justo tincidunt. Proin id condimentum tortor. Curabitur scelerisque erat quis feugiat fringilla. Maecenas faucibus elit a neque tempor, vitae interdum odio feugiat. Donec ut malesuada nunc. Quisque at ultrices neque. Nunc odio ante, sagittis id nulla et, finibus cursus lectus. Interdum et malesuada fames ac ante ipsum primis in faucibus. Sed tortor turpis, blandit sit amet erat a, fringilla suscipit ligula. Proin at ante eu nibh tristique tincidunt. Nam sagittis lectus ac erat dictum lacinia.` | |
| 11 | .split(" "); | |
| 12 | ||
| 13 | log.startWidget({ format: () => "" }); | |
| 14 | ||
| 15 | (async () => { | |
| 16 | let first = true; | |
| 17 | let wrap = 0; | |
| 18 | const stream = process.stderr; | |
| 19 | for (const word of lorem1) { | |
| 20 | await async.delay(200); | |
| 21 | if ((wrap + word.length) >= 80) { | |
| 22 | stream.write("\n"); | |
| 23 | first = true; | |
| 24 | wrap = 0; | |
| 25 | } | |
| 26 | if (!first) stream.write(" "); | |
| 27 | first = false; | |
| 28 | stream.write(word); | |
| 29 | wrap += word.length + " ".length; | |
| 30 | } | |
| 31 | })(); | |
| 32 | ||
| 33 | async.delay(1000).then(async () => { | |
| 34 | const root = progress.start("generation"); | |
| 35 | }); | |
| 36 | ||
| 37 | import * as async from "../async.ts"; | |
| 38 | import * as progress from "../progress.ts"; | |
| 39 | import * as log from "../log.ts"; |
lib/log.test.ts+211-21| ... | ... | @@ -18,18 +18,18 @@ describe("log widgets", () => { |
| 18 | 18 | }); |
| 19 | 19 | host.write("abc def"); |
| 20 | 20 | { |
| 21 | const unlock = host.getDrawLock(); | |
| 21 | using lock = host.getDrawLock("long"); | |
| 22 | 22 | host.expectFrame(null, { |
| 23 | 23 | stdout: "abc def", |
| 24 | 24 | }); |
| 25 | unlock(); | |
| 25 | lock.release("cursor-start-of-line"); | |
| 26 | 26 | } |
| 27 | 27 | host.expectFrame(null, { stdout: "" }); |
| 28 | 28 | { |
| 29 | const unlock = host.getDrawLock(); | |
| 29 | const lock = host.getDrawLock("long"); | |
| 30 | 30 | host.write("abc def "); |
| 31 | 31 | host.write("hijk"); |
| 32 | unlock(); | |
| 32 | lock.release("cursor-start-of-line"); | |
| 33 | 33 | host.expectFrame(0, { |
| 34 | 34 | stdout: "abc def hijk", |
| 35 | 35 | }); |
| ... | ... | @@ -54,7 +54,6 @@ describe("log widgets", () => { |
| 54 | 54 | ansi.cursorUp(1), |
| 55 | 55 | ansi.clearFullLine, |
| 56 | 56 | "partial line", |
| 57 | ansi.cursorSave, | |
| 58 | 57 | "\n", |
| 59 | 58 | "t=0\n", |
| 60 | 59 | ]), |
| ... | ... | @@ -62,21 +61,23 @@ describe("log widgets", () => { |
| 62 | 61 | stderr: testing.MockScreen.sync([ |
| 63 | 62 | ansi.cursorUp(1), |
| 64 | 63 | ansi.clearFullLine, |
| 65 | ansi.cursorSave, | |
| 66 | 64 | "\n", |
| 67 | 65 | "t=0\n", |
| 68 | 66 | ]), |
| 69 | 67 | }); |
| 70 | 68 | host.write(" and more\n"); |
| 69 | // partial line | |
| 70 | // t=0 | |
| 71 | // [C] | |
| 71 | 72 | host.expectFrame(0, { |
| 72 | 73 | merged: testing.MockScreen.sync([ |
| 73 | ansi.cursorRestore, | |
| 74 | ansi.cursorUp(2) + ansi.cursorRight("partial line".length), | |
| 74 | 75 | " and more\n", |
| 75 | 76 | "t=0\n", |
| 76 | 77 | ]), |
| 77 | 78 | stdout: " and more\n", |
| 78 | 79 | stderr: testing.MockScreen.sync([ |
| 79 | ansi.cursorRestore, | |
| 80 | ansi.cursorUp(2) + ansi.cursorRight("partial line".length), | |
| 80 | 81 | "t=0\n", |
| 81 | 82 | ]), |
| 82 | 83 | }); |
| ... | ... | @@ -104,7 +105,7 @@ describe("log widgets", () => { |
| 104 | 105 | ansi.cursorUp(2), |
| 105 | 106 | ansi.clearFullLine, |
| 106 | 107 | "partial line", |
| 107 | ansi.cursorSave + "\n", | |
| 108 | "\n", | |
| 108 | 109 | "abc", |
| 109 | 110 | ansi.clearToEndOfLine, |
| 110 | 111 | "\ndefg\n", |
| ... | ... | @@ -120,10 +121,10 @@ describe("log widgets", () => { |
| 120 | 121 | merged: testing.MockScreen.sync([ |
| 121 | 122 | ansi.cursorUp(2), |
| 122 | 123 | ansi.clearFullLine, |
| 123 | ansi.cursorRestore, | |
| 124 | ansi.cursorUp(1) + ansi.cursorRight(12), | |
| 124 | 125 | " and more\n", |
| 125 | 126 | "a", |
| 126 | ansi.cursorSave + "\n", | |
| 127 | "\n", | |
| 127 | 128 | "abc", |
| 128 | 129 | ansi.clearToEndOfLine, |
| 129 | 130 | "\ndefg\n", |
| ... | ... | @@ -197,7 +198,7 @@ describe("log widgets", () => { |
| 197 | 198 | { |
| 198 | 199 | host.write("warn: line of log\n"); |
| 199 | 200 | host.expectWithoutConsume(0); |
| 200 | const unlock = host.getDrawLock("short"); | |
| 201 | const lock = host.getDrawLock("short"); | |
| 201 | 202 | // info: line of log |
| 202 | 203 | // line one 0 |
| 203 | 204 | // line two |
| ... | ... | @@ -213,13 +214,16 @@ describe("log widgets", () => { |
| 213 | 214 | ].join(""), |
| 214 | 215 | stdout: "warn: line of log\n", |
| 215 | 216 | }); |
| 216 | const unlock2 = host.getDrawLock("long"); | |
| 217 | // info: line of log | |
| 218 | // warn: line of log | |
| 219 | // [C] | |
| 220 | const lock2 = host.getDrawLock("long"); | |
| 217 | 221 | host.expectNone(); |
| 218 | 222 | host.write("error: line of log\n"); |
| 219 | 223 | host.expectNone(); |
| 220 | unlock2(); | |
| 224 | lock2.release("cursor-start-of-line"); | |
| 221 | 225 | host.expectNone(); |
| 222 | unlock(); | |
| 226 | lock.release("cursor-start-of-line"); | |
| 223 | 227 | // warn: line of log |
| 224 | 228 | // [c] |
| 225 | 229 | host.expectFrame(0, { |
| ... | ... | @@ -232,7 +236,7 @@ describe("log widgets", () => { |
| 232 | 236 | host.cancel(); |
| 233 | 237 | } |
| 234 | 238 | { |
| 235 | const unlock = host.getDrawLock("short"); | |
| 239 | const lock = host.getDrawLock("short"); | |
| 236 | 240 | // info: line of log |
| 237 | 241 | // line one 0 |
| 238 | 242 | // line two |
| ... | ... | @@ -247,11 +251,11 @@ describe("log widgets", () => { |
| 247 | 251 | ].join(""), |
| 248 | 252 | stdout: "", |
| 249 | 253 | }); |
| 250 | const unlock2 = host.getDrawLock("long"); | |
| 254 | const lock2 = host.getDrawLock("long"); | |
| 251 | 255 | host.expectNone(); |
| 252 | unlock2(); | |
| 256 | lock2.release("cursor-start-of-line"); | |
| 253 | 257 | host.expectNone(); |
| 254 | unlock(); | |
| 258 | lock.release("cursor-start-of-line"); | |
| 255 | 259 | // warn: line of log |
| 256 | 260 | // [c] |
| 257 | 261 | host.expectFrame(0, { |
| ... | ... | @@ -264,6 +268,192 @@ describe("log widgets", () => { |
| 264 | 268 | } |
| 265 | 269 | }); |
| 266 | 270 | |
| 271 | test("draw lock middle of cursor restoration short", () => { | |
| 272 | const host = new testing.MockScreen(); | |
| 273 | ||
| 274 | using _ = host.startWidget({ | |
| 275 | format: (now) => `line one ${now}\nline two`, | |
| 276 | }); | |
| 277 | ||
| 278 | host.expectFrame(0, { | |
| 279 | stderr: testing.MockScreen.sync([ | |
| 280 | "line one 0\nline two\n", | |
| 281 | ]), | |
| 282 | }); | |
| 283 | ||
| 284 | host.write("info: line of log\n"); | |
| 285 | host.expectFrame(0, { | |
| 286 | merged: testing.MockScreen.sync([ | |
| 287 | ansi.cursorUp(2), | |
| 288 | ansi.clearFullLine, | |
| 289 | "info: line of log\n", | |
| 290 | "line one 0\nline two\n", | |
| 291 | ]), | |
| 292 | }); | |
| 293 | ||
| 294 | host.write("warn: line of log\n"); | |
| 295 | host.expectWithoutConsume(0); | |
| 296 | const lock = host.getDrawLock("short"); | |
| 297 | // info: line of log | |
| 298 | // line one 0 | |
| 299 | // line two | |
| 300 | // [C] | |
| 301 | host.expectFrame(null, { | |
| 302 | merged: [ | |
| 303 | ansi.syncStart, | |
| 304 | ansi.cursorUp(1), | |
| 305 | ansi.clearFullLine, | |
| 306 | ansi.cursorUp(1), | |
| 307 | ansi.clearFullLine, | |
| 308 | "warn: line of log\n", | |
| 309 | ].join(""), | |
| 310 | stdout: "warn: line of log\n", | |
| 311 | }); | |
| 312 | // info: line of log | |
| 313 | // warn: line of log | |
| 314 | // [C] | |
| 315 | const lock2 = host.getDrawLock("long"); | |
| 316 | host.expectNone(); | |
| 317 | // now we will pretend the environment writes "hello " without | |
| 318 | // a newline. without extra write calls, it has to save and | |
| 319 | // restore the cursor perfectly. when multiple locks are held, | |
| 320 | // it's a bit ambiguous, so the implementation takes the slow path | |
| 321 | // if any releases are uncertain of the terminal state. | |
| 322 | lock2.release("cursor-middle-of-line"); | |
| 323 | host.expectNone(); | |
| 324 | lock.release("cursor-start-of-line"); | |
| 325 | // warn: line of log | |
| 326 | // [c] | |
| 327 | host.expectFrame(0, { | |
| 328 | merged: [ | |
| 329 | ansi.cursorSave, | |
| 330 | "\n", | |
| 331 | "line one 0\nline two\n", | |
| 332 | ansi.syncEnd, | |
| 333 | ].join(""), | |
| 334 | }); | |
| 335 | const lock3 = host.getDrawLock("short"); | |
| 336 | // hello [S] | |
| 337 | // line one 0 | |
| 338 | // line two | |
| 339 | // [C] | |
| 340 | host.expectFrame(null, { | |
| 341 | merged: [ | |
| 342 | ansi.syncStart, | |
| 343 | ansi.cursorUp(1), | |
| 344 | ansi.clearFullLine, | |
| 345 | ansi.cursorUp(1), | |
| 346 | ansi.clearFullLine, | |
| 347 | ansi.cursorRestore, | |
| 348 | ].join(""), | |
| 349 | }); | |
| 350 | lock3.release("cursor-middle-of-line"); | |
| 351 | host.expectFrame(0, { | |
| 352 | merged: [ | |
| 353 | ansi.cursorSave, | |
| 354 | "\n", | |
| 355 | "line one 0\nline two\n", | |
| 356 | ansi.syncEnd, | |
| 357 | ].join(""), | |
| 358 | }); | |
| 359 | host.cancel(); | |
| 360 | }); | |
| 361 | ||
| 362 | test("draw lock middle of cursor restoration long", () => { | |
| 363 | const host = new testing.MockScreen(); | |
| 364 | ||
| 365 | using _ = host.startWidget({ | |
| 366 | format: (now) => `line one ${now}\nline two`, | |
| 367 | }); | |
| 368 | ||
| 369 | host.expectFrame(0, { | |
| 370 | stderr: testing.MockScreen.sync([ | |
| 371 | "line one 0\nline two\n", | |
| 372 | ]), | |
| 373 | }); | |
| 374 | ||
| 375 | host.write("info: line of log\n"); | |
| 376 | host.expectFrame(0, { | |
| 377 | merged: testing.MockScreen.sync([ | |
| 378 | ansi.cursorUp(2), | |
| 379 | ansi.clearFullLine, | |
| 380 | "info: line of log\n", | |
| 381 | "line one 0\nline two\n", | |
| 382 | ]), | |
| 383 | }); | |
| 384 | ||
| 385 | host.write("warn: line of log\n"); | |
| 386 | host.expectWithoutConsume(0); | |
| 387 | const lock = host.getDrawLock("long"); | |
| 388 | // info: line of log | |
| 389 | // line one 0 | |
| 390 | // line two | |
| 391 | // [C] | |
| 392 | host.expectFrame(null, { | |
| 393 | merged: [ | |
| 394 | ansi.syncStart, | |
| 395 | ansi.cursorUp(1), | |
| 396 | ansi.clearFullLine, | |
| 397 | ansi.cursorUp(1), | |
| 398 | ansi.clearFullLine, | |
| 399 | ansi.syncEnd, | |
| 400 | "warn: line of log\n", | |
| 401 | ].join(""), | |
| 402 | stdout: "warn: line of log\n", | |
| 403 | }); | |
| 404 | // info: line of log | |
| 405 | // warn: line of log | |
| 406 | // [C] | |
| 407 | const lock2 = host.getDrawLock("long"); | |
| 408 | host.expectNone(); | |
| 409 | // now we will pretend the environment writes "hello " without | |
| 410 | // a newline. without extra write calls, it has to save and | |
| 411 | // restore the cursor perfectly. when multiple locks are held, | |
| 412 | // it's a bit ambiguous, so the implementation takes the slow path | |
| 413 | // if any releases are uncertain of the terminal state. | |
| 414 | lock2.release("cursor-middle-of-line"); | |
| 415 | host.expectNone(); | |
| 416 | lock.release("cursor-start-of-line"); | |
| 417 | // warn: line of log | |
| 418 | // [c] | |
| 419 | host.expectFrame(0, { | |
| 420 | merged: [ | |
| 421 | ansi.cursorSave, | |
| 422 | "\n", | |
| 423 | ansi.syncStart, | |
| 424 | "line one 0\nline two\n", | |
| 425 | ansi.syncEnd, | |
| 426 | ].join(""), | |
| 427 | }); | |
| 428 | const lock3 = host.getDrawLock("long"); | |
| 429 | // hello [S] | |
| 430 | // line one 0 | |
| 431 | // line two | |
| 432 | // [C] | |
| 433 | host.expectFrame(null, { | |
| 434 | merged: [ | |
| 435 | ansi.syncStart, | |
| 436 | ansi.cursorUp(1), | |
| 437 | ansi.clearFullLine, | |
| 438 | ansi.cursorUp(1), | |
| 439 | ansi.clearFullLine, | |
| 440 | ansi.syncEnd, | |
| 441 | ansi.cursorRestore, | |
| 442 | ].join(""), | |
| 443 | }); | |
| 444 | lock3.release("cursor-middle-of-line"); | |
| 445 | host.expectFrame(0, { | |
| 446 | merged: [ | |
| 447 | ansi.cursorSave, | |
| 448 | "\n", | |
| 449 | ansi.syncStart, | |
| 450 | "line one 0\nline two\n", | |
| 451 | ansi.syncEnd, | |
| 452 | ].join(""), | |
| 453 | }); | |
| 454 | host.cancel(); | |
| 455 | }); | |
| 456 | ||
| 267 | 457 | test("widget expires", () => { |
| 268 | 458 | const host = new testing.MockScreen(); |
| 269 | 459 | |
| ... | ... | @@ -316,7 +506,7 @@ describe("log widgets", () => { |
| 316 | 506 | stdout: "log line;", |
| 317 | 507 | merged: testing.MockScreen.sync([ |
| 318 | 508 | "log line;", |
| 319 | ansi.cursorSave + "\n", | |
| 509 | "\n", | |
| 320 | 510 | "widget line one 0\nwidget line two\n", |
| 321 | 511 | ]), |
| 322 | 512 | }); |
| ... | ... | @@ -328,7 +518,7 @@ describe("log widgets", () => { |
| 328 | 518 | ansi.clearFullLine, |
| 329 | 519 | ansi.cursorUp(1), |
| 330 | 520 | ansi.clearFullLine, |
| 331 | ansi.cursorRestore, | |
| 521 | ansi.cursorUp(1) + ansi.cursorRight("log line;".length), | |
| 332 | 522 | " rest of line\n", |
| 333 | 523 | ]), |
| 334 | 524 | }); |
lib/log.ts+194-68| ... | ... | @@ -2,7 +2,7 @@ |
| 2 | 2 | * by using `lib/log.ts`, an application gets easy scoped logging as well as |
| 3 | 3 | * integration with terminal widgets such as `lib/progress.ts`. even when these |
| 4 | 4 | * widgets are active, using the logging interface is optional; global I/O with |
| 5 | * `console.*` and `process.std{out/err}` are patched to play nice. | |
| 5 | * `console.*` and `process.std{out/err}` are automatically patched to play nice. | |
| 6 | 6 | * |
| 7 | 7 | * the pattern for using this module is to shadow the global `console` with a |
| 8 | 8 | * per-file logging scope, which makes it impossible to use the wrong logger. |
| ... | ... | @@ -28,6 +28,8 @@ |
| 28 | 28 | * these allow showing temporary or interactive information, such as program |
| 29 | 29 | * status or input prompts. a powerful example of this system in action is |
| 30 | 30 | * `lib/progress.ts`, which uses a widget as it's default rendering backend. |
| 31 | * alongside this, a very complicated locking system is provided to ensure that | |
| 32 | * widgets to not interfere with globals like `process.stdout.write`. | |
| 31 | 33 | * (TODO: widgets cannot recieve "input" data yet) |
| 32 | 34 | * |
| 33 | 35 | * `lib/log.ts` offers two environment integrations: |
| ... | ... | @@ -39,10 +41,11 @@ |
| 39 | 41 | * |
| 40 | 42 | * custom log integrations can be built on top of this module by calling |
| 41 | 43 | * `log.tee()` to duplicate all messages elsewhere. for example, a project may |
| 42 | * configure logs to upload to a telemetry service. | |
| 44 | * configure logs to upload to a telemetry service. these logs can carry custom | |
| 45 | * metadata as well as source code traces -- amazing tools for debugging. | |
| 43 | 46 | * |
| 44 | 47 | * if bundling for the browser, see the details in `./node.ts` on how to |
| 45 | * trigger tree-shaking to eliminate the node.js bindings in your bundle. | |
| 48 | * trigger tree-shaking to eliminate the node.js bindings in your web bundle. | |
| 46 | 49 | * |
| 47 | 50 | * @module |
| 48 | 51 | */ |
| ... | ... | @@ -142,12 +145,18 @@ export function scoped(name: string): Scope { |
| 142 | 145 | export function tee(destination: DispatchFunction): ts.Dispose { |
| 143 | 146 | return globalLog.tee(destination); |
| 144 | 147 | } |
| 145 | /** replace the default writer */ | |
| 146 | export function replaceGlobalDestination(destination: DispatchFunction) { | |
| 148 | ||
| 149 | /** replace the default message writer */ | |
| 150 | export function replaceGlobalMessageDestination(destination: DispatchFunction) { | |
| 147 | 151 | globalOutputFunction = destination; |
| 148 | 152 | } |
| 149 | 153 | |
| 150 | /** replace the default formatter */ | |
| 154 | /** replace the default interactive widget host */ | |
| 155 | export function replaceGlobalWidgetHost(widgetHost: WidgetHost) { | |
| 156 | globalWidgetHost = widgetHost; | |
| 157 | } | |
| 158 | ||
| 159 | /** replace the default message formatter */ | |
| 151 | 160 | export function replaceGlobalFormatFunction( |
| 152 | 161 | format: (msg: Message, colors: boolean) => string, |
| 153 | 162 | ) { |
| ... | ... | @@ -194,15 +203,45 @@ export function writeMessage(m: Message) { |
| 194 | 203 | } |
| 195 | 204 | |
| 196 | 205 | /** |
| 197 | * while locked, no widgets will draw. prefer calling `write` to opt into | |
| 198 | * automatic buffering. this lock is not exclusive. if the lock will be held | |
| 199 | * for an extremely short amount of time, pass `"short"` which will allow | |
| 200 | * more optimized use of ansi synchronization codes. | |
| 206 | * hold a lock that stops `@clo/lib` from drawing to the terminal. this is an | |
| 207 | * advanced api that is likely unneeded unless messing with external APIs that | |
| 208 | * freely write to the terminal. | |
| 209 | * | |
| 210 | * NOTE: if the lock will be held for an extremely short amount of time, pass | |
| 211 | * `"short"` which will allow more optimized use of ansi synchronization codes. | |
| 201 | 212 | */ |
| 202 | export function getDrawLock(mode: "long" | "short"): ts.Dispose { | |
| 213 | export function getDrawLock(mode: "long" | "short"): DrawLock { | |
| 203 | 214 | return globalWidgetHost.getDrawLock(mode); |
| 204 | 215 | } |
| 205 | 216 | |
| 217 | /** | |
| 218 | * call this if you are doing silly things with `process.stderr` and you want to | |
| 219 | * make sure that starting a log widget (such as `@clo/lib/progress.ts`) doesn't | |
| 220 | * have interweaving bugs. in particular, you need this if your program directly | |
| 221 | * writes incomplete lines to the terminal (missing a trailing `\n`). | |
| 222 | * | |
| 223 | * the globals are patched to track if widget text must move to a fresh line | |
| 224 | * before drawing, as well as knowing to return to the original position | |
| 225 | * afterwards. | |
| 226 | * | |
| 227 | * you most likely do not need to call this API. | |
| 228 | */ | |
| 229 | export function ensureGlobalsArePatched(): ts.Dispose { | |
| 230 | return globalWidgetHost.startWidget({ format: () => "" }); | |
| 231 | } | |
| 232 | ||
| 233 | export interface DrawLock { | |
| 234 | /** | |
| 235 | * decides if widget drawing requires an extra newline, which is needed if | |
| 236 | * there is extra text on the line that the lock is being released on. | |
| 237 | */ | |
| 238 | release( | |
| 239 | cursorPosition: "cursor-start-of-line" | "cursor-middle-of-line", | |
| 240 | ): void; | |
| 241 | /** Assumes worst case `cursor-middle-of-line` */ | |
| 242 | [Symbol.dispose](): void; | |
| 243 | } | |
| 244 | ||
| 206 | 245 | export function headlessScope(dispatch: DispatchFunction): RootScope { |
| 207 | 246 | return new ScopeImpl(dispatch); |
| 208 | 247 | } |
| ... | ... | @@ -228,8 +267,8 @@ export interface Widget { |
| 228 | 267 | onKey?(key: string): void; |
| 229 | 268 | } |
| 230 | 269 | |
| 231 | /** {@linkcode widgetHost}'s input takes terminal i/o as well as timing APIs */ | |
| 232 | export interface HeadlessWidgetEnv { | |
| 270 | /** {@linkcode widgetHost}'s input takes terminal I/O as well as timing APIs */ | |
| 271 | export interface WidgetHostOptions { | |
| 233 | 272 | /** |
| 234 | 273 | * an exclusive lock on the terminal is held whenever widgets are active. a |
| 235 | 274 | * secondary purpose of this is to instrument/deinstrument other code to |
| ... | ... | @@ -238,12 +277,11 @@ export interface HeadlessWidgetEnv { |
| 238 | 277 | * draw lock. |
| 239 | 278 | * |
| 240 | 279 | * this lock can be cleared by deactiving all widgets, or by calling |
| 241 | * {@linkcode getDrawLock}. | |
| 280 | * {@linkcode getDrawLock} to force `@clo/lib` to stop drawing. | |
| 242 | 281 | * |
| 243 | 282 | * currently, this lock must be able to be synchronously aquired at any |
| 244 | 283 | * point. if you desire an async locking function, please contact me so we |
| 245 | * can design how it would work. you can currently work around this with | |
| 246 | * `getDrawLock` | |
| 284 | * can design how it would work. | |
| 247 | 285 | */ |
| 248 | 286 | lockTerminal: () => TerminalLock; |
| 249 | 287 | /** fast path for writing output without widgets */ |
| ... | ... | @@ -254,6 +292,10 @@ export interface HeadlessWidgetEnv { |
| 254 | 292 | delay: typeof async.delay; |
| 255 | 293 | } |
| 256 | 294 | |
| 295 | /** | |
| 296 | * When `@clo/lib` requests a lock on the terminal, the adapter provides this | |
| 297 | * interface to communicate everything about the terminal state correctly. | |
| 298 | */ | |
| 257 | 299 | export interface TerminalLock { |
| 258 | 300 | /** recieves ANSI escape sequences for interactive data (should flush immediately) */ |
| 259 | 301 | writeInteractive(text: string): void; |
| ... | ... | @@ -261,6 +303,7 @@ export interface TerminalLock { |
| 261 | 303 | writeOutput(text: string): void; |
| 262 | 304 | /** called often. TODO: convert this into a subscription */ |
| 263 | 305 | getSize(): { columns: number; rows: number }; |
| 306 | // size(cb: (value: { width: number, height: number }) => void): () => void; | |
| 264 | 307 | /** temporarily free the lock */ |
| 265 | 308 | temporaryUnlock?(): () => void; |
| 266 | 309 | /** completely free the lock */ |
| ... | ... | @@ -268,11 +311,11 @@ export interface TerminalLock { |
| 268 | 311 | } |
| 269 | 312 | |
| 270 | 313 | /** an implementation of an ANSI-based widget host */ |
| 271 | export interface HeadlessWidgetHost { | |
| 314 | export interface WidgetHost { | |
| 272 | 315 | /** see the top-level {@linkcode writeLine} function */ |
| 273 | 316 | write(text: string): void; |
| 274 | 317 | /** see the top-level {@linkcode getDrawLock} function */ |
| 275 | getDrawLock(mode: "long" | "short"): ts.Dispose; | |
| 318 | getDrawLock(mode: "long" | "short"): DrawLock; | |
| 276 | 319 | /** see the top-level {@linkcode startWidget} function */ |
| 277 | 320 | startWidget(widget: Widget): ts.Dispose; |
| 278 | 321 | /** stop all widgets and remove all timers. */ |
| ... | ... | @@ -293,19 +336,8 @@ interface WidgetState { |
| 293 | 336 | /** |
| 294 | 337 | * terminal widget rendering is done by specifying all system APIs up front in |
| 295 | 338 | * an interface, creating an instance of the "widget host". |
| 296 | * | |
| 297 | * some notes on widget rendering: | |
| 298 | * - Never flush immediately (exception for | |
| 299 | * {@linkcode WidgetHost["getDrawLock"]|getDrawLock}), always do it next tick. | |
| 300 | * - Maximum of one `wait` call at once. When the expected time suddenly | |
| 301 | * shrinks, the timer is rescheduled. | |
| 302 | * - When redrawing widget lines, three tricks are done to reduce flickering: | |
| 303 | * 1. Tell the terminal not to flicker (ansi.syncStart/syncEnd). | |
| 304 | * 2. A simple prefix-based diffing algorithm for skipping unchanged text | |
| 305 | * 3. Avoid clearing a line before redrawing it. | |
| 306 | * Points 2 and 3 are used for terminals that are slow or do not support sync. | |
| 307 | 339 | */ |
| 308 | export function headlessWidgetHost(env: HeadlessWidgetEnv): HeadlessWidgetHost { | |
| 340 | export function createWidgetHost(env: WidgetHostOptions): WidgetHost { | |
| 309 | 341 | const { lockTerminal, now, delay, writeOutputTemporaryLock } = env; |
| 310 | 342 | |
| 311 | 343 | let timer: async.Cancelable<void> | null = null; |
| ... | ... | @@ -314,7 +346,9 @@ export function headlessWidgetHost(env: HeadlessWidgetEnv): HeadlessWidgetHost { |
| 314 | 346 | let redrawTime = 0; |
| 315 | 347 | let lastFlush = 0; |
| 316 | 348 | let buffer = ""; |
| 317 | let partialLine = false; | |
| 349 | let partialLineIndex = 0; | |
| 350 | let needsToSaveCursor = false; | |
| 351 | let needsToRestoreCursor = false; | |
| 318 | 352 | const widgets: Widget[] = []; |
| 319 | 353 | const internals: WidgetState[] = []; |
| 320 | 354 | let lines: string[] = []; |
| ... | ... | @@ -329,6 +363,8 @@ export function headlessWidgetHost(env: HeadlessWidgetEnv): HeadlessWidgetHost { |
| 329 | 363 | // trivial path when not using widgets |
| 330 | 364 | if (!lines.length && !widgets.length) { |
| 331 | 365 | ASSERT(buffer); |
| 366 | needsToRestoreCursor = false; | |
| 367 | needsToSaveCursor = false; | |
| 332 | 368 | if (writeOutputTemporaryLock) { |
| 333 | 369 | writeOutputTemporaryLock(buffer); |
| 334 | 370 | if (hasSyncStart) { |
| ... | ... | @@ -345,6 +381,7 @@ export function headlessWidgetHost(env: HeadlessWidgetEnv): HeadlessWidgetHost { |
| 345 | 381 | terminal.close(); |
| 346 | 382 | terminal = null; |
| 347 | 383 | } |
| 384 | partialLineIndex = partialLineLength(buffer); | |
| 348 | 385 | buffer = ""; |
| 349 | 386 | return; |
| 350 | 387 | } |
| ... | ... | @@ -376,13 +413,17 @@ export function headlessWidgetHost(env: HeadlessWidgetEnv): HeadlessWidgetHost { |
| 376 | 413 | if (next < Infinity) redrawSoon(next); |
| 377 | 414 | |
| 378 | 415 | if (!newWidgetLines[0]) { |
| 416 | ASSERT(!needsToSaveCursor); | |
| 379 | 417 | if (lines.length > 0) { |
| 380 | 418 | terminal.writeInteractive( |
| 381 | 419 | (hasSyncStart ? "" : ansi.syncStart) + |
| 382 | 420 | // clear the widget space |
| 383 | 421 | (ansi.cursorUp(1) + ansi.clearFullLine) |
| 384 | 422 | .repeat(lines.length) + |
| 385 | (partialLine ? ansi.cursorRestore : ""), | |
| 423 | (partialLineIndex | |
| 424 | ? ansi.cursorUp(1) + ansi.cursorRight(partialLineIndex) | |
| 425 | : "") + | |
| 426 | (needsToRestoreCursor ? ansi.cursorRestore : ""), | |
| 386 | 427 | ); |
| 387 | 428 | hasSyncStart = true; |
| 388 | 429 | lines = []; |
| ... | ... | @@ -403,18 +444,20 @@ export function headlessWidgetHost(env: HeadlessWidgetEnv): HeadlessWidgetHost { |
| 403 | 444 | // > [cursor is start of this line] |
| 404 | 445 | // |
| 405 | 446 | // first, clear out the space where new lines are going to intersect |
| 406 | const createsPartialLine = !buffer.endsWith("\n"); | |
| 447 | const createsPartialLine = partialLineLength(buffer); | |
| 407 | 448 | // if more lines are buffered than there are widgets, only some are needed |
| 408 | 449 | const clearLinesTop = Math.min( |
| 409 | 450 | lines.length, |
| 410 | 451 | string.countNewlines(buffer) + |
| 411 | 452 | (createsPartialLine ? 1 : 0) + |
| 412 | (partialLine ? -1 : 0), | |
| 453 | (partialLineIndex ? -1 : 0), // "consumes partial line" | |
| 413 | 454 | ); |
| 414 | 455 | const oldLines = lines.slice(clearLinesTop); |
| 415 | 456 | terminal.writeInteractive( |
| 416 | (hasSyncStart ? "" : ansi.syncStart) + | |
| 417 | ((clearLinesTop > 0 || partialLine) | |
| 457 | (needsToSaveCursor ? ansi.cursorSave + "\n" : "") + | |
| 458 | (hasSyncStart ? "" : ansi.syncStart) + | |
| 459 | (partialLineIndex && !lines[0] ? "\n" : "") + | |
| 460 | ((clearLinesTop > 0 || (partialLineIndex && lines[0])) | |
| 418 | 461 | // clear the lines for buffer |
| 419 | 462 | ? (clearLinesTop > 0 |
| 420 | 463 | ? ansi.cursorUp(lines.length - clearLinesTop + 1) + |
| ... | ... | @@ -422,17 +465,22 @@ export function headlessWidgetHost(env: HeadlessWidgetEnv): HeadlessWidgetHost { |
| 422 | 465 | (ansi.cursorUp(1) + ansi.clearFullLine) |
| 423 | 466 | .repeat(clearLinesTop - 1) |
| 424 | 467 | : "") + |
| 425 | (partialLine ? ansi.cursorRestore : "") | |
| 468 | (partialLineIndex && lines[0] | |
| 469 | ? ansi.cursorUp( | |
| 470 | clearLinesTop > 0 ? 1 : lines.length + 1, | |
| 471 | ) + | |
| 472 | ansi.cursorRight(partialLineIndex) | |
| 473 | : "") | |
| 426 | 474 | : ""), |
| 427 | 475 | ); |
| 428 | partialLine = createsPartialLine; | |
| 476 | partialLineIndex = createsPartialLine; | |
| 429 | 477 | // then write output lines on standard out |
| 430 | 478 | terminal.writeOutput(buffer); |
| 431 | 479 | terminal.writeInteractive( |
| 432 | 480 | // if a partial line is created, then the widgets |
| 433 | 481 | // have to go on the next line, to avoid breaking stdout, |
| 434 | 482 | // the newline gets emitted on the interactive out. |
| 435 | (createsPartialLine ? ansi.cursorSave + "\n" : "") + | |
| 483 | (createsPartialLine ? "\n" : "") + | |
| 436 | 484 | // the widget text |
| 437 | 485 | newWidgetLines.map((newLine, i) => |
| 438 | 486 | (newLine.includes("\x1b") && !newLine.endsWith(ansi.reset) |
| ... | ... | @@ -453,7 +501,8 @@ export function headlessWidgetHost(env: HeadlessWidgetEnv): HeadlessWidgetHost { |
| 453 | 501 | Math.max(0, lines.length - (newWidgetLines?.length ?? 0)), |
| 454 | 502 | ); |
| 455 | 503 | terminal.writeInteractive( |
| 456 | (hasSyncStart ? "" : ansi.syncStart) + | |
| 504 | (needsToSaveCursor ? ansi.cursorSave + "\n" : "") + | |
| 505 | (hasSyncStart ? "" : ansi.syncStart) + | |
| 457 | 506 | // clear the bottom lines |
| 458 | 507 | (clearLinesBottom |
| 459 | 508 | ? (ansi.cursorUp(1) + ansi.clearToEndOfLine) |
| ... | ... | @@ -478,6 +527,8 @@ export function headlessWidgetHost(env: HeadlessWidgetEnv): HeadlessWidgetHost { |
| 478 | 527 | ); |
| 479 | 528 | } |
| 480 | 529 | hasSyncStart = false; |
| 530 | needsToRestoreCursor ||= needsToSaveCursor; | |
| 531 | needsToSaveCursor = false; | |
| 481 | 532 | lines = newWidgetLines; |
| 482 | 533 | |
| 483 | 534 | buffer = ""; |
| ... | ... | @@ -505,8 +556,11 @@ export function headlessWidgetHost(env: HeadlessWidgetEnv): HeadlessWidgetHost { |
| 505 | 556 | // clear the widget space |
| 506 | 557 | (ansi.cursorUp(1) + ansi.clearFullLine) |
| 507 | 558 | .repeat(lines.length) + |
| 508 | (partialLine ? ansi.cursorRestore : "") + | |
| 509 | (shortTermDrawLock ? "" : ansi.syncEnd), | |
| 559 | (partialLineIndex | |
| 560 | ? ansi.cursorUp(1) + ansi.cursorRight(partialLineIndex) | |
| 561 | : "") + | |
| 562 | (shortTermDrawLock ? "" : ansi.syncEnd) + | |
| 563 | (needsToRestoreCursor ? ansi.cursorRestore : ""), | |
| 510 | 564 | ); |
| 511 | 565 | lines = []; |
| 512 | 566 | hasSyncStart = shortTermDrawLock; |
| ... | ... | @@ -543,14 +597,23 @@ export function headlessWidgetHost(env: HeadlessWidgetEnv): HeadlessWidgetHost { |
| 543 | 597 | } |
| 544 | 598 | } |
| 545 | 599 | locks += 1; |
| 546 | return ts.defer(() => { | |
| 547 | locks -= 1; | |
| 548 | if (locks === 0) { | |
| 549 | tempUnlock?.(); | |
| 550 | tempUnlock = null; | |
| 551 | if (buffer.length > 0 || widgets.length > 0) redrawSoon(0); | |
| 552 | } | |
| 553 | }); | |
| 600 | let disposed = false; | |
| 601 | return { | |
| 602 | release(mode) { | |
| 603 | if (disposed) return; | |
| 604 | locks -= 1; | |
| 605 | needsToSaveCursor ||= mode === "cursor-middle-of-line"; | |
| 606 | if (locks === 0) { | |
| 607 | tempUnlock?.(); | |
| 608 | tempUnlock = null; | |
| 609 | if (buffer.length > 0 || widgets.length > 0) redrawSoon(0); | |
| 610 | } | |
| 611 | disposed = true; | |
| 612 | }, | |
| 613 | [Symbol.dispose]() { | |
| 614 | this.release("cursor-middle-of-line"); | |
| 615 | }, | |
| 616 | }; | |
| 554 | 617 | }, |
| 555 | 618 | startWidget(w) { |
| 556 | 619 | ASSERT(!widgets.includes(w), "Cannot start the same widget twice."); |
| ... | ... | @@ -583,6 +646,12 @@ export function headlessWidgetHost(env: HeadlessWidgetEnv): HeadlessWidgetHost { |
| 583 | 646 | }; |
| 584 | 647 | } |
| 585 | 648 | |
| 649 | function partialLineLength(text: string) { | |
| 650 | const idx = text.lastIndexOf("\n"); | |
| 651 | if (idx === -1) return text.length; | |
| 652 | return text.length - idx - 1; | |
| 653 | } | |
| 654 | ||
| 586 | 655 | const logColors = node.process?.stderr.isTTY ?? false; |
| 587 | 656 | |
| 588 | 657 | let formatLine = /* @__PURE__ */ (() => { |
| ... | ... | @@ -702,35 +771,63 @@ const ScopeImpl = class Scope implements RootScope { |
| 702 | 771 | } |
| 703 | 772 | }; |
| 704 | 773 | |
| 705 | const globalWidgetHost = node.process | |
| 774 | let globalWidgetHost = node.process | |
| 706 | 775 | ? /* @__PURE__*/ ((process: NonNullable<typeof node.process>) => { |
| 707 | const widget = headlessWidgetHost({ | |
| 776 | const widget = createWidgetHost({ | |
| 708 | 777 | lockTerminal() { |
| 709 | 778 | const { stdout, stderr } = process; |
| 710 | 779 | let disposed = false; |
| 711 | 780 | |
| 712 | function patch<T, A extends unknown[]>( | |
| 713 | fn: (this: T, ...args: A) => void, | |
| 714 | ) { | |
| 715 | return function (this: T, ...args: A) { | |
| 716 | using _ = disposed ? null : widget.getDrawLock("short"); | |
| 717 | fn.apply(this, args); | |
| 718 | }; | |
| 719 | } | |
| 720 | ||
| 721 | 781 | // patch calls to `process.std{out,err}` |
| 722 | 782 | // note: `pipe` uses managed calls to `write`, so this is plenty |
| 723 | 783 | const stdoutWrite = stdout.write; |
| 724 | 784 | const stderrWrite = stderr.write; |
| 725 | 785 | const stdoutEnd = stdout.end; |
| 726 | 786 | const stderrEnd = stderr.end; |
| 727 | const newStdoutWrite = stdout.write = patch(stdoutWrite); | |
| 728 | const newStderrWrite = stderr.write = patch(stderrWrite); | |
| 729 | const newStdoutEnd = stdout.end = patch(stdoutEnd); | |
| 730 | const newStderrEnd = stderr.end = patch(stderrEnd); | |
| 787 | const newStdoutWrite = stdout.write = widget.write; | |
| 788 | const newStderrWrite = stderr.write = function ( | |
| 789 | this: typeof stderr, | |
| 790 | ...args | |
| 791 | ) { | |
| 792 | using lock = disposed ? null : widget.getDrawLock("short"); | |
| 793 | stderrWrite.apply(stderr, args); | |
| 794 | if (lock) { | |
| 795 | lock.release( | |
| 796 | (typeof args[0] === "string" | |
| 797 | ? args[0].endsWith("\n") | |
| 798 | : bufferEndsInNewline(args[0])) | |
| 799 | ? "cursor-start-of-line" | |
| 800 | : "cursor-middle-of-line", | |
| 801 | ); | |
| 802 | } | |
| 803 | }; | |
| 804 | function patchEndMethod<T, A extends unknown[]>( | |
| 805 | fn: (this: T, ...args: A) => void, | |
| 806 | ) { | |
| 807 | return function (this: T, ...args: A) { | |
| 808 | using lock = disposed ? null : widget.getDrawLock("short"); | |
| 809 | fn.apply(this, args); | |
| 810 | // TODO: this is not handled correctly, but nobody closes | |
| 811 | // their fucking standard error! this likely should just | |
| 812 | // disable the library if you call end. | |
| 813 | if (lock) lock.release("cursor-start-of-line"); | |
| 814 | }; | |
| 815 | } | |
| 816 | const newStdoutEnd = stdout.end = patchEndMethod(stdoutEnd); | |
| 817 | const newStderrEnd = stderr.end = patchEndMethod(stderrEnd); | |
| 731 | 818 | |
| 732 | 819 | // non-node runtimes will typically implement console in a way that |
| 733 | // doesn't use `node:process`, so it must also get patched | |
| 820 | // doesn't use `node:process`, so it must also get patched. this is | |
| 821 | // okay because the lock is re-enterant. | |
| 822 | function patchSyncMethod<T, A extends unknown[]>( | |
| 823 | fn: (this: T, ...args: A) => void, | |
| 824 | ) { | |
| 825 | return function (this: T, ...args: A) { | |
| 826 | using lock = disposed ? null : widget.getDrawLock("short"); | |
| 827 | fn.apply(this, args); | |
| 828 | if (lock) lock.release("cursor-start-of-line"); | |
| 829 | }; | |
| 830 | } | |
| 734 | 831 | const console = globalThis |
| 735 | 832 | .console as unknown as Record<string, () => void>; |
| 736 | 833 | const restoreConsole: [string, old: () => void, patch: () => void][] = |
| ... | ... | @@ -738,7 +835,7 @@ const globalWidgetHost = node.process |
| 738 | 835 | for (const [key, old] of Object.entries(console)) { |
| 739 | 836 | if (typeof old !== "function") continue; |
| 740 | 837 | try { |
| 741 | const patched = console[key] = patch(old); | |
| 838 | const patched = console[key] = patchSyncMethod(old); | |
| 742 | 839 | restoreConsole.push([key, old, patched]); |
| 743 | 840 | } catch { /* skip */ } |
| 744 | 841 | } |
| ... | ... | @@ -774,7 +871,7 @@ const globalWidgetHost = node.process |
| 774 | 871 | : /* @__PURE__ */ ((warned = false) => { |
| 775 | 872 | return { |
| 776 | 873 | write: (line: string) => console.log(line), |
| 777 | getDrawLock: () => ts.defer(() => {}), | |
| 874 | getDrawLock: () => ({ [Symbol.dispose]() {}, release() {} }), | |
| 778 | 875 | startWidget: (w: Widget) => { |
| 779 | 876 | if (!warned) { |
| 780 | 877 | console.warn( |
| ... | ... | @@ -791,6 +888,35 @@ const globalWidgetHost = node.process |
| 791 | 888 | }; |
| 792 | 889 | })(); |
| 793 | 890 | |
| 891 | export function simpleNodeProcessWidgetHost(process: node.Process) { | |
| 892 | return createWidgetHost({ | |
| 893 | lockTerminal() { | |
| 894 | return { | |
| 895 | writeOutput: (string) => process.stdout.write(string), | |
| 896 | writeInteractive: (string) => process.stderr.write(string), | |
| 897 | getSize: () => process.stderr, | |
| 898 | temporarilyUnlock() { | |
| 899 | // no action needed | |
| 900 | }, | |
| 901 | close() { | |
| 902 | // no action needed | |
| 903 | }, | |
| 904 | }; | |
| 905 | }, | |
| 906 | now: () => performance.now(), | |
| 907 | delay: async.delay, | |
| 908 | }); | |
| 909 | } | |
| 910 | ||
| 911 | function bufferEndsInNewline(buffer: ArrayBufferView | undefined) { | |
| 912 | return buffer | |
| 913 | ? buffer instanceof Uint8Array | |
| 914 | ? buffer.at(-1) === 0xa | |
| 915 | : new Uint8Array(buffer.buffer, buffer.byteOffset, buffer.byteLength) | |
| 916 | .at(-1) === 0xa | |
| 917 | : false; | |
| 918 | } | |
| 919 | ||
| 794 | 920 | const levelToAnsi: Record<Message["level"], string> = { |
| 795 | 921 | info: `${ansi.fgBlue}info`, |
| 796 | 922 | warn: `${ansi.fgYellow}warn`, |
lib/node.ts+2-1| ... | ... | @@ -20,7 +20,8 @@ export const process: Process | undefined = |
| 20 | 20 | |
| 21 | 21 | export const isServer: boolean = !!process; |
| 22 | 22 | |
| 23 | interface Process { | |
| 23 | /** partial types for Node.js `globalThis.process` */ | |
| 24 | export interface Process { | |
| 24 | 25 | getBuiltinModule<K extends keyof Builtins>(name: K): Builtins[K] | null; |
| 25 | 26 | binding<K extends keyof Bindings>(name: K): Bindings[K] | null; |
| 26 | 27 | addListener(event: string, callback: () => void): this; |
lib/progress.test.ts+24-24| ... | ... | @@ -105,30 +105,30 @@ test("percentValueFormatter", () => { |
| 105 | 105 | assert.equal(progress.percentValueFormatter(0.5, null), "50%"); |
| 106 | 106 | }); |
| 107 | 107 | |
| 108 | // ## `progress.Ema` | |
| 109 | // | |
| 110 | // the exponential moving average algorithm can be used on its own | |
| 111 | // | |
| 112 | test("Ema", () => { | |
| 113 | // first sample always returns null, if the progress moves linearly then the | |
| 114 | // estimated time will be the same. | |
| 115 | let ema = new progress.Ema(); | |
| 116 | assert.equal(ema.sample(50_000, 0), null); | |
| 117 | assert.equal(ema.sample(50_010, 0.1), 50_100); | |
| 118 | assert.equal(ema.sample(50_020, 0.2), 50_100); | |
| 119 | assert.equal(ema.sample(50_080, 0.8), 50_100); | |
| 120 | ||
| 121 | // this algorithm estimates somewhat well | |
| 122 | ema = new progress.Ema(); | |
| 123 | assert.equal(ema.sample(50_000, 0), null); | |
| 124 | assert.equal(ema.sample(50_010, 0.1), 50_100); | |
| 125 | assert.equal(ema.sample(50_020, 0.3), 50_087.666666666664); | |
| 126 | assert.equal(ema.sample(50_050, 0.4), 50_109.7); | |
| 127 | assert.equal(ema.sample(50_100, 0.6), 50_142.486666666664); | |
| 128 | assert.equal(ema.sample(50_110, 0.7), 50_143.392785714284); | |
| 129 | assert.equal(ema.sample(50_115, 0.8), 50_137.91067142857); | |
| 130 | assert.equal(ema.sample(50_130, 0.9), 50_141.754246587305); | |
| 131 | }); | |
| 108 | // // ## `progress.Ema` | |
| 109 | // // | |
| 110 | // // the exponential moving average algorithm can be used on its own | |
| 111 | // // | |
| 112 | // test("Ema", () => { | |
| 113 | // // first sample always returns null, if the progress moves linearly then the | |
| 114 | // // estimated time will be the same. | |
| 115 | // let ema = new progress.Ema(); | |
| 116 | // assert.equal(ema.sample(50_000, 0), null); | |
| 117 | // assert.equal(ema.sample(50_010, 0.1), 50_100); | |
| 118 | // assert.equal(ema.sample(50_020, 0.2), 50_100); | |
| 119 | // assert.equal(ema.sample(50_080, 0.8), 50_100); | |
| 120 | ||
| 121 | // // this algorithm estimates somewhat well | |
| 122 | // ema = new progress.Ema(); | |
| 123 | // assert.equal(ema.sample(50_000, 0), null); | |
| 124 | // assert.equal(ema.sample(50_010, 0.1), 50_100); | |
| 125 | // assert.equal(ema.sample(50_020, 0.3), 50_087.666666666664); | |
| 126 | // assert.equal(ema.sample(50_050, 0.4), 50_109.7); | |
| 127 | // assert.equal(ema.sample(50_100, 0.6), 50_142.486666666664); | |
| 128 | // assert.equal(ema.sample(50_110, 0.7), 50_143.392785714284); | |
| 129 | // assert.equal(ema.sample(50_115, 0.8), 50_137.91067142857); | |
| 130 | // assert.equal(ema.sample(50_130, 0.9), 50_141.754246587305); | |
| 131 | // }); | |
| 132 | 132 | |
| 133 | 133 | // ## internals |
| 134 | 134 | test("event encoding round trip cases", async () => { |
lib/progress.ts+11-7| ... | ... | @@ -114,7 +114,7 @@ export interface Node extends Ref, Disposable { |
| 114 | 114 | */ |
| 115 | 115 | inc(this: Node, value?: number): void; |
| 116 | 116 | /** a scoped logger set to output inline on this item. */ |
| 117 | readonly log: log.Scope; | |
| 117 | readonly log: log.RootScope; | |
| 118 | 118 | |
| 119 | 119 | /** |
| 120 | 120 | * a time for when this node is estimated to be completed, in milliseconds |
| ... | ... | @@ -697,10 +697,15 @@ function renderChildren(state: ReadOnlyNode, now: number, depth: boolean[]) { |
| 697 | 697 | truncated += 1; |
| 698 | 698 | continue; |
| 699 | 699 | } |
| 700 | const logLines = child?.logs.slice(-Math.min(3, maxHeight)) ?? []; | |
| 700 | const logLines = child?.logs | |
| 701 | .map((msg) => log.formatMessage(msg, true)) | |
| 702 | .join("") | |
| 703 | .split("\n") | |
| 704 | .slice(-Math.min(3, maxHeight)) ?? []; | |
| 701 | 705 | for (const line of logLines) { |
| 702 | item += left + box.line + " " + ansi.style(ansi.fgBrightBlack, ">") + | |
| 703 | " " + log.formatMessage(line, true) + "\n"; | |
| 706 | item += left + (i === length - 1 && !truncated ? " " : box.line) + " " + | |
| 707 | ansi.style(ansi.fgBrightBlack, ">") + | |
| 708 | " " + line + "\n"; | |
| 704 | 709 | } |
| 705 | 710 | maxHeight -= h + logLines.length; |
| 706 | 711 | out += item; |
| ... | ... | @@ -754,7 +759,7 @@ export function formatUnicodeBar(progress: number, width: number): string { |
| 754 | 759 | export function attachToScreen( |
| 755 | 760 | root: Root, |
| 756 | 761 | { write, startWidget }: Pick< |
| 757 | log.HeadlessWidgetHost, | |
| 762 | log.WidgetHost, | |
| 758 | 763 | "write" | "startWidget" |
| 759 | 764 | >, |
| 760 | 765 | ): ts.Dispose { |
| ... | ... | @@ -783,8 +788,7 @@ export function attachToScreen( |
| 783 | 788 | while (p = p.parent) title = p.text + " / " + title; |
| 784 | 789 | const { logs } = node; |
| 785 | 790 | if (logs.length > 0) { |
| 786 | const count = `${logs.length} line${logs.length === 1 ? "" : "s"}`; | |
| 787 | const header = `[${count} from ${title}]`; | |
| 791 | const header = `[logs from ${title}]`; | |
| 788 | 792 | write(ansi.style(ansi.fgBrightBlack, header) + "\n"); |
| 789 | 793 | write(logs.map((msg) => log.formatMessage(msg, true)).join("")); |
| 790 | 794 | } |
lib/testing.ts+52-20| ... | ... | @@ -150,23 +150,29 @@ export function removeUndefinedKeys(obj: unknown): unknown { |
| 150 | 150 | ); |
| 151 | 151 | } |
| 152 | 152 | |
| 153 | interface WriteCall { | |
| 154 | kind: "interactive" | "output"; | |
| 155 | stack: stack.Frame[]; | |
| 156 | content: string; | |
| 157 | } | |
| 158 | ||
| 153 | 159 | /** implements a log.HeadlessWidgetHost that acts as a fake screen. */ |
| 154 | export class MockScreen implements Disposable, log.HeadlessWidgetHost { | |
| 160 | export class MockScreen implements Disposable, log.WidgetHost { | |
| 155 | 161 | columns = 80; |
| 156 | 162 | rows = 33; |
| 157 | 163 | |
| 158 | 164 | stdout: string = ""; |
| 159 | 165 | stderr: string = ""; |
| 160 | 166 | out: string = ""; |
| 161 | writeCalls = 0; | |
| 167 | writeCalls: WriteCall[] = []; | |
| 162 | 168 | |
| 163 | 169 | timers = new FakeTimers(); |
| 164 | 170 | |
| 165 | write: log.HeadlessWidgetHost["write"]; | |
| 166 | getDrawLock: log.HeadlessWidgetHost["getDrawLock"]; | |
| 167 | startWidget: log.HeadlessWidgetHost["startWidget"]; | |
| 168 | delay: log.HeadlessWidgetHost["delay"]; | |
| 169 | now: log.HeadlessWidgetHost["now"]; | |
| 171 | write: log.WidgetHost["write"]; | |
| 172 | getDrawLock: log.WidgetHost["getDrawLock"]; | |
| 173 | startWidget: log.WidgetHost["startWidget"]; | |
| 174 | delay: log.WidgetHost["delay"]; | |
| 175 | now: log.WidgetHost["now"]; | |
| 170 | 176 | |
| 171 | 177 | hasTerminalLock: null | "locked" | "temporary-unlock" = null; |
| 172 | 178 | |
| ... | ... | @@ -175,20 +181,33 @@ export class MockScreen implements Disposable, log.HeadlessWidgetHost { |
| 175 | 181 | } |
| 176 | 182 | |
| 177 | 183 | constructor({ temporaryUnlocking }: { temporaryUnlocking?: boolean } = {}) { |
| 178 | const host = log.headlessWidgetHost({ | |
| 184 | const callerFile = UNWRAP(stack.capture()[0]); | |
| 185 | const host = log.createWidgetHost({ | |
| 179 | 186 | lockTerminal: () => { |
| 180 | 187 | ASSERT(!this.hasTerminalLock); |
| 181 | 188 | this.hasTerminalLock = "locked"; |
| 182 | 189 | return { |
| 183 | writeInteractive: (text) => { | |
| 184 | this.stderr += text; | |
| 185 | this.out += text; | |
| 186 | this.writeCalls += 1; | |
| 190 | writeInteractive: (content) => { | |
| 191 | this.stderr += content; | |
| 192 | this.out += content; | |
| 193 | const frames = stack.capture().filter(x => x.file !== import.meta.filename); | |
| 194 | const cutoff = frames.findIndex(f => f.file === callerFile.file && f.fn === callerFile.fn) | |
| 195 | this.writeCalls.push({ | |
| 196 | kind: "interactive", | |
| 197 | stack: cutoff === -1 ? frames : frames.slice(0, cutoff), | |
| 198 | content, | |
| 199 | }) | |
| 187 | 200 | }, |
| 188 | writeOutput: (text) => { | |
| 189 | this.stdout += text; | |
| 190 | this.out += text; | |
| 191 | this.writeCalls += 1; | |
| 201 | writeOutput: (content) => { | |
| 202 | this.stdout += content; | |
| 203 | this.out += content; | |
| 204 | const frames = stack.capture().filter(x => x.file !== import.meta.filename); | |
| 205 | const cutoff = frames.findIndex(f => f.file === callerFile.file && f.fn === callerFile.fn) | |
| 206 | this.writeCalls.push({ | |
| 207 | kind: "output", | |
| 208 | stack: cutoff === -1 ? frames : frames.slice(0, cutoff), | |
| 209 | content, | |
| 210 | }) | |
| 192 | 211 | }, |
| 193 | 212 | getSize: () => { |
| 194 | 213 | return this; |
| ... | ... | @@ -234,7 +253,7 @@ export class MockScreen implements Disposable, log.HeadlessWidgetHost { |
| 234 | 253 | if (ms != null) { |
| 235 | 254 | const wait = UNWRAP( |
| 236 | 255 | this.timers.entries.shift(), |
| 237 | "no call to MockScreen.delay", | |
| 256 | () => this.out.length > 0 ? "terminal i/o did not wait" : "no terminal i/o", | |
| 238 | 257 | ); |
| 239 | 258 | ASSERT( |
| 240 | 259 | ms === wait.duration, |
| ... | ... | @@ -255,25 +274,38 @@ export class MockScreen implements Disposable, log.HeadlessWidgetHost { |
| 255 | 274 | () => |
| 256 | 275 | `merged out does not match\n` + |
| 257 | 276 | `expected: ${ansi.debugAnsi(out ?? "")}\n` + |
| 258 | `actual: ${ansi.debugAnsi(this.out)}\n`, | |
| 277 | `actual: ${ansi.debugAnsi(this.out)}\n` + | |
| 278 | this.fmtWriteCalls(), | |
| 259 | 279 | ); |
| 260 | 280 | ASSERT( |
| 261 | 281 | stdout == null || this.stdout === stdout, |
| 262 | 282 | () => |
| 263 | 283 | `standard out does not match\n` + |
| 264 | 284 | `expected: ${ansi.debugAnsi(stdout ?? "")}\n` + |
| 265 | `actual: ${ansi.debugAnsi(this.stdout)}\n`, | |
| 285 | `actual: ${ansi.debugAnsi(this.stdout)}\n` + | |
| 286 | this.fmtWriteCalls(), | |
| 266 | 287 | ); |
| 267 | 288 | ASSERT( |
| 268 | 289 | stderr == null || this.stderr === stderr, |
| 269 | 290 | () => |
| 270 | 291 | `interactive out does not match\n` + |
| 271 | 292 | `expected: ${ansi.debugAnsi(stderr ?? "")}\n` + |
| 272 | `actual: ${ansi.debugAnsi(this.stderr)}\n`, | |
| 293 | `actual: ${ansi.debugAnsi(this.stderr)}\n` + | |
| 294 | this.fmtWriteCalls(), | |
| 273 | 295 | ); |
| 274 | 296 | this.stdout = ""; |
| 275 | 297 | this.stderr = ""; |
| 276 | 298 | this.out = ""; |
| 299 | this.writeCalls = []; | |
| 300 | } | |
| 301 | ||
| 302 | fmtWriteCalls() { | |
| 303 | return `\n${ansi.reset}breakdown of calls that rendered this frame:\n` + | |
| 304 | this.writeCalls.map((call, i) => | |
| 305 | `${i + 1}. [${call.kind}] ${ansi.debugAnsi(call.content)}\n` | |
| 306 | + call.stack.map(frame => ansi.reset + stack.formatFrame(frame, true)).join('\n') + | |
| 307 | `\n` | |
| 308 | ).join('\n').replaceAll(ansi.fgReset, ansi.reset) | |
| 277 | 309 | } |
| 278 | 310 | |
| 279 | 311 | [Symbol.dispose]() { |