AtCoder JavaScript Tester

AtCoderでJavaScriptコードをテスト実行するためのユーザースクリプト

Version au 01/11/2025. Voir la dernière version.

Vous devrez installer une extension telle que Tampermonkey, Greasemonkey ou Violentmonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey ou Violentmonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey ou Userscripts pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey pour installer ce script.

Vous devrez installer une extension de gestionnaire de script utilisateur pour installer ce script.

(J'ai déjà un gestionnaire de scripts utilisateur, laissez-moi l'installer !)

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

(J'ai déjà un gestionnaire de style utilisateur, laissez-moi l'installer!)

// ==UserScript==
// @name         AtCoder JavaScript Tester
// @namespace    http://axtech.dev/
// @version      0.3.0
// @description  AtCoderでJavaScriptコードをテスト実行するためのユーザースクリプト
// @author       AXT-AyaKoto
// @match        https://atcoder.jp/contests/*/tasks/*
// @grant        GM_addStyle
// @grant        GM_setValue
// @grant        GM_getValue
// ==/UserScript==
(async function () {
    'use strict';

    // config定数
    const TIMEOUT_BUFFER_RATE = 1.1; // タイムアウト判定のためのバッファ率 (例: 1.1なら10%増しでタイムアウト判定)

    // 中間定数
    const path = window.location.pathname;

    // あとでMonaco Editorを容易に取得できるようにletを用意しておく
    let monaco_editor;

    // CSS挿入
    const customCss = `
        #main-container {
            width: max(750px, 50%);
            margin-left: 6rem;
        }

        #ajt_container {
            width: calc(100% - 14rem - max(750px, 50%));
            position: fixed;
            right: 6rem;
            top: calc(50px + 2rem);
            bottom: calc(80px + 2rem);
            border-radius: 16px;
            padding: 8px;
            box-shadow: 0px 0px 8px 2px #4444;
            background-color: #ffffff;
            overflow-y: auto;
            z-index: 1000;

            display: block grid;
            grid-template-columns: 1fr 1fr 1fr;
            grid-template-rows: 5fr 2fr 32px 2fr;
            gap: 16px;
            grid-template-areas:
                "editor editor editor"
                "stdin answer settings"
                "controls controls controls"
                "status stdout stderr";
            
            &>.monaco-editor-container {
                grid-area: editor;
                border-radius: 8px;
                overflow: hidden;
                box-shadow: 0px 0px 4px 1px #0002;
            }

            &>*>h3 {
                user-select: none;
                margin: 0 0 4px 0;
                font-size: 1.2rem;
                font-weight: bold;
                color: #333;
            }

            &>:is(.section_stdin, .section_answer, .section_stdout, .section_stderr) {
                display: flex;
                flex-direction: column;
                &>textarea {
                    flex-grow: 1;
                    width: 100%;
                    border-radius: 8px;
                    border: 1px solid #aaa;
                    padding: 4px;
                    font-family: monospace;
                    font-size: 12px;
                    resize: none;
                }
            }

            &>.section_stdin {
                grid-area: stdin;
            }

            &>.section_answer {
                grid-area: answer;
            }

            &>.section_stdout {
                grid-area: stdout;
            }

            &>.section_stderr {
                grid-area: stderr;
            }

            &>.section_settings {
                grid-area: settings;
                &>.settings_list {
                    font-size: 12px;
                    list-style: none;
                    padding: 0;
                    margin: 0;
                    &>.setting_item {
                        margin-bottom: 8px;

                        &>label {
                            margin-right: 4px;
                            width: calc(60% - 4px);
                        }
                        &>input {
                            width: 40%;
                        }
                    }
                }
            }

            &>.section_controls {
                grid-area: controls;
                display: flex;
                justify-content: left;
                align-items: center;
                gap: 8px;
                & * {
                    font-size: 12px;
                    height: 100%;
                }
                &>.controls_group {
                    display: inline-flex;
                    align-items: center;
                    gap: 4px;
                    &>h4 {
                        margin: 0;
                        line-height: 30px;
                        font-weight: normal;
                        user-select: none;
                    }
                    &>button {
                        padding-inline: 4px;
                        border-radius: 8px;
                        border: 1px solid #888;
                        background-color: #eee;
                        cursor: pointer;
                        transition: background-color 0.12s;

                        &:hover {
                            background-color: #ddd;
                        }

                        &:active {
                            background-color: #ccc;
                        }

                        &:disabled {
                            background-color: #aaa;
                            border-color: #888;
                            cursor: not-allowed;
                        }
                    }
                }
                &>hr {
                    width: 2px;
                    height: 24px;
                    margin: 0;
                    background-color: #8888;
                    border: none;
                }
            }

            &>.section_status {
                grid-area: status;
                &>table {
                    width: 100%;
                    font-size: 12px;
                    border-collapse: collapse;
                    &>tbody>tr>:is(th, td) {
                        padding: 2px 4px;
                        border: 1px solid #ccc;
                        &:is(th) {
                            text-align: left;
                            background-color: #f0f0f0;
                        }
                        &:is(td) {
                            text-align: center;
                            font-family: monospace;
                        }
                        &:is(td#ajt_status_result) {
                            font-weight: bold;
                            &::before {
                                content: attr(data-result-value);
                                color: #fff;
                                display: inline-block;
                                padding-inline: 6px;
                                border-radius: 4px;
                            }
                            &[data-result-value="--"]::before,
                            &[data-result-value="WJ"]::before {
                                background-color: #888888;
                            }
                            &[data-result-value="AC"]::before {
                                background-color: #4caf50;
                            }
                            &[data-result-value="WA"]::before {
                                background-color: #f44336;
                            }
                            &[data-result-value="TLE"]::before {
                                background-color: #ff9800;
                            }
                            &[data-result-value="RE"]::before {
                                background-color: #2196f3;
                            }
                        }
                    }
                }
            }
        }
    `;
    GM_addStyle(customCss);

    // コンテナdiv追加
    const container_div = document.createElement('div');
    container_div.id = 'ajt_container';
    document.querySelector("#main-container").appendChild(container_div);

    // Monaco Editor**以外**の要素を追加
    const insertHTML = `\
<div class="section_stdin">
    <h3>Standard Input</h3>
    <textarea id="ajt_stdin"></textarea>
</div>
<div class="section_answer">
    <h3>Expected Answer</h3>
    <textarea id="ajt_answer"></textarea>
</div>
<div class="section_settings">
    <h3>Settings</h3>
    <ul class="settings_list">
        <li class="setting_item"><label for="ajt_timeout">Time Limit(ms):</label><input type="number" id="ajt_timeout" value="2000"></li>
        <li class="setting_item"><label for="ajt_allowable_error">Allowable Error:</label><input type="text" id="ajt_allowable_error" value="1e-6"></li>
    </ul>
</div>
<div class="section_controls">
    <div class="controls_group">
        <button id="ajt_run_button">Run Test</button>
        <button id="ajt_prepare_submit_button">Prepare Submit</button>
    </div>
    <hr>
    <div class="controls_group">
        <h4>Insert Template:</h4>
        <button id="ajt_insert_nodejs_button">Node.js</button>
        <button id="ajt_insert_deno_button">Deno</button>
        <button id="ajt_insert_bun_button">Bun</button>
    </div>
</div>
<div class="section_status">
    <h3>Status</h3>
    <table>
        <tbody>
            <tr><th>Result</th><td id="ajt_status_result" data-result-value="--"></td></tr>
            <tr><th>Execution Time</th><td id="ajt_status_time">-</td></tr>
        </tbody>
    </table>
</div>
<div class="section_stdout">
    <h3>Standard Output</h3>
    <textarea id="ajt_stdout" readonly></textarea>
</div>
<div class="section_stderr">
    <h3>Standard Error</h3>
    <textarea id="ajt_stderr" readonly></textarea>
</div>
`;
    container_div.insertAdjacentHTML('beforeend', insertHTML);

    // Monaco Editorへ入れるコードについて、過去に保存されていればそれを持ってくる(なければ空文字)
    const savedCode = GM_getValue(`monaco_editor_code_${path}`, "");

    // Monaco Editorの読み込み
    await new Promise((resolve) => {
        const script = document.createElement('script');
        script.src = 'https://cdn.jsdelivr.net/npm/monaco-editor/min/vs/loader.js';
        script.onload = resolve;
        document.body.appendChild(script);
    });
    console.log('loader.js の読み込み完了。');
    require.config({
        paths: {
            'vs': 'https://cdn.jsdelivr.net/npm/monaco-editor@latest/min/vs'
        }
    });
    await new Promise((resolve, reject) => {
        require(['vs/editor/editor.main'], () => {
            try {
                const editorContainer = document.createElement('div');
                editorContainer.style.width = '100%';
                editorContainer.style.height = '100%';
                editorContainer.classList.add('monaco-editor-container');
                container_div.appendChild(editorContainer);
                const editor = monaco.editor.create(editorContainer, {
                    value: savedCode,
                    language: 'javascript', // 言語モード
                    theme: 'vs-dark',       // テーマ (vs-dark, vs-lightなど)
                    automaticLayout: true,  // コンテナサイズ変更時に自動リサイズ
                    minimap: { enabled: true } // ミニマップの表示
                });
                console.log('Monaco Editorの生成に成功しました。');
                monaco_editor = editor;
                resolve();

            } catch (e) {
                console.error('Monaco Editorの生成に失敗しました:', e);
                reject(e);
            }
        });
    });

    // #ajt_insert_*_button を押下したときの処理を追加
    document.querySelector("#ajt_insert_nodejs_button").addEventListener("click", () => {
        const newCode = `\
function Main(inputText) {
    /** @type {string[][]} */
    const input = inputText.trim().split("\\n").map(row => row.split(" "));
    // 処理
}
Main(require("fs").readFileSync("/dev/stdin", "utf8"));
`;
        monaco_editor.setValue(newCode);
    });
    document.querySelector("#ajt_insert_deno_button").addEventListener("click", () => {
        const newCode = `\
function Main(inputText) {
    /** @type {string[][]} */
    const input = inputText.trim().split("\\n").map(row => row.split(" "));
    // 処理
}
Main(await Deno.readTextFile("/dev/stdin"));
`;
        monaco_editor.setValue(newCode);
    });
    document.querySelector("#ajt_insert_bun_button").addEventListener("click", () => {
        const newCode = `\
function Main(inputText) {
    /** @type {string[][]} */
    const input = inputText.trim().split("\\n").map(row => row.split(" "));
    // 処理
}
Main(await Bun.file("/dev/stdin").text());
`;
        monaco_editor.setValue(newCode);
    });

    // 問題ページのソースコード欄に文字列をsetする関数
    const setSourceCode = (code) => {
        // #sourceCode の中には #editor(Ace Editor)と #plain-textarea(textarea)がある
        // 今どっちかを取得 displayがnoneじゃないほう
        const currentEditor = document.querySelector("#sourceCode #editor").style.display !== "none"
            ? document.querySelector("#sourceCode #editor")
            : document.querySelector("#sourceCode #plain-textarea");
        // currentEditorがAce Editorの場合、.btn-toggle-editorを押す→ textareaにセット → .btn-toggle-editorをもう一回押す でいける
        // currentEditorがtextareaの場合、そのままセットすればOK
        if (currentEditor.id === "editor") document.querySelector(".btn-toggle-editor").click();
        document.querySelector("#sourceCode #plain-textarea").value = code;
        if (currentEditor.id === "editor") document.querySelector(".btn-toggle-editor").click();
    };
    // #ajt_prepare_submit_button を押下したときの処理を追加
    document.querySelector("#ajt_prepare_submit_button").addEventListener("click", () => {
        const code = monaco_editor.getValue();
        // Clipboard APIを使ってクリップボードにコピー
        navigator.clipboard.writeText(code).then(() => {
            console.log("Code copied to clipboard.");
        }).catch((err) => {
            console.error("Failed to copy code to clipboard:", err);
        });
        // #sourceCode までスクロール
        document.querySelector("#sourceCode").scrollIntoView({ behavior: 'smooth' });
        // #sourceCode にコードをセット
        setSourceCode(code);
    });


    // Monaco Editorの内容が変化したら保存するようにする
    const saveEditorContent = () => {
        const code = monaco_editor.getValue();
        GM_setValue(`monaco_editor_code_${path}`, code);
    };
    monaco_editor.getModel().onDidChangeContent(saveEditorContent);

    // Runボタン押下時、Workerを動的に生成してコードテストを実行して結果を反映する処理
    // 1. Worker側のコードを用意する関数
    const createWorkerScript = () => {
        // Editorのコードを取得
        let userCode = monaco_editor.getValue();
        // `Main(require("fs").readFileSync("/dev/stdin", "utf8"));`, `Main(await Deno.readTextFile("/dev/stdin"));`, `Main(await Bun.file("/dev/stdin").text());`があれば削除する
        const deleteTargets = [
            'Main(require("fs").readFileSync("/dev/stdin", "utf8"));',
            'Main(await Deno.readTextFile("/dev/stdin"));',
            'Main(await Bun.file("/dev/stdin").text());'
        ];
        deleteTargets.forEach(target => {
            userCode = userCode.replace(target, '');
        });
        // Workerが"run"メッセージでstdinを受け取る → Main関数に渡してstdout/stderrを記録する → "result"メッセージで返す、という流れを実装するコードを生成
        const workerCommonScript = `\
importScripts("https://cdn.jsdelivr.net/npm/node-inspect-extracted/dist/inspect.js");
const formatValue = value => {
    if (typeof value === 'string') {
        return value;
    }
    if (typeof self.util === 'object' && typeof self.util.inspect === 'function') {
        return self.util.inspect(value, { depth: null });
    }
    return String(value);
};
self.onmessage = async function(event) {
    if (event.data.type === "run") {
        const stdin = event.data.stdin;
        const stdout = [];
        const stderr = [];
        console.log = (...args) => {
            stdout.push(args.map(formatValue).join(" "));
        };
        console.error = (...args) => {
            stderr.push(args.map(formatValue).join(" "));
        };
        try {
            await Main(stdin);
        } catch (e) {
            stderr.push(\`Error: \${e.message} (@L:\${e.lineno ?? "??"})\`);
        }
        self.postMessage({
            type: "result",
            stdout: stdout.join("\\n"),
            stderr: stderr.join("\\n")
        });
    }
};
`;
        // 最終的なWorkerスクリプトを生成して返す
        return `\
${userCode}
${workerCommonScript}
`;
    };
    // 2. 返ってきた結果をもとにUIを更新する関数
    /**
     * @typedef {Object} ExecutionResult
     * @property {string} stdout - 標準出力の内容
     * @property {string} stderr - 標準エラー出力の内容
     * @property {number|null} execTime - 実行時間 (ms)。タイムアウト時はnull
     */
    /** @type {(arg0: ExecutionResult) => void} */
    const updateUIWithResult = ({ stdout, stderr, execTime }) => {
        // とりあえずstdout/stderrを反映
        document.querySelector("#ajt_stdout").value = stdout;
        document.querySelector("#ajt_stderr").value = stderr;
        // 実行時間を反映 ただし、execTimeがnullか実行時間制限のTIMEOUT_BUFFER_RATE倍以上の場合は`≦ ${実行制限時間 * TIMEOUT_BUFFER_RATE} ms`と表示
        const timeoutLimit = parseInt(document.querySelector("#ajt_timeout").value);
        if (execTime !== null && execTime < timeoutLimit * TIMEOUT_BUFFER_RATE) {
            document.querySelector("#ajt_status_time").textContent = `${execTime.toFixed(0)} ms`;
        } else {
            document.querySelector("#ajt_status_time").textContent = `≦ ${Math.floor(timeoutLimit * TIMEOUT_BUFFER_RATE)} ms`;
        }
        // 結果を判定して反映
        const expectedAnswer = document.querySelector("#ajt_answer").value.trim();
        let resultValue = "--";
        if (execTime === null || execTime >= timeoutLimit) {
            resultValue = "TLE";
        } else if (stderr.length > 0) {
            resultValue = "RE";
        } else {
            // stdoutとexpectedAnswerを比較
            // 改行・スペース区切りで2次元配列にして比較。各要素が数値の場合は許容誤差内での比較を行う
            const parseOutput = (text) => text.trim().split("\n").map(row => row.trim().split(/\s+/));
            const outputArray = parseOutput(stdout);
            const answerArray = parseOutput(expectedAnswer);
            const allowableError = Number.parseFloat(document.querySelector("#ajt_allowable_error").value);
            const twoDimensionalArrayEqual = (arr1, arr2) => {
                if (arr1.length !== arr2.length) return false;
                for (let i = 0; i < arr1.length; i++) {
                    if (arr1[i].length !== arr2[i].length) return false;
                    for (let j = 0; j < arr1[i].length; j++) {
                        const val1 = arr1[i][j];
                        const val2 = arr2[i][j];
                        const num1 = Number.parseFloat(val1);
                        const num2 = Number.parseFloat(val2);
                        if (!Number.isNaN(num1) && !Number.isNaN(num2)) {
                            // 数値として比較
                            if (Math.abs(num1 - num2) > allowableError) return false;
                        } else {
                            // 文字列として比較
                            if (val1 !== val2) return false;
                        }
                    }
                }
                return true;
            };
            if (twoDimensionalArrayEqual(outputArray, answerArray)) {
                resultValue = "AC";
            } else {
                resultValue = "WA";
            }
        }
        const resultCell = document.querySelector("#ajt_status_result");
        resultCell.setAttribute("data-result-value", resultValue);
    };
    // 3. Runボタン押下時の処理を追加
    document.querySelector("#ajt_run_button").addEventListener("click", async () => {
        // ボタンを一時的にdisabledにする
        const runButton = document.querySelector("#ajt_run_button");
        runButton.disabled = true;
        // Workerスクリプトを生成
        const workerScript = createWorkerScript();
        const blob = new Blob([workerScript], { type: 'application/javascript' });
        const workerUrl = URL.createObjectURL(blob);
        const worker = new Worker(workerUrl);
        // stdinを取得
        const stdin = document.querySelector("#ajt_stdin").value;
        // runメッセージでWorkerにstdinを送信・実行開始
        const startTime = performance.now();
        worker.postMessage({ type: "run", stdin: stdin });
        // ExecutionResultは、Promise.raceで勝ったほうを採用する形にする
        const executionResult = await Promise.race([
            // 順当にWorkerからの結果を待つPromise
            new Promise((resolve) => {
                worker.onmessage = (event) => {
                    if (event.data.type === "result") {
                        const endTime = performance.now();
                        const execTime = endTime - startTime;
                        resolve({
                            stdout: event.data.stdout,
                            stderr: event.data.stderr,
                            execTime: Math.ceil(execTime)
                        });
                    }
                };
            }),
            // Workerでerrorが発生した場合にREとして扱うPromise
            new Promise((resolve) => {
                worker.onerror = (error) => {
                    const endTime = performance.now();
                    const execTime = endTime - startTime;
                    resolve({
                        stdout: "",
                        stderr: `Error: ${error.message ?? "??"} (@L:${error.lineno ?? "??"})`,
                        execTime: Math.ceil(execTime)
                    });
                };
            }),
            // 実行時間制限 × TIMEOUT_BUFFER_RATEミリ秒後にタイムアウトとするPromise
            new Promise((resolve) => {
                const timeLimit = parseInt(document.querySelector("#ajt_timeout").value);
                const timeoutLimit = timeLimit * TIMEOUT_BUFFER_RATE;
                setTimeout(() => {
                    resolve({
                        stdout: "",
                        stderr: "Error: Execution timed out.",
                        execTime: null
                    });
                }, timeoutLimit);
            })
        ]);
        // Workerを終了・URLを解放
        worker.terminate();
        URL.revokeObjectURL(workerUrl);
        // UIを更新
        updateUIWithResult(executionResult);
        // ボタンを再度有効化
        runButton.disabled = false;
    });

    // 実行時間制限を問題文から取得して input#ajt_timeout にセットする処理
    // .row > .col-sm-12の序盤に「実行時間制限: <number> sec」のように書かれているので、innerTextから正規表現でその文字列を抜き出す 時間は小数の可能性もある
    const problemInfoDivs = document.querySelectorAll(".row > .col-sm-12");
    for (const div of problemInfoDivs) {
        const text = div.innerText;
        const match = text.match(/実行時間制限:\s*([\d.]+)\s*sec/);
        if (match) {
            const timeLimitSec = Number.parseFloat(match[1]);
            if (!Number.isNaN(timeLimitSec)) {
                const timeLimitMs = Math.ceil(timeLimitSec * 1000);
                document.querySelector("#ajt_timeout").value = timeLimitMs.toString();
                console.log(`Detected time limit from problem statement: ${timeLimitMs} ms`);
                break;
            }
        }
    }
})();