Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions packages/jsEval/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
Expand Down
77 changes: 77 additions & 0 deletions packages/jsEval/src/console.ts
Original file line number Diff line number Diff line change
@@ -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<string, number>();

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) });
},
};
}
2 changes: 2 additions & 0 deletions packages/jsEval/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,4 @@
export { replLikeEval } from "./eval";
export { checkSyntax } from "./syntax";
export { createReplConsole } from "./console";
export type { ConsoleOutput, ConsoleEmitter, ReplConsole } from "./console";
99 changes: 99 additions & 0 deletions packages/jsEval/tests/console.spec.ts
Original file line number Diff line number Diff line change
@@ -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"));
});
});
});
81 changes: 4 additions & 77 deletions packages/runtime/src/worker/jsEval.worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>) | null =
null;
let pendingOutputPromise: Promise<void>[] = [];
const consoleTimers = new Map<string, number>();

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<{
Expand Down
58 changes: 0 additions & 58 deletions packages/runtime/tests/repl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,64 +157,6 @@ export const replTests: Record<string, (lang: RuntimeLang) => 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<RuntimeLang, string | null>
)[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<RuntimeLang, string | null>
)[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!";
Expand Down
Loading