From 44e3166339d32ccc3ea4d7e8a7304ea0bf4a52da Mon Sep 17 00:00:00 2001 From: fly6516 Date: Sun, 5 Jan 2025 14:59:16 +0800 Subject: [PATCH] feat: prototype running code --- next.config.ts | 10 + package.json | 11 +- src/actions/enhancedmultiFileRunner.test.ts | 115 +++++++++++ src/actions/enhancedmultiFileRunner.ts | 205 ++++++++++++++++++++ src/actions/index.ts | 80 ++++++++ src/actions/multiFileRunner.ts | 86 ++++++++ src/app/(main)/page.tsx | 106 ++++++++-- 7 files changed, 591 insertions(+), 22 deletions(-) create mode 100644 src/actions/enhancedmultiFileRunner.test.ts create mode 100644 src/actions/enhancedmultiFileRunner.ts create mode 100644 src/actions/index.ts create mode 100644 src/actions/multiFileRunner.ts diff --git a/next.config.ts b/next.config.ts index aa7c1ca..01398ed 100644 --- a/next.config.ts +++ b/next.config.ts @@ -17,6 +17,16 @@ const nextConfig: NextConfig = { }, ], }, + webpack(config, { isServer }) { + // 仅在服务器端处理 .node 文件 + if (isServer) { + config.module.rules.push({ + test: /\.node$/, + use: 'node-loader', // 使用 node-loader 处理 .node 文件 + }); + } + return config; + }, }; export default nextConfig; diff --git a/package.json b/package.json index 0fc0f20..655a2ab 100644 --- a/package.json +++ b/package.json @@ -20,19 +20,20 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "devicons-react": "^1.4.0", + "dockerode": "^4.0.3", "gitea-js": "^1.22.0", "lucide-react": "^0.469.0", "monaco-editor": "0.36.1", "monaco-languageclient": "5.0.1", - "normalize-url": "~8.0.0", "next": "15.1.3", "next-auth": "^5.0.0-beta.25", - "react": "^19.0.0", - "react-dom": "^19.0.0", + "normalize-url": "~8.0.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", "tailwind-merge": "^2.6.0", "tailwindcss-animate": "^1.0.7", - "vscode-ws-jsonrpc": "3.0.0", "vscode-languageclient": "~8.1.0", + "vscode-ws-jsonrpc": "3.0.0", "zustand": "^5.0.2" }, "devDependencies": { @@ -49,4 +50,4 @@ "tailwindcss": "^3.4.1", "typescript": "^5" } -} \ No newline at end of file +} diff --git a/src/actions/enhancedmultiFileRunner.test.ts b/src/actions/enhancedmultiFileRunner.test.ts new file mode 100644 index 0000000..ae5d750 --- /dev/null +++ b/src/actions/enhancedmultiFileRunner.test.ts @@ -0,0 +1,115 @@ +const { runMultiFileCodeWithOptionalTestData, setGiteaRepoUrl, setGiteaToken, setTestDataPath } = require('./enhancedmultiFileRunner'); +const Docker = require('dockerode'); +const fs = require('fs/promises'); +const path = require('path'); +const axios = require('axios'); + +jest.mock('dockerode'); +jest.mock('fs/promises'); +jest.mock('axios'); + +const mockDocker = new Docker(); +const mockContainer = { + start: jest.fn(), + logs: jest.fn(), + wait: jest.fn(), + remove: jest.fn(), +}; + +mockDocker.createContainer = jest.fn().mockResolvedValue(mockContainer); +mockContainer.logs.mockResolvedValue({ on: jest.fn() }); +mockContainer.wait.mockResolvedValue({ StatusCode: 0 }); + +describe('runMultiFileCodeWithOptionalTestData', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should throw an error if files are not provided', async () => { + await expect(runMultiFileCodeWithOptionalTestData({ files: [], language: 'c' })) + .rejects.toThrow('Files and language are required.'); + }); + + it('should throw an error if language is not provided', async () => { + await expect(runMultiFileCodeWithOptionalTestData({ files: [{ name: 'main.c', content: '' }], language: '' })) + .rejects.toThrow('Files and language are required.'); + }); + + it('should throw an error if an unsupported language is provided', async () => { + await expect(runMultiFileCodeWithOptionalTestData({ files: [{ name: 'main.c', content: '' }], language: 'ruby' })) + .rejects.toThrow("Language 'ruby' is not supported."); + }); + + it('should successfully compile and run C code', async () => { + const result = await runMultiFileCodeWithOptionalTestData({ + files: [{ name: 'main.c', content: 'int main() { return 0; }' }], + language: 'c', + }); + + expect(result).toHaveProperty('output'); + }); + + it('should successfully compile and run Java code', async () => { + const result = await runMultiFileCodeWithOptionalTestData({ + files: [{ name: 'Main.java', content: 'public class Main { public static void main(String[] args) {} }' }], + language: 'java', + }); + + expect(result).toHaveProperty('output'); + }); + + it('should successfully compile and run Python code', async () => { + const result = await runMultiFileCodeWithOptionalTestData({ + files: [{ name: 'script.py', content: 'print("Hello, World!")' }], + language: 'python', + }); + + expect(result).toHaveProperty('output'); + }); + + it('should fetch test data from Gitea if configured', async () => { + await setGiteaRepoUrl('http://example.com'); + await setGiteaToken('token'); + await setTestDataPath('testData'); + + axios.get.mockResolvedValue({ data: [{ type: 'file', name: 'testData.txt', download_url: 'http://example.com/testData.txt' }] }); + + const result = await runMultiFileCodeWithOptionalTestData({ + files: [{ name: 'main.c', content: '' }], + language: 'c', + testDataFiles: ['testData.txt'], + expectedAnswerFiles: ['expected.txt'], + resultOutputFile: 'result.txt', + }); + + expect(result).toHaveProperty('output'); + }); + + it('should handle errors during file operations', async () => { + fs.writeFile.mockRejectedValue(new Error('File write error')); + + await expect(runMultiFileCodeWithOptionalTestData({ + files: [{ name: 'main.c', content: '' }], + language: 'c', + })).rejects.toThrow('File write error'); + }); + + it('should handle errors during Docker container operations', async () => { + mockDocker.createContainer.mockRejectedValue(new Error('Docker error')); + + await expect(runMultiFileCodeWithOptionalTestData({ + files: [{ name: 'main.c', content: '' }], + language: 'c', + })).rejects.toThrow('Docker error'); + }); + + it('should handle errors during HTTP requests', async () => { + axios.get.mockRejectedValue(new Error('HTTP error')); + + await expect(runMultiFileCodeWithOptionalTestData({ + files: [{ name: 'main.c', content: '' }], + language: 'c', + testDataFiles: ['testData.txt'], + })).rejects.toThrow('HTTP error'); + }); +}); diff --git a/src/actions/enhancedmultiFileRunner.ts b/src/actions/enhancedmultiFileRunner.ts new file mode 100644 index 0000000..769f644 --- /dev/null +++ b/src/actions/enhancedmultiFileRunner.ts @@ -0,0 +1,205 @@ +'use server'; + +import Docker from 'dockerode'; +import fs from 'fs/promises'; // 使用异步版本的 fs 模块 +import path from 'path'; +import axios from 'axios'; + +const docker = new Docker(); +const DOCKER_IMAGE_URL = process.env.DOCKER_IMAGE_URL || 'fly6516.synology.me:8080/multilang:latest'; // 使用环境变量 + +let giteaRepoUrl: string | undefined; +let giteaToken: string | undefined; +let testDataPath: string = 'testdata'; // 默认测试数据目录 + +/** + * 设置 Gitea 仓库 URL + * @param url 仓库 URL + */ +export const setGiteaRepoUrl = async (url: string) => { + if (!url || !url.startsWith('http')) { + throw new Error('Invalid repository URL. Please provide a valid URL starting with "http".'); + } + giteaRepoUrl = url; +}; + +/** + * 设置 Gitea Token + * @param token 仓库 API 访问令牌 + */ +export const setGiteaToken = async (token: string) => { + if (!token || token.trim().length === 0) { + throw new Error('Invalid token. Please provide a non-empty token.'); + } + giteaToken = token; +}; + +/** + * 设置测试数据在仓库中的路径 + * @param path 测试数据目录路径 + */ +export const setTestDataPath = async (path: string) => { + if (!path || path.trim().length === 0) { + throw new Error('Invalid test data path. Please provide a non-empty path.'); + } + testDataPath = path; +}; + +/** + * 从 Gitea 仓库中下载指定目录的文件 + * @param dir 目录路径 + * @param tempDir 本地存储的临时目录 + */ +const fetchFilesFromGitea = async (dir: string, tempDir: string) => { + if (!giteaRepoUrl || !giteaToken) { + throw new Error('Gitea repository URL or token is not set. Please configure them using setGiteaRepoUrl and setGiteaToken.'); + } + + const targetDir = path.join(tempDir, dir); + await fs.mkdir(targetDir, { recursive: true }); + + try { + const response = await axios.get(`${giteaRepoUrl}/contents/${dir}`, { + headers: { + Authorization: `token ${giteaToken}`, + }, + }); + + const files = response.data; + if (!Array.isArray(files)) { + throw new Error(`Invalid directory structure for ${dir} in repository.`); + } + + // 并行下载文件 + await Promise.all(files.filter(file => file.type === 'file').map(async (file) => { + const fileResponse = await axios.get(file.download_url, { + headers: { + Authorization: `token ${giteaToken}`, + }, + responseType: 'arraybuffer', + }); + + const filePath = path.join(targetDir, file.name); + await fs.writeFile(filePath, fileResponse.data); + })); + } catch (error) { + throw new Error(`Failed to fetch files from '${dir}': ${(error as Error).message}`); + } +}; + +/** + * 比较运行结果与正确答案文件 + * @param resultFilePath 运行结果文件路径 + * @param expectedFilePath 正确答案文件路径 + * @returns 是否一致的比较结果 + */ +const compareResults = async (resultFilePath: string, expectedFilePath: string): Promise => { + try { + const result = (await fs.readFile(resultFilePath, 'utf-8')).trim(); + const expected = (await fs.readFile(expectedFilePath, 'utf-8')).trim(); + return result === expected; + } catch (error) { + throw new Error(`Failed to read files for comparison: ${(error as Error).message}`); + } +}; + +/** + * 执行多文件代码并测试结果 + * @param params 包含代码文件列表、语言、测试数据文件名和正确答案文件名的参数 + * @returns 运行结果或比较结果 + */ +export const runMultiFileCodeWithOptionalTestData = async (params: { + files: { name: string; content: string }[]; + language: string; + testDataFiles?: string[]; + expectedAnswerFiles?: string[]; + resultOutputFile?: string; +}) => { + const { files, language, testDataFiles, expectedAnswerFiles, resultOutputFile } = params; + + if (!files || files.length === 0 || !language) { + throw new Error('Files and language are required.'); + } + + const supportedLanguages = ['c', 'java', 'python']; + if (!supportedLanguages.includes(language)) { + throw new Error(`Language '${language}' is not supported. Only 'c', 'java', and 'python' are supported.`); + } + + const compileAndRunCmd = + language === 'c' + ? 'gcc *.c -o main && ./main' + : language === 'java' + ? 'javac *.java && java Main' + : 'python3 script.py'; + + const tempDir = path.join('/tmp', `${Date.now()}`); + await fs.mkdir(tempDir, { recursive: true }); + + try { + // 写入代码文件 + await Promise.all(files.map(async (file) => { + const filePath = path.join(tempDir, file.name); + await fs.writeFile(filePath, file.content); + })); + + // 检查是否设置了 Gitea 仓库信息 + if (giteaRepoUrl && giteaToken) { + try { + await fetchFilesFromGitea(testDataPath, tempDir); + } catch (error) { + throw new Error(`Failed to fetch test data from '${testDataPath}': ${(error as Error).message}`); + } + } else { + console.info('No Gitea repository connection set. Proceeding without testdata.'); + } + + // 创建 Docker 容器 + const container = await docker.createContainer({ + Image: DOCKER_IMAGE_URL, + Cmd: ['sh', '-c', compileAndRunCmd], + AttachStdout: true, + AttachStderr: true, + Tty: false, + HostConfig: { + Binds: [`${tempDir}:/workspace`], // 挂载临时目录到容器 + }, + WorkingDir: '/workspace', + }); + + await container.start(); + + const logs = await container.logs({ stdout: true, stderr: true, follow: true }); + + let output = ''; + logs.on('data', (data: Buffer) => { + output += data.toString().replace(/[\x00-\x1F\x7F]/g, ''); // 去除不可见字符 + }); + + const exitCode = await container.wait(); + await container.remove(); + + if (exitCode.StatusCode !== 0) { + return { error: `Execution failed with exit code ${exitCode.StatusCode}. Output: ${output}` }; + } + + // 如果指定了结果输出文件和正确答案文件,执行比较 + if (resultOutputFile && expectedAnswerFiles && expectedAnswerFiles.length > 0) { + const resultFilePath = path.join(tempDir, resultOutputFile); + + const comparisonResults = await Promise.all(expectedAnswerFiles.map(async (expectedFile) => { + const expectedFilePath = path.join(tempDir, testDataPath, expectedFile); + const isMatch = await compareResults(resultFilePath, expectedFilePath); + return { expectedFile, isMatch }; + })); + + return { output, comparisonResults }; + } + + return { output }; + } catch (error) { + return { error: `An error occurred: ${(error as Error).message}` }; + } finally { + await fs.rm(tempDir, { recursive: true, force: true }); + } +}; diff --git a/src/actions/index.ts b/src/actions/index.ts new file mode 100644 index 0000000..50156a5 --- /dev/null +++ b/src/actions/index.ts @@ -0,0 +1,80 @@ +'use server'; + +import Docker from 'dockerode'; +import fs from 'fs'; +import path from 'path'; + + +// 创建 Docker 客户端实例 +const docker = new Docker(); + +// 定义执行代码的 Server Action +export const runCode = async (params: { code: string; language: string }) => { + const { code, language } = params; + + if (!code || !language) { + throw new Error('Code and language are required.'); + } + + const supportedLanguages = ['c', 'java', 'python']; + if (!supportedLanguages.includes(language)) { + throw new Error(`Language '${language}' is not supported. Only 'c', 'java', and 'python' are supported.`); + } + + // 根据语言选择文件名和编译/运行命令 + const fileName = + language === 'c' ? 'main.c' : + language === 'java' ? 'Main.java' : + 'script.py'; + const runCmd = + language === 'c' ? 'gcc main.c -o main && ./main' : + language === 'java' ? 'javac Main.java && java Main' : + 'python3 script.py'; + + // 创建临时目录用于存储代码文件 + const tempDir = path.join('/tmp', `${Date.now()}`); + fs.mkdirSync(tempDir, { recursive: true }); + + const filePath = path.join(tempDir, fileName); + fs.writeFileSync(filePath, code); + + try { + // 创建并启动 Docker 容器 + const container = await docker.createContainer({ + Image: 'fly6516.synology.me:8080/multilang:latest', + Cmd: ['sh', '-c', runCmd], + AttachStdout: true, + AttachStderr: true, + Tty: false, + HostConfig: { + Binds: [`${tempDir}:/workspace`], // 将临时目录挂载到容器 + }, + WorkingDir: '/workspace', + }); + + await container.start(); + + const logs = await container.logs({ stdout: true, stderr: true, follow: true }); + + let output = ''; + logs.on('data', (data: Buffer) => { + output += data.toString().replace(/[\x00-\x1F\x7F]/g, ''); // 去除不可见字符 + }); + + const exitCode = await container.wait(); + await container.remove(); + + if (exitCode.StatusCode === 0) { + return { output }; + } else { + // 返回详细的错误信息 + return { error: `Execution failed with exit code ${exitCode.StatusCode}. Output: ${output}` }; + } + } catch (error) { + // 捕获并返回详细的错误信息 + return { error: `An error occurred: ${(error as Error).message}` }; + } finally { + // 清理临时目录 + fs.rmSync(tempDir, { recursive: true, force: true }); + } +}; diff --git a/src/actions/multiFileRunner.ts b/src/actions/multiFileRunner.ts new file mode 100644 index 0000000..ad4496e --- /dev/null +++ b/src/actions/multiFileRunner.ts @@ -0,0 +1,86 @@ +'use server'; + +import Docker from 'dockerode'; +import fs from 'fs'; +import path from 'path'; + +// 创建 Docker 客户端实例 +const docker = new Docker(); + +/** + * 执行多文件代码的函数 + * @param params 包含文件列表和语言的参数 + * @returns 执行结果或错误信息 + */ +export const runMultiFileCode = async (params: { files: { name: string; content: string }[]; language: string }) => { + const { files, language } = params; + + if (!files || files.length === 0 || !language) { + throw new Error('Files and language are required.'); + } + + // 支持的编程语言 + const supportedLanguages = ['c', 'java', 'python']; + if (!supportedLanguages.includes(language)) { + throw new Error(`Language '${language}' is not supported. Only 'c', 'java', and 'python' are supported.`); + } + + // 根据语言设置编译和运行命令 + const compileAndRunCmd = + language === 'c' + ? 'gcc *.c -o main && ./main' + : language === 'java' + ? 'javac *.java && java Main' + : 'python3 script.py'; + + // 创建临时目录存放代码文件 + const tempDir = path.join('/tmp', `${Date.now()}`); + fs.mkdirSync(tempDir, { recursive: true }); + + try { + // 将所有文件写入临时目录 + for (const file of files) { + const filePath = path.join(tempDir, file.name); + fs.writeFileSync(filePath, file.content); + } + + // 创建 Docker 容器 + const container = await docker.createContainer({ + Image: 'fly6516.synology.me:8080/multilang:latest', + Cmd: ['sh', '-c', compileAndRunCmd], + AttachStdout: true, + AttachStderr: true, + Tty: false, + HostConfig: { + Binds: [`${tempDir}:/workspace`], // 挂载临时目录到容器 + }, + WorkingDir: '/workspace', + }); + + await container.start(); + + // 获取容器日志 + const logs = await container.logs({ stdout: true, stderr: true, follow: true }); + + let output = ''; + logs.on('data', (data: Buffer) => { + output += data.toString().replace(/[\x00-\x1F\x7F]/g, ''); // 去除不可见字符 + }); + + const exitCode = await container.wait(); // 等待容器执行结束 + await container.remove(); // 移除容器 + + // 返回执行结果或错误信息 + if (exitCode.StatusCode === 0) { + return { output }; + } else { + return { error: `Execution failed with exit code ${exitCode.StatusCode}. Output: ${output}` }; + } + } catch (error) { + // 捕获异常并返回详细错误信息 + return { error: `An error occurred: ${(error as Error).message}` }; + } finally { + // 清理临时目录 + fs.rmSync(tempDir, { recursive: true, force: true }); + } +}; diff --git a/src/app/(main)/page.tsx b/src/app/(main)/page.tsx index b45a092..b980a0f 100644 --- a/src/app/(main)/page.tsx +++ b/src/app/(main)/page.tsx @@ -8,26 +8,98 @@ import FontSizeSlider from "./components/font-size-slider"; import LanguageSelector from "./components/language-selector"; import { useCodeEditorStore } from "@/store/useCodeEditorStore"; import { highlightMonacoEditor } from "@/constants/editor/themes"; +import { runMultiFileCode } from "@/actions/multiFileRunner"; +import { useState } from "react"; loader.config({ monaco }); export default function MainPage() { - const { language, theme, fontSize, setEditor } = useCodeEditorStore(); + const { language, theme, fontSize, setEditor } = useCodeEditorStore(); + const [code, setCode] = useState(""); // 存储编辑器代码内容 + const [output, setOutput] = useState(""); // 存储运行结果 + const [loading, setLoading] = useState(false); // 控制加载状态 - return ( -
-
- - - -
- -
- ); + const handleRunCode = async () => { + console.group("Code Execution Debugging"); + console.log("Execution started..."); + console.log("Selected language:", language); + console.log("Code content:\n", code); + + if (!language || !code.trim()) { + console.error("Missing language or code content."); + setOutput("Error: Please select a language and enter code."); + console.groupEnd(); + return; + } + + setLoading(true); + setOutput("Running code..."); + try { + const files = [ + { + name: language === "python" ? "script.py" : language === "java" ? "Main.java" : "main.c", + content: code, + }, + ]; + console.log("Files to execute:", files); + + const result = await runMultiFileCode({ files, language }); + console.log("Execution result:", result); + + if (result.output) { + console.log("Execution successful. Output:\n", result.output); + setOutput(result.output); + } else if (result.error) { + console.error("Execution error. Details:\n", result.error); + setOutput(result.error); + } else { + console.warn("No output or error returned."); + setOutput("No output received from execution."); + } + } catch (error) { + console.error("Error during execution:", error); + setOutput(`Error: ${(error as Error).message}`); + } finally { + setLoading(false); + console.groupEnd(); + } + }; + + return ( +
+
+ + + + +
+
+ { + console.log("Editor content updated:", value); + setCode(value || ""); + }} + options={{ fontSize, automaticLayout: true }} + /> +
+
+

Output:

+
+
{output || "No output yet."}
+
+
+
+ ); }