From 5beebc975972f8b8da4e4fdfa0259aa47ffeeaf7 Mon Sep 17 00:00:00 2001 From: tknkaa Date: Fri, 24 Jul 2026 09:46:55 +0900 Subject: [PATCH] =?UTF-8?q?console=E3=81=AE=E5=AE=9F=E8=A3=85=E3=81=A8?= =?UTF-8?q?=E3=83=86=E3=82=B9=E3=83=88=E3=82=92packages/jsEval/=E3=81=AB?= =?UTF-8?q?=E7=A7=BB=E5=8B=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit replのテストファイルにJS専用のconsole.time/timeEndテストが混在していたため、 consoleの実装(createReplConsole)をjsEval側に切り出し、対応するテストも移動した。 Co-Authored-By: Claude Sonnet 5 --- package-lock.json | 4 + packages/jsEval/package.json | 4 + packages/jsEval/src/console.ts | 77 +++++++++++++++ packages/jsEval/src/index.ts | 2 + packages/jsEval/tests/console.spec.ts | 99 ++++++++++++++++++++ packages/runtime/src/worker/jsEval.worker.ts | 81 +--------------- packages/runtime/tests/repl.ts | 58 ------------ 7 files changed, 190 insertions(+), 135 deletions(-) create mode 100644 packages/jsEval/src/console.ts create mode 100644 packages/jsEval/tests/console.spec.ts diff --git a/package-lock.json b/package-lock.json index 769ee141..0adbc6bb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23321,7 +23321,11 @@ "packages/jsEval": { "name": "@my-code/js-eval", "version": "0.1.0", + "dependencies": { + "object-inspect": "^1.13.4" + }, "devDependencies": { + "@types/object-inspect": "^1.13.0", "tsx": "^4", "typescript": "^5" } diff --git a/packages/jsEval/package.json b/packages/jsEval/package.json index c62edc57..fe8bd733 100644 --- a/packages/jsEval/package.json +++ b/packages/jsEval/package.json @@ -9,7 +9,11 @@ "scripts": { "test": "node --import tsx/esm --test tests/*" }, + "dependencies": { + "object-inspect": "^1.13.4" + }, "devDependencies": { + "@types/object-inspect": "^1.13.0", "tsx": "^4", "typescript": "^5" } diff --git a/packages/jsEval/src/console.ts b/packages/jsEval/src/console.ts new file mode 100644 index 00000000..5c7d2a7a --- /dev/null +++ b/packages/jsEval/src/console.ts @@ -0,0 +1,77 @@ +import inspect from "object-inspect"; + +export type ConsoleOutput = + | { type: "stdout"; message: string } + | { type: "stderr"; message: string }; + +export type ConsoleEmitter = (output: ConsoleOutput) => void; + +export interface ReplConsole { + time: (label?: unknown) => void; + timeEnd: (label?: unknown) => void; + log: (...args: unknown[]) => void; + error: (...args: unknown[]) => void; + warn: (...args: unknown[]) => void; + info: (...args: unknown[]) => void; +} + +function format(...args: unknown[]): string { + // TODO: console.logの第1引数はフォーマット指定文字列を取ることができる + // https://nodejs.org/api/util.html#utilformatformat-args + return args.map((a) => (typeof a === "string" ? a : inspect(a))).join(" "); +} + +function formatElapsedTime(ms: number): string { + if (ms < 1000) return `${ms.toFixed(3)}ms`; + return `${(ms / 1000).toFixed(3)}s`; +} + +/** + * REPL用のconsole実装を作成します。 + * console.time/timeEndのラベル管理を含め、出力はすべてemitに渡されます。 + */ +export function createReplConsole(emit: ConsoleEmitter): ReplConsole { + const timers = new Map(); + + return { + time: (label: unknown = "default") => { + const key = String(label); + if (timers.has(key)) { + emit({ + type: "stderr", + message: `Warning: Label '${key}' already exists for console.time()`, + }); + return; + } + timers.set(key, performance.now()); + }, + timeEnd: (label: unknown = "default") => { + const key = String(label); + const start = timers.get(key); + if (start === undefined) { + emit({ + type: "stderr", + message: `Warning: No such label '${key}' for console.timeEnd()`, + }); + return; + } + timers.delete(key); + emit({ + type: "stdout", + message: `${key}: ${formatElapsedTime(performance.now() - start)}`, + }); + }, + log: (...args: unknown[]) => { + emit({ type: "stdout", message: format(...args) }); + }, + error: (...args: unknown[]) => { + emit({ type: "stderr", message: format(...args) }); + }, + warn: (...args: unknown[]) => { + emit({ type: "stderr", message: format(...args) }); + }, + info: (...args: unknown[]) => { + emit({ type: "stdout", message: format(...args) }); + }, + }; +} diff --git a/packages/jsEval/src/index.ts b/packages/jsEval/src/index.ts index 6a98a229..a90839c0 100644 --- a/packages/jsEval/src/index.ts +++ b/packages/jsEval/src/index.ts @@ -1,2 +1,4 @@ export { replLikeEval } from "./eval"; export { checkSyntax } from "./syntax"; +export { createReplConsole } from "./console"; +export type { ConsoleOutput, ConsoleEmitter, ReplConsole } from "./console"; diff --git a/packages/jsEval/tests/console.spec.ts b/packages/jsEval/tests/console.spec.ts new file mode 100644 index 00000000..82cb52f6 --- /dev/null +++ b/packages/jsEval/tests/console.spec.ts @@ -0,0 +1,99 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { createReplConsole, type ConsoleOutput } from "../src/index.js"; + +function collect() { + const outputs: ConsoleOutput[] = []; + const replConsole = createReplConsole((output) => outputs.push(output)); + return { outputs, replConsole }; +} + +describe("createReplConsole", () => { + describe("log", () => { + it("emits stdout with joined, formatted arguments", () => { + const { outputs, replConsole } = collect(); + replConsole.log("hello", 42, { a: 1 }); + assert.deepStrictEqual(outputs, [ + { type: "stdout", message: 'hello 42 { a: 1 }' }, + ]); + }); + }); + + describe("error", () => { + it("emits stderr", () => { + const { outputs, replConsole } = collect(); + replConsole.error("boom"); + assert.deepStrictEqual(outputs, [{ type: "stderr", message: "boom" }]); + }); + }); + + describe("warn", () => { + it("emits stderr", () => { + const { outputs, replConsole } = collect(); + replConsole.warn("careful"); + assert.deepStrictEqual(outputs, [ + { type: "stderr", message: "careful" }, + ]); + }); + }); + + describe("info", () => { + it("emits stdout", () => { + const { outputs, replConsole } = collect(); + replConsole.info("fyi"); + assert.deepStrictEqual(outputs, [{ type: "stdout", message: "fyi" }]); + }); + }); + + describe("time / timeEnd", () => { + it("emits elapsed time on stdout for a matching label", () => { + const { outputs, replConsole } = collect(); + replConsole.time("t"); + replConsole.timeEnd("t"); + assert.strictEqual(outputs.length, 1); + assert.strictEqual(outputs[0]?.type, "stdout"); + assert.match(outputs[0]!.message, /^t: \d+(\.\d+)?m?s$/); + }); + + it("defaults the label to 'default'", () => { + const { outputs, replConsole } = collect(); + replConsole.time(); + replConsole.timeEnd(); + assert.strictEqual(outputs.length, 1); + assert.match(outputs[0]!.message, /^default: \d+(\.\d+)?m?s$/); + }); + + it("warns on stderr when starting a timer with a label already in use", () => { + const { outputs, replConsole } = collect(); + replConsole.time("t"); + replConsole.time("t"); + assert.deepStrictEqual(outputs, [ + { + type: "stderr", + message: "Warning: Label 't' already exists for console.time()", + }, + ]); + }); + + it("warns on stderr when ending a timer with no matching label", () => { + const { outputs, replConsole } = collect(); + replConsole.timeEnd("missing"); + assert.deepStrictEqual(outputs, [ + { + type: "stderr", + message: "Warning: No such label 'missing' for console.timeEnd()", + }, + ]); + }); + + it("allows reusing a label after it has been ended", () => { + const { outputs, replConsole } = collect(); + replConsole.time("t"); + replConsole.timeEnd("t"); + replConsole.time("t"); + replConsole.timeEnd("t"); + assert.strictEqual(outputs.length, 2); + assert.ok(outputs.every((o) => o.type === "stdout")); + }); + }); +}); diff --git a/packages/runtime/src/worker/jsEval.worker.ts b/packages/runtime/src/worker/jsEval.worker.ts index 86f38860..561e8a45 100644 --- a/packages/runtime/src/worker/jsEval.worker.ts +++ b/packages/runtime/src/worker/jsEval.worker.ts @@ -4,94 +4,21 @@ import { expose } from "comlink"; import type { ReplOutput, UpdatedFile } from "../interface"; import type { WorkerAPI, WorkerCapabilities } from "./runtime"; import inspect from "object-inspect"; -import { replLikeEval, checkSyntax } from "@my-code/js-eval"; +import { replLikeEval, checkSyntax, createReplConsole } from "@my-code/js-eval"; -function format(...args: unknown[]): string { - // TODO: console.logの第1引数はフォーマット指定文字列を取ることができる - // https://nodejs.org/api/util.html#utilformatformat-args - return args.map((a) => (typeof a === "string" ? a : inspect(a))).join(" "); -} let currentOutputCallback: ((output: ReplOutput) => Promise) | null = null; let pendingOutputPromise: Promise[] = []; -const consoleTimers = new Map(); - -function formatElapsedTime(ms: number): string { - if (ms < 1000) return `${ms.toFixed(3)}ms`; - return `${(ms / 1000).toFixed(3)}s`; -} // Helper function to capture console output const originalConsole = self.console; self.console = { ...originalConsole, - time: (label: unknown = "default") => { - const key = String(label); - if (consoleTimers.has(key)) { - if (currentOutputCallback) { - pendingOutputPromise.push( - currentOutputCallback({ - type: "stderr", - message: `Warning: Label '${key}' already exists for console.time()`, - }) - ); - } - return; - } - consoleTimers.set(key, performance.now()); - }, - timeEnd: (label: unknown = "default") => { - const key = String(label); - const start = consoleTimers.get(key); - if (start === undefined) { - if (currentOutputCallback) { - pendingOutputPromise.push( - currentOutputCallback({ - type: "stderr", - message: `Warning: No such label '${key}' for console.timeEnd()`, - }) - ); - } - return; - } - consoleTimers.delete(key); - if (currentOutputCallback) { - pendingOutputPromise.push( - currentOutputCallback({ - type: "stdout", - message: `${key}: ${formatElapsedTime(performance.now() - start)}`, - }) - ); - } - }, - log: (...args: unknown[]) => { - if (currentOutputCallback) { - pendingOutputPromise.push( - currentOutputCallback({ type: "stdout", message: format(...args) }) - ); - } - }, - error: (...args: unknown[]) => { - if (currentOutputCallback) { - pendingOutputPromise.push( - currentOutputCallback({ type: "stderr", message: format(...args) }) - ); - } - }, - warn: (...args: unknown[]) => { - if (currentOutputCallback) { - pendingOutputPromise.push( - currentOutputCallback({ type: "stderr", message: format(...args) }) - ); - } - }, - info: (...args: unknown[]) => { + ...createReplConsole((output) => { if (currentOutputCallback) { - pendingOutputPromise.push( - currentOutputCallback({ type: "stdout", message: format(...args) }) - ); + pendingOutputPromise.push(currentOutputCallback(output)); } - }, + }), }; async function init(/*_interruptBuffer?: Uint8Array*/): Promise<{ diff --git a/packages/runtime/tests/repl.ts b/packages/runtime/tests/repl.ts index 36587b2d..e47d5da3 100644 --- a/packages/runtime/tests/repl.ts +++ b/packages/runtime/tests/repl.ts @@ -157,64 +157,6 @@ export const replTests: Record TestBody | null> = }; }, - "should support console.time and console.timeEnd": (lang) => { - const timeCode = ( - { - python: null, - ruby: null, - cpp: null, - rust: null, - javascript: `console.time("t"); console.timeEnd("t")`, - typescript: null, - } satisfies Record - )[lang]; - if (!timeCode) return null; - - return async (runtimeRef) => { - const outputs: ReplOutput[] = []; - await (runtimeRef.current![lang].mutex || emptyMutex).runExclusive(() => - runtimeRef.current![lang].runCommand!(timeCode, (output) => { - if (output.type !== "file") outputs.push(output); - }) - ); - console.log(`${lang} REPL console.time test: `, outputs); - expect( - outputs.some( - (o) => o.type === "stdout" && /^t: \d+(\.\d+)?m?s$/.test(o.message) - ) - ).to.be.true; - }; - }, - - "should warn on console.timeEnd with no matching console.time": (lang) => { - const timeCode = ( - { - python: null, - ruby: null, - cpp: null, - rust: null, - javascript: `console.timeEnd("missing")`, - typescript: null, - } satisfies Record - )[lang]; - if (!timeCode) return null; - - return async (runtimeRef) => { - const outputs: ReplOutput[] = []; - await (runtimeRef.current![lang].mutex || emptyMutex).runExclusive(() => - runtimeRef.current![lang].runCommand!(timeCode, (output) => { - if (output.type !== "file") outputs.push(output); - }) - ); - console.log(`${lang} REPL console.timeEnd warning test: `, outputs); - expect( - outputs.some( - (o) => o.type === "stderr" && o.message.includes("missing") - ) - ).to.be.true; - }; - }, - "should capture files modified by command": (lang) => { const targetFile = "test.txt"; const msg = "Hello, World!";