Skip to content
Draft
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
14 changes: 14 additions & 0 deletions .github/workflows/node.js.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,3 +32,17 @@ jobs:
cache: 'npm'
- run: npm ci
- run: npm run tsc

test-js-eval:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [22.x]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: 'npm'
- run: npm ci
- run: npm test --workspace=packages/jsEval
62 changes: 1 addition & 61 deletions app/terminal/worker/jsEval.worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { expose } from "comlink";
import type { ReplOutput } from "../repl";
import type { WorkerCapabilities } from "./runtime";
import inspect from "object-inspect";
import { replLikeEval, checkSyntax } from "@my-code/js-eval";

function format(...args: unknown[]): string {
// TODO: console.logの第1引数はフォーマット指定文字列を取ることができる
Expand Down Expand Up @@ -34,41 +35,6 @@ async function init(/*_interruptBuffer?: Uint8Array*/): Promise<{
return { capabilities: { interrupt: "restart" } };
}

async function replLikeEval(code: string): Promise<unknown> {
// eval()の中でconst,letを使って変数を作成した場合、
// 次に実行するコマンドはスコープ外扱いでありアクセスできなくなってしまうので、
// varに置き換えている
if (code.trim().startsWith("const ")) {
code = "var " + code.trim().slice(6);
} else if (code.trim().startsWith("let ")) {
code = "var " + code.trim().slice(4);
}
// eval()の中でclassを作成した場合も同様
const classRegExp = /^\s*class\s+(\w+)/;
if (classRegExp.test(code)) {
code = code.replace(classRegExp, "var $1 = class $1");
}

if (code.trim().startsWith("{") && code.trim().endsWith("}")) {
// オブジェクトは ( ) で囲わなければならない
try {
return self.eval(`(${code})`);
} catch (e) {
if (e instanceof SyntaxError) {
// オブジェクトではなくブロックだった場合、再度普通に実行
return self.eval(code);
} else {
throw e;
}
}
} else if (/^\s*await\W/.test(code)) {
// promiseをawaitする場合は、promiseの部分だけをevalし、それを外からawaitする
return await self.eval(code.trim().slice(5));
} else {
return self.eval(code);
}
}

async function runCode(
code: string,
onOutput: (output: ReplOutput) => void
Expand Down Expand Up @@ -129,32 +95,6 @@ function runFile(
return { updatedFiles: {} as Record<string, string> };
}

async function checkSyntax(
code: string
): Promise<{ status: "complete" | "incomplete" | "invalid" }> {
try {
// Try to create a Function to check syntax
// new Function(code); // <- not working
self.eval(`() => {${code}}`);
return { status: "complete" };
} catch (e) {
// Check if it's a syntax error or if more input is expected
if (e instanceof SyntaxError) {
// Simple heuristic: check for "Unexpected end of input"
if (
e.message.includes("Unexpected token '}'") ||
e.message.includes("Unexpected end of input")
) {
return { status: "incomplete" };
} else {
return { status: "invalid" };
}
} else {
return { status: "invalid" };
}
}
}

async function restoreState(commands: string[]): Promise<object> {
// Re-execute all previously successful commands to restore state
for (const command of commands) {
Expand Down
16 changes: 16 additions & 0 deletions package-lock.json

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

3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@
"version": "0.1.0",
"private": true,
"type": "module",
"workspaces": [
"packages/*"
],
"scripts": {
"dev": "npm run cf-typegen && npm run generateLanguages && npm run generateSections && npm run copyAllDTSFiles && npm run removeHinting && next dev",
"build": "npm run cf-typegen && npm run generateLanguages && npm run generateSections && npm run copyAllDTSFiles && npm run removeHinting && next build",
Expand Down
16 changes: 16 additions & 0 deletions packages/jsEval/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
{
"name": "@my-code/js-eval",
"version": "0.1.0",
"private": true,
"type": "module",
"exports": {
".": "./src/index.ts"
},
"scripts": {
"test": "node --import tsx/esm --test src/index.test.ts"
},
"devDependencies": {
"tsx": "*",
"typescript": "*"
}
}
205 changes: 205 additions & 0 deletions packages/jsEval/src/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { replLikeEval, checkSyntax } from "./index.ts";

Check failure on line 3 in packages/jsEval/src/index.test.ts

View workflow job for this annotation

GitHub Actions / types (22.x)

An import path can only end with a '.ts' extension when 'allowImportingTsExtensions' is enabled.

// ---------------------------------------------------------------------------
// replLikeEval
// ---------------------------------------------------------------------------

describe("replLikeEval", () => {
describe("var declaration", () => {
it("evaluates var and returns undefined", async () => {
const result = await replLikeEval("var x = 42");
assert.strictEqual(result, undefined);
});
});

describe("const declaration", () => {
it("converts const to var and returns undefined", async () => {
const result = await replLikeEval("const constVar = 1");
assert.strictEqual(result, undefined);
});
});

describe("let declaration", () => {
it("converts let to var and returns undefined", async () => {
const result = await replLikeEval("let letVar = 2");
assert.strictEqual(result, undefined);
});
});

describe("undeclared variable assignment", () => {
it("assigns to global and returns the assigned value", async () => {
const result = await replLikeEval("undeclaredVar = 99");
assert.strictEqual(result, 99);
});
});

describe("expression evaluation", () => {
it("returns numeric result", async () => {
assert.strictEqual(await replLikeEval("1 + 2"), 3);
});

it("returns string result", async () => {
assert.strictEqual(await replLikeEval('"hello"'), "hello");
});

it("returns boolean result", async () => {
assert.strictEqual(await replLikeEval("true"), true);
});
});

describe("function declaration", () => {
it("declares a function and returns undefined", async () => {
const result = await replLikeEval("function greet() { return 'hi'; }");
assert.strictEqual(result, undefined);
});
});

describe("class declaration", () => {
it("converts class to var-assigned class expression and returns undefined", async () => {
const result = await replLikeEval("class MyClass { constructor() {} }");
assert.strictEqual(result, undefined);
});
});

describe("array literal", () => {
it("returns an array", async () => {
const result = await replLikeEval("[1, 2, 3]");
assert.deepStrictEqual(result, [1, 2, 3]);
});

it("returns an empty array", async () => {
assert.deepStrictEqual(await replLikeEval("[]"), []);
});
});

describe("object literal", () => {
it("returns an object for { a: 1 }", async () => {
const result = await replLikeEval("{ a: 1 }");
assert.deepStrictEqual(result, { a: 1 });
});

it("returns an empty object for {}", async () => {
assert.deepStrictEqual(await replLikeEval("{}"), {});
});
});

describe("block that looks like an object", () => {
it("executes a labelled statement block and returns undefined", async () => {
// { x: 1 } is ambiguous – first tried as object; if that fails as
// SyntaxError it falls back to block execution. A true label statement
// `{ label: expr; }` is a block and eval returns the last expression.
// { a: 1 } is valid as both, so replLikeEval returns the object.
const result = await replLikeEval("{ x: 1 }");
// Treated as object literal when it is valid JSON-like
assert.deepStrictEqual(result, { x: 1 });
});

it("executes pure block statement when object parse fails", async () => {
// A block containing a statement that is invalid as an object expression
// forces the fallback path.
const result = await replLikeEval("{ let tmp = 5; tmp; }");
assert.strictEqual(result, 5);
});
});

describe("await expression", () => {
it("awaits a resolved promise", async () => {
const result = await replLikeEval("await Promise.resolve(7)");
assert.strictEqual(result, 7);
});
});

describe("error propagation", () => {
it("throws ReferenceError for undefined identifier", async () => {
await assert.rejects(
() => replLikeEval("notDefinedAtAllXyz"),
ReferenceError
);
});
});
});

// ---------------------------------------------------------------------------
// checkSyntax
// ---------------------------------------------------------------------------

describe("checkSyntax", () => {
describe("complete inputs", () => {
it("simple expression is complete", async () => {
assert.deepStrictEqual(await checkSyntax("1 + 2"), {
status: "complete",
});
});

it("function declaration is complete", async () => {
assert.deepStrictEqual(
await checkSyntax("function f() { return 1; }"),
{ status: "complete" }
);
});

it("if-else block is complete", async () => {
assert.deepStrictEqual(
await checkSyntax("if (true) { 1; } else { 2; }"),
{ status: "complete" }
);
});

it("for loop is complete", async () => {
assert.deepStrictEqual(
await checkSyntax("for (let i = 0; i < 3; i++) {}"),
{ status: "complete" }
);
});

it("empty string is complete", async () => {
assert.deepStrictEqual(await checkSyntax(""), { status: "complete" });
});
});

describe("incomplete inputs", () => {
it("if(1){ is incomplete", async () => {
assert.deepStrictEqual(await checkSyntax("if(1){"), {
status: "incomplete",
});
});

it("function f() { is incomplete", async () => {
assert.deepStrictEqual(await checkSyntax("function f() {"), {
status: "incomplete",
});
});

it("open array bracket is incomplete", async () => {
assert.deepStrictEqual(await checkSyntax("[1, 2,"), {
status: "incomplete",
});
});

it("open object brace is incomplete", async () => {
assert.deepStrictEqual(await checkSyntax("({ a:"), {
status: "incomplete",
});
});
});

describe("invalid inputs", () => {
it("extra closing brace after complete block returns incomplete (extra } matches wrapper)", async () => {
// The implementation wraps code in `() => {<code>}`.
// An extra } is interpreted as closing the wrapper early, so the
// engine reports "Unexpected token '}'" – which the heuristic maps to
// "incomplete". This is a known limitation of the wrapper approach.
assert.deepStrictEqual(await checkSyntax("if(1){}}"), {
status: "incomplete",
});
});

it("syntax error expression is invalid", async () => {
assert.deepStrictEqual(await checkSyntax("1 +* 2"), {
status: "invalid",
});
});
});
});
Loading
Loading