Skip to content

Commit 05407e5

Browse files
authored
Merge pull request #226 from ut-code/refactor/move-console-to-jseval
consoleの実装とテストをpackages/jsEval/に移動
2 parents ece8c3c + 5beebc9 commit 05407e5

7 files changed

Lines changed: 190 additions & 135 deletions

File tree

package-lock.json

Lines changed: 4 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/jsEval/package.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,11 @@
99
"scripts": {
1010
"test": "node --import tsx/esm --test tests/*"
1111
},
12+
"dependencies": {
13+
"object-inspect": "^1.13.4"
14+
},
1215
"devDependencies": {
16+
"@types/object-inspect": "^1.13.0",
1317
"tsx": "^4",
1418
"typescript": "^5"
1519
}

packages/jsEval/src/console.ts

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import inspect from "object-inspect";
2+
3+
export type ConsoleOutput =
4+
| { type: "stdout"; message: string }
5+
| { type: "stderr"; message: string };
6+
7+
export type ConsoleEmitter = (output: ConsoleOutput) => void;
8+
9+
export interface ReplConsole {
10+
time: (label?: unknown) => void;
11+
timeEnd: (label?: unknown) => void;
12+
log: (...args: unknown[]) => void;
13+
error: (...args: unknown[]) => void;
14+
warn: (...args: unknown[]) => void;
15+
info: (...args: unknown[]) => void;
16+
}
17+
18+
function format(...args: unknown[]): string {
19+
// TODO: console.logの第1引数はフォーマット指定文字列を取ることができる
20+
// https://nodejs.org/api/util.html#utilformatformat-args
21+
return args.map((a) => (typeof a === "string" ? a : inspect(a))).join(" ");
22+
}
23+
24+
function formatElapsedTime(ms: number): string {
25+
if (ms < 1000) return `${ms.toFixed(3)}ms`;
26+
return `${(ms / 1000).toFixed(3)}s`;
27+
}
28+
29+
/**
30+
* REPL用のconsole実装を作成します。
31+
* console.time/timeEndのラベル管理を含め、出力はすべてemitに渡されます。
32+
*/
33+
export function createReplConsole(emit: ConsoleEmitter): ReplConsole {
34+
const timers = new Map<string, number>();
35+
36+
return {
37+
time: (label: unknown = "default") => {
38+
const key = String(label);
39+
if (timers.has(key)) {
40+
emit({
41+
type: "stderr",
42+
message: `Warning: Label '${key}' already exists for console.time()`,
43+
});
44+
return;
45+
}
46+
timers.set(key, performance.now());
47+
},
48+
timeEnd: (label: unknown = "default") => {
49+
const key = String(label);
50+
const start = timers.get(key);
51+
if (start === undefined) {
52+
emit({
53+
type: "stderr",
54+
message: `Warning: No such label '${key}' for console.timeEnd()`,
55+
});
56+
return;
57+
}
58+
timers.delete(key);
59+
emit({
60+
type: "stdout",
61+
message: `${key}: ${formatElapsedTime(performance.now() - start)}`,
62+
});
63+
},
64+
log: (...args: unknown[]) => {
65+
emit({ type: "stdout", message: format(...args) });
66+
},
67+
error: (...args: unknown[]) => {
68+
emit({ type: "stderr", message: format(...args) });
69+
},
70+
warn: (...args: unknown[]) => {
71+
emit({ type: "stderr", message: format(...args) });
72+
},
73+
info: (...args: unknown[]) => {
74+
emit({ type: "stdout", message: format(...args) });
75+
},
76+
};
77+
}

packages/jsEval/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,4 @@
11
export { replLikeEval } from "./eval";
22
export { checkSyntax } from "./syntax";
3+
export { createReplConsole } from "./console";
4+
export type { ConsoleOutput, ConsoleEmitter, ReplConsole } from "./console";
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import { describe, it } from "node:test";
2+
import assert from "node:assert/strict";
3+
import { createReplConsole, type ConsoleOutput } from "../src/index.js";
4+
5+
function collect() {
6+
const outputs: ConsoleOutput[] = [];
7+
const replConsole = createReplConsole((output) => outputs.push(output));
8+
return { outputs, replConsole };
9+
}
10+
11+
describe("createReplConsole", () => {
12+
describe("log", () => {
13+
it("emits stdout with joined, formatted arguments", () => {
14+
const { outputs, replConsole } = collect();
15+
replConsole.log("hello", 42, { a: 1 });
16+
assert.deepStrictEqual(outputs, [
17+
{ type: "stdout", message: 'hello 42 { a: 1 }' },
18+
]);
19+
});
20+
});
21+
22+
describe("error", () => {
23+
it("emits stderr", () => {
24+
const { outputs, replConsole } = collect();
25+
replConsole.error("boom");
26+
assert.deepStrictEqual(outputs, [{ type: "stderr", message: "boom" }]);
27+
});
28+
});
29+
30+
describe("warn", () => {
31+
it("emits stderr", () => {
32+
const { outputs, replConsole } = collect();
33+
replConsole.warn("careful");
34+
assert.deepStrictEqual(outputs, [
35+
{ type: "stderr", message: "careful" },
36+
]);
37+
});
38+
});
39+
40+
describe("info", () => {
41+
it("emits stdout", () => {
42+
const { outputs, replConsole } = collect();
43+
replConsole.info("fyi");
44+
assert.deepStrictEqual(outputs, [{ type: "stdout", message: "fyi" }]);
45+
});
46+
});
47+
48+
describe("time / timeEnd", () => {
49+
it("emits elapsed time on stdout for a matching label", () => {
50+
const { outputs, replConsole } = collect();
51+
replConsole.time("t");
52+
replConsole.timeEnd("t");
53+
assert.strictEqual(outputs.length, 1);
54+
assert.strictEqual(outputs[0]?.type, "stdout");
55+
assert.match(outputs[0]!.message, /^t: \d+(\.\d+)?m?s$/);
56+
});
57+
58+
it("defaults the label to 'default'", () => {
59+
const { outputs, replConsole } = collect();
60+
replConsole.time();
61+
replConsole.timeEnd();
62+
assert.strictEqual(outputs.length, 1);
63+
assert.match(outputs[0]!.message, /^default: \d+(\.\d+)?m?s$/);
64+
});
65+
66+
it("warns on stderr when starting a timer with a label already in use", () => {
67+
const { outputs, replConsole } = collect();
68+
replConsole.time("t");
69+
replConsole.time("t");
70+
assert.deepStrictEqual(outputs, [
71+
{
72+
type: "stderr",
73+
message: "Warning: Label 't' already exists for console.time()",
74+
},
75+
]);
76+
});
77+
78+
it("warns on stderr when ending a timer with no matching label", () => {
79+
const { outputs, replConsole } = collect();
80+
replConsole.timeEnd("missing");
81+
assert.deepStrictEqual(outputs, [
82+
{
83+
type: "stderr",
84+
message: "Warning: No such label 'missing' for console.timeEnd()",
85+
},
86+
]);
87+
});
88+
89+
it("allows reusing a label after it has been ended", () => {
90+
const { outputs, replConsole } = collect();
91+
replConsole.time("t");
92+
replConsole.timeEnd("t");
93+
replConsole.time("t");
94+
replConsole.timeEnd("t");
95+
assert.strictEqual(outputs.length, 2);
96+
assert.ok(outputs.every((o) => o.type === "stdout"));
97+
});
98+
});
99+
});

packages/runtime/src/worker/jsEval.worker.ts

Lines changed: 4 additions & 77 deletions
Original file line numberDiff line numberDiff line change
@@ -4,94 +4,21 @@ import { expose } from "comlink";
44
import type { ReplOutput, UpdatedFile } from "../interface";
55
import type { WorkerAPI, WorkerCapabilities } from "./runtime";
66
import inspect from "object-inspect";
7-
import { replLikeEval, checkSyntax } from "@my-code/js-eval";
7+
import { replLikeEval, checkSyntax, createReplConsole } from "@my-code/js-eval";
88

9-
function format(...args: unknown[]): string {
10-
// TODO: console.logの第1引数はフォーマット指定文字列を取ることができる
11-
// https://nodejs.org/api/util.html#utilformatformat-args
12-
return args.map((a) => (typeof a === "string" ? a : inspect(a))).join(" ");
13-
}
149
let currentOutputCallback: ((output: ReplOutput) => Promise<void>) | null =
1510
null;
1611
let pendingOutputPromise: Promise<void>[] = [];
17-
const consoleTimers = new Map<string, number>();
18-
19-
function formatElapsedTime(ms: number): string {
20-
if (ms < 1000) return `${ms.toFixed(3)}ms`;
21-
return `${(ms / 1000).toFixed(3)}s`;
22-
}
2312

2413
// Helper function to capture console output
2514
const originalConsole = self.console;
2615
self.console = {
2716
...originalConsole,
28-
time: (label: unknown = "default") => {
29-
const key = String(label);
30-
if (consoleTimers.has(key)) {
31-
if (currentOutputCallback) {
32-
pendingOutputPromise.push(
33-
currentOutputCallback({
34-
type: "stderr",
35-
message: `Warning: Label '${key}' already exists for console.time()`,
36-
})
37-
);
38-
}
39-
return;
40-
}
41-
consoleTimers.set(key, performance.now());
42-
},
43-
timeEnd: (label: unknown = "default") => {
44-
const key = String(label);
45-
const start = consoleTimers.get(key);
46-
if (start === undefined) {
47-
if (currentOutputCallback) {
48-
pendingOutputPromise.push(
49-
currentOutputCallback({
50-
type: "stderr",
51-
message: `Warning: No such label '${key}' for console.timeEnd()`,
52-
})
53-
);
54-
}
55-
return;
56-
}
57-
consoleTimers.delete(key);
58-
if (currentOutputCallback) {
59-
pendingOutputPromise.push(
60-
currentOutputCallback({
61-
type: "stdout",
62-
message: `${key}: ${formatElapsedTime(performance.now() - start)}`,
63-
})
64-
);
65-
}
66-
},
67-
log: (...args: unknown[]) => {
68-
if (currentOutputCallback) {
69-
pendingOutputPromise.push(
70-
currentOutputCallback({ type: "stdout", message: format(...args) })
71-
);
72-
}
73-
},
74-
error: (...args: unknown[]) => {
75-
if (currentOutputCallback) {
76-
pendingOutputPromise.push(
77-
currentOutputCallback({ type: "stderr", message: format(...args) })
78-
);
79-
}
80-
},
81-
warn: (...args: unknown[]) => {
82-
if (currentOutputCallback) {
83-
pendingOutputPromise.push(
84-
currentOutputCallback({ type: "stderr", message: format(...args) })
85-
);
86-
}
87-
},
88-
info: (...args: unknown[]) => {
17+
...createReplConsole((output) => {
8918
if (currentOutputCallback) {
90-
pendingOutputPromise.push(
91-
currentOutputCallback({ type: "stdout", message: format(...args) })
92-
);
19+
pendingOutputPromise.push(currentOutputCallback(output));
9320
}
94-
},
21+
}),
9522
};
9623

9724
async function init(/*_interruptBuffer?: Uint8Array*/): Promise<{

packages/runtime/tests/repl.ts

Lines changed: 0 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -157,64 +157,6 @@ export const replTests: Record<string, (lang: RuntimeLang) => TestBody | null> =
157157
};
158158
},
159159

160-
"should support console.time and console.timeEnd": (lang) => {
161-
const timeCode = (
162-
{
163-
python: null,
164-
ruby: null,
165-
cpp: null,
166-
rust: null,
167-
javascript: `console.time("t"); console.timeEnd("t")`,
168-
typescript: null,
169-
} satisfies Record<RuntimeLang, string | null>
170-
)[lang];
171-
if (!timeCode) return null;
172-
173-
return async (runtimeRef) => {
174-
const outputs: ReplOutput[] = [];
175-
await (runtimeRef.current![lang].mutex || emptyMutex).runExclusive(() =>
176-
runtimeRef.current![lang].runCommand!(timeCode, (output) => {
177-
if (output.type !== "file") outputs.push(output);
178-
})
179-
);
180-
console.log(`${lang} REPL console.time test: `, outputs);
181-
expect(
182-
outputs.some(
183-
(o) => o.type === "stdout" && /^t: \d+(\.\d+)?m?s$/.test(o.message)
184-
)
185-
).to.be.true;
186-
};
187-
},
188-
189-
"should warn on console.timeEnd with no matching console.time": (lang) => {
190-
const timeCode = (
191-
{
192-
python: null,
193-
ruby: null,
194-
cpp: null,
195-
rust: null,
196-
javascript: `console.timeEnd("missing")`,
197-
typescript: null,
198-
} satisfies Record<RuntimeLang, string | null>
199-
)[lang];
200-
if (!timeCode) return null;
201-
202-
return async (runtimeRef) => {
203-
const outputs: ReplOutput[] = [];
204-
await (runtimeRef.current![lang].mutex || emptyMutex).runExclusive(() =>
205-
runtimeRef.current![lang].runCommand!(timeCode, (output) => {
206-
if (output.type !== "file") outputs.push(output);
207-
})
208-
);
209-
console.log(`${lang} REPL console.timeEnd warning test: `, outputs);
210-
expect(
211-
outputs.some(
212-
(o) => o.type === "stderr" && o.message.includes("missing")
213-
)
214-
).to.be.true;
215-
};
216-
},
217-
218160
"should capture files modified by command": (lang) => {
219161
const targetFile = "test.txt";
220162
const msg = "Hello, World!";

0 commit comments

Comments
 (0)