diff --git a/.gitignore b/.gitignore index a7126769a4..68b3295238 100644 --- a/.gitignore +++ b/.gitignore @@ -20,6 +20,7 @@ !/storage/.keep /public/assets +/public/wasm .byebug_history # Ignore master key for decrypting credentials and more. diff --git a/app/commands/solution/generate_test_run_config.rb b/app/commands/solution/generate_test_run_config.rb new file mode 100644 index 0000000000..3dc3202609 --- /dev/null +++ b/app/commands/solution/generate_test_run_config.rb @@ -0,0 +1,22 @@ +class Solution::GenerateTestRunConfig + include Mandate + + initialize_with :solution + + def call + { + files: exercise_repo.tooling_files + } + end + + private + memoize + def exercise_repo + Git::Exercise.new( + solution.git_slug, + solution.git_type, + solution.git_sha, + repo_url: solution.track.repo_url + ) + end +end diff --git a/app/commands/submission/create.rb b/app/commands/submission/create.rb index 3de1544102..b43b446232 100644 --- a/app/commands/submission/create.rb +++ b/app/commands/submission/create.rb @@ -1,16 +1,15 @@ class Submission::Create include Mandate - def initialize(solution, files, submitted_via) + def initialize(solution, raw_files, submitted_via, test_results_json = nil) @submission_uuid = SecureRandom.compact_uuid - @solution = solution - @submitted_files = files @submitted_via = submitted_via + @test_results_json = test_results_json # TODO: (Optional) - Move this into another service # TODO: (Optional) - Consider risks around filenames - @submitted_files.each do |f| + @submitted_files = raw_files.each do |f| f[:digest] = Digest::SHA1.hexdigest(f[:content]) end end @@ -21,7 +20,7 @@ def call create_submission! create_files! - init_test_run! + handle_test_run! schedule_jobs! log_metric! @@ -30,7 +29,7 @@ def call end private - attr_reader :solution, :submitted_files, :submission_uuid, :submitted_via, :submission + attr_reader :solution, :submitted_files, :submission_uuid, :submitted_via, :submission, :test_results_json delegate :track, :user, to: :solution @@ -61,8 +60,12 @@ def create_files! end end - def init_test_run! - Submission::TestRun::Init.(submission) + def handle_test_run! + if test_results_json + Submission::TestRun::ProcessClientSideResults.(submission, test_results_json) + else + Submission::TestRun::Init.(submission) + end end def schedule_jobs! diff --git a/app/commands/submission/test_run/process.rb b/app/commands/submission/test_run/process.rb index a840af3dfa..3176629a5c 100644 --- a/app/commands/submission/test_run/process.rb +++ b/app/commands/submission/test_run/process.rb @@ -114,9 +114,9 @@ def broadcast!(test_run) def results res = JSON.parse(tooling_job.execution_output['results.json'], allow_invalid_unicode: true) res.is_a?(Hash) ? res.symbolize_keys : {} - rescue StandardError => e - Bugsnag.notify(e) - {} + # rescue StandardError => e + # Bugsnag.notify(e) + # {} end memoize diff --git a/app/commands/submission/test_run/process_client_side_results.rb b/app/commands/submission/test_run/process_client_side_results.rb new file mode 100644 index 0000000000..c9cfc5851b --- /dev/null +++ b/app/commands/submission/test_run/process_client_side_results.rb @@ -0,0 +1,29 @@ +class Submission::TestRun::ProcessClientSideResults + include Mandate + + initialize_with :submission, :test_results_json + + def call + Submission::TestRun::Process.( + FauxToolingJob.new(submission, test_results_json) + ) + end + + # Rather than rewrite this critical component, for now + # we're just stubbing a tooling job as if it had come back + # from the server. + class FauxToolingJob + include Mandate + + initialize_with :submission, :test_results_json do + @id = SecureRandom.uuid + end + + attr_reader :id + + delegate :uuid, to: :submission, prefix: true + def execution_status = 200 + def source = { "exercise_git_sha" => submission.solution.git_sha } + def execution_output = { "results.json" => test_results_json } + end +end diff --git a/app/controllers/api/solutions/submissions_controller.rb b/app/controllers/api/solutions/submissions_controller.rb index 2d1ea15203..71f3324cba 100644 --- a/app/controllers/api/solutions/submissions_controller.rb +++ b/app/controllers/api/solutions/submissions_controller.rb @@ -16,7 +16,7 @@ def create # TODO: (Required) Allow rerunning of tests if previous submission was an error / ops error / timeout begin - submission = Submission::Create.(solution, files, :api) + submission = Submission::Create.(solution, files, :api, params[:test_results_json]) rescue DuplicateSubmissionError return render_error(400, :duplicate_submission) end diff --git a/app/helpers/react_components/editor.rb b/app/helpers/react_components/editor.rb index c25856fd5e..7cba24b335 100644 --- a/app/helpers/react_components/editor.rb +++ b/app/helpers/react_components/editor.rb @@ -75,7 +75,8 @@ def data icon_url: track.icon_url, median_wait_time: track.median_wait_time }, - show_deep_dive_video: show_deep_dive_video? + show_deep_dive_video: show_deep_dive_video?, + local_test_runner: } end @@ -102,6 +103,12 @@ def show_deep_dive_video? true end + def local_test_runner + return nil unless track.slug == "javascript" + + Solution::GenerateTestRunConfig.(solution) + end + def mark_video_as_seen_endpoint return nil if solution.user.watched_video?(:youtube, exercise.deep_dive_youtube_id) diff --git a/app/javascript/components/Editor.tsx b/app/javascript/components/Editor.tsx index 382d969d6b..d533a36d07 100644 --- a/app/javascript/components/Editor.tsx +++ b/app/javascript/components/Editor.tsx @@ -52,6 +52,7 @@ import { import { RealtimeFeedbackModal } from './modals' import { ChatGptTab } from './editor/ChatGptFeedback/ChatGptTab' import { ChatGptPanel } from './editor/ChatGptFeedback/ChatGptPanel' +import { runTestsClientSide } from './editor/ClientSideTestRunner/generalTestRunner' export type TabIndex = | 'instructions' @@ -127,7 +128,9 @@ export default ({ current: submission, set: setSubmission, remove: removeSubmission, - } = useSubmissionsList(defaultSubmissions, { create: links.runTests }) + } = useSubmissionsList(defaultSubmissions, { + create: links.runTests, + }) const { revertToExerciseStart, revertToLastIteration } = useFileRevert() const { create: createIteration } = useIteration() const { get: getFiles, set: setFiles } = useEditorFiles({ @@ -162,44 +165,50 @@ export default ({ else setIsProcessing(false) }, [status, testRunStatus]) - const runTests = useCallback(() => { + const runTests = useCallback(async () => { dispatch({ status: EditorStatus.CREATING_SUBMISSION }) - createSubmission(files, { - onSuccess: () => { - dispatch({ status: EditorStatus.INITIALIZED }) - setSubmissionFiles(files) - setHasLatestIteration(false) - }, - onError: async (error) => { - let editorError: null | Promise<{ type: string; message: string }> = - null + const testResults = await runTestsClientSide(files) - if (error instanceof Error) { - editorError = Promise.resolve({ - type: 'unknown', - message: 'Unable to submit file. Please try again.', - }) - } else if (error instanceof Response) { - editorError = error - .json() - .then((json) => json.error) - .catch(() => { - return { - type: 'unknown', - message: 'Unable to submit file. Please try again.', - } + createSubmission( + { files, testResults }, + { + onSuccess: () => { + console.log('SUCCESS') + dispatch({ status: EditorStatus.INITIALIZED }) + setSubmissionFiles(files) + setHasLatestIteration(false) + }, + onError: async (error) => { + let editorError: null | Promise<{ type: string; message: string }> = + null + + if (error instanceof Error) { + editorError = Promise.resolve({ + type: 'unknown', + message: 'Unable to submit file. Please try again.', }) - } + } else if (error instanceof Response) { + editorError = error + .json() + .then((json) => json.error) + .catch(() => { + return { + type: 'unknown', + message: 'Unable to submit file. Please try again.', + } + }) + } - if (editorError) { - dispatch({ - status: EditorStatus.CREATE_SUBMISSION_FAILED, - error: await editorError, - }) - } - }, - }) + if (editorError) { + dispatch({ + status: EditorStatus.CREATE_SUBMISSION_FAILED, + error: await editorError, + }) + } + }, + } + ) }, [createSubmission, dispatch, files]) const showFeedbackModal = useCallback(() => { diff --git a/app/javascript/components/editor/ClientSideTestRunner/generalTestRunner.ts b/app/javascript/components/editor/ClientSideTestRunner/generalTestRunner.ts new file mode 100644 index 0000000000..efb6818643 --- /dev/null +++ b/app/javascript/components/editor/ClientSideTestRunner/generalTestRunner.ts @@ -0,0 +1,20 @@ +import { File } from '../../types' +import { runJsTests } from './jsTestRunner' +import { runRubyTests } from './rubyTestRunner' + +export async function runTestsClientSide(files: File[]) { + const solutionFile = files[0] + + const fileExtension = solutionFile.filename.split('.').pop() + + switch (fileExtension) { + case 'js': + return runJsTests() + + case 'rb': + return await runRubyTests() + + default: + return null + } +} diff --git a/app/javascript/components/editor/ClientSideTestRunner/jsTestRunner.ts b/app/javascript/components/editor/ClientSideTestRunner/jsTestRunner.ts new file mode 100644 index 0000000000..de27d5485d --- /dev/null +++ b/app/javascript/components/editor/ClientSideTestRunner/jsTestRunner.ts @@ -0,0 +1,34 @@ +export function runJsTests() { + // run tests here with the js test-runner from npm and return results + + return TEST_RESULTS +} + +const TEST_RESULTS = { + version: 3, + status: 'pass', + message: null, + messageHtml: null, + output: null, + outputHtml: null, + tests: [ + { + name: 'Hello World > Say Hi!', + status: 'pass', + testCode: "expect(hello()).toEqual('Hello, World!');", + message: + 'Error: \u001b[2mexpect(\u001b[22m\u001b[31mreceived\u001b[39m\u001b[2m).\u001b[22mtoEqual\u001b[2m(\u001b[22m\u001b[32mexpected\u001b[39m\u001b[2m) // deep equality\u001b[22m\n\nExpected: \u001b[32m"\u001b[7mHello, World\u001b[27m!"\u001b[39m\nReceived: \u001b[31m"\u001b[7mGoodbye, Mars\u001b[27m!"\u001b[39m', + messageHtml: + "Error: expect(received).toEqual(expected) // deep equality\n\nExpected: "Hello, World!"\nReceived: "Goodbye, Mars!"", + expected: null, + output: null, + outputHtml: null, + taskId: null, + }, + ], + tasks: [], + highlightjsLanguage: 'javascript', + links: { + self: 'http://local.exercism.io:3020/api/v2/solutions/b714573e50244417a0812ca49cc76a1d/submissions/71487f490f584bfaa61a0051bd244932/test_run', + }, +} diff --git a/app/javascript/components/editor/ClientSideTestRunner/rubyTestRunner.ts b/app/javascript/components/editor/ClientSideTestRunner/rubyTestRunner.ts new file mode 100644 index 0000000000..4ba397a25c --- /dev/null +++ b/app/javascript/components/editor/ClientSideTestRunner/rubyTestRunner.ts @@ -0,0 +1,51 @@ +import { DefaultRubyVM } from '@ruby/wasm-wasi/dist/browser' + +export async function runRubyTests() { + const response = await fetch('/wasm/ruby/ruby+stdlib.wasm') + const module = await WebAssembly.compileStreaming(response) + + const { vm } = await DefaultRubyVM(module) + + globalThis.RUBY_TEST_RESULTS = null + exec(vm) + + console.log('RES', globalThis.RUBY_TEST_RESULTS) + return globalThis.RUBY_TEST_RESULTS +} + +function exec(vm) { + vm.eval(` + require "js" + JS.global[:console].log("HERE!!!") + + result = { + version: 3, + status: 'pass', + message: nil, + messageHtml: nil, + output: nil, + outputHtml: nil, + tests: [ + { + name: 'Hello World > Say Hi!', + status: 'pass', + testCode: "expect(hello()).toEqual('Hello, World!');", + message: + 'Error: \u001b[2mexpect(\u001b[22m\u001b[31mreceived\u001b[39m\u001b[2m).\u001b[22mtoEqual\u001b[2m(\u001b[22m\u001b[32mexpected\u001b[39m\u001b[2m) // deep equality\u001b[22m\n\nExpected: \u001b[32m"\u001b[7mHello, World\u001b[27m!"\u001b[39m\nReceived: \u001b[31m"\u001b[7mGoodbye, Mars\u001b[27m!"\u001b[39m', + messageHtml: + "Error: expect(received).toEqual(expected) // deep equality\n\nExpected: "Hello, World!"\nReceived: "Goodbye, Mars!"", + expected: nil, + output: nil, + outputHtml: nil, + taskId: nil, + }, + ], + tasks: [], + highlightjsLanguage: 'javascript', + links: { + self: 'http://local.exercism.io:3020/api/v2/solutions/b714573e50244417a0812ca49cc76a1d/submissions/71487f490f584bfaa61a0051bd244932/test_run', + }, + } + JS.global["RUBY_TEST_RESULTS"] = result + `) +} diff --git a/app/javascript/components/editor/useSubmissionsList.ts b/app/javascript/components/editor/useSubmissionsList.ts index 557e1b334a..bb85337750 100644 --- a/app/javascript/components/editor/useSubmissionsList.ts +++ b/app/javascript/components/editor/useSubmissionsList.ts @@ -9,13 +9,18 @@ type Links = { create: string } +type CreateSubmissionParams = { + files: File[] + testResults: Object | null +} + export const useSubmissionsList = ( defaultList: readonly Submission[], links: Links ): { current: Submission | null create: ( - files: File[], + params: CreateSubmissionParams, config?: { onSuccess: () => void; onError: (error: unknown) => void } ) => void set: (uuid: string, data: Submission) => void @@ -23,12 +28,18 @@ export const useSubmissionsList = ( } => { const [list, setList] = useState(defaultList) - const { mutate: create } = useMutation( - async (files) => { + const { mutate: create } = useMutation< + Submission, + unknown, + CreateSubmissionParams + >( + async ({ files, testResults }) => { + const testResultsJson = testResults ? JSON.stringify(testResults) : null + console.log('Creating submission with files:', files, testResultsJson) const { fetch } = sendRequest({ endpoint: links.create, method: 'POST', - body: JSON.stringify({ files: files }), + body: JSON.stringify({ files, test_results_json: testResultsJson }), }) return fetch.then((response) => diff --git a/package.json b/package.json index 3e9a822cb4..44bff9ca54 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,8 @@ "@popperjs/core": "^2.11.8", "@rails/actioncable": "^6.0.0", "@replit/codemirror-interact": "^6.3.1", + "@ruby/3.4-wasm-wasi": "^2.7.1", + "@ruby/wasm-wasi": "^2.7.1", "@sector-labs/postcss-inline-class": "^0.0.6", "@stripe/react-stripe-js": "^2.1.1", "@stripe/stripe-js": "^1.54.1", @@ -211,7 +213,8 @@ "test": "jest", "test-watch": "jest --watchAll", "build:css": "postcss ./app/css/packs/**/*.css --dir .built-assets", - "build": "./app/javascript/esbuild.js", + "copy:wasm": "node ./scripts/wasm/ruby.js", + "build": "yarn copy:wasm && ./app/javascript/esbuild.js", "prepare": "husky install" }, "resolutions": { diff --git a/scripts/wasm/ruby.js b/scripts/wasm/ruby.js new file mode 100644 index 0000000000..3b4c994c78 --- /dev/null +++ b/scripts/wasm/ruby.js @@ -0,0 +1,16 @@ +const fs = require('fs') +const path = require('path') + +const src = path.resolve( + __dirname, // this is scripts/wasm/ + '../../node_modules/@ruby/3.4-wasm-wasi/dist/ruby+stdlib.wasm' +) +const destDir = path.resolve(__dirname, '../../public/wasm/ruby') +const dest = path.join(destDir, 'ruby+stdlib.wasm') + +if (!fs.existsSync(destDir)) { + fs.mkdirSync(destDir, { recursive: true }) +} + +fs.copyFileSync(src, dest) +console.log('✅ Copied ruby+stdlib.wasm to public/wasm/ruby/') diff --git a/test/commands/submission/create_test.rb b/test/commands/submission/create_test.rb index 6e82786097..628a8461a2 100644 --- a/test/commands/submission/create_test.rb +++ b/test/commands/submission/create_test.rb @@ -110,4 +110,25 @@ class Submission::CreateTest < ActiveSupport::TestCase assert_equal solution.track, metric.track assert_equal solution.user, metric.user end + + test "processes test run if it's passed and doesn't create new one" do + test_results_json = { "foo": "bar" }.to_json + solution = create :concept_solution + + files = [{ filename: "subdir/foobar.rb", content: "'I think' = 'I am'" }] + + test_run_id = SecureRandom.uuid + submission_uuid = SecureRandom.uuid + SecureRandom.stubs(uuid: test_run_id) + SecureRandom.stubs(compact_uuid: submission_uuid) + Submission::TestRun::Init.expects(:call).never + Submission::TestRun::Process.expects(:call).with do |tooling_job| + assert_equal test_run_id, tooling_job.id + assert_equal submission_uuid, tooling_job.submission_uuid + assert_equal 200, tooling_job.execution_status + assert_equal({ "exercise_git_sha": solution.git_sha }, tooling_job.source) + assert_equal({ "results.json": test_results_json }, tooling_job.execution_output) + end + Submission::Create.(solution, files, :api, test_results_json) + end end diff --git a/test/controllers/api/solutions/submissions_controller_test.rb b/test/controllers/api/solutions/submissions_controller_test.rb index 486aa9b1db..f40cd38175 100644 --- a/test/controllers/api/solutions/submissions_controller_test.rb +++ b/test/controllers/api/solutions/submissions_controller_test.rb @@ -66,7 +66,7 @@ class API::Solutions::SubmissionsControllerTest < API::BaseTestCase { filename: "foo", content: "bar" }, { filename: "bar", content: "foo" } ] - Submission::Create.expects(:call).with(solution, files, :api).returns(create(:submission)) + Submission::Create.expects(:call).with(solution, files, :api, nil).returns(create(:submission)) post api_solution_submissions_path(solution.uuid), params: { files: }, @@ -76,6 +76,25 @@ class API::Solutions::SubmissionsControllerTest < API::BaseTestCase assert_response :created end + test "create should proxy test results" do + setup_user + solution = create :concept_solution, user: @current_user + + files = [ + { filename: "foo", content: "bar" }, + { filename: "bar", content: "foo" } + ] + test_results_json = { "foo": "bar" }.to_json + Submission::Create.expects(:call).with(solution, files, :api, test_results_json).returns(create(:submission)) + + post api_solution_submissions_path(solution.uuid, test_results_json:), + params: { files: }, + headers: @headers, + as: :json + + assert_response :created + end + test "create is rate limited" do setup_user diff --git a/yarn.lock b/yarn.lock index ec643ccc40..e7660f0c88 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1050,6 +1050,11 @@ resolved "https://registry.yarnpkg.com/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz#75a2e8b51cb758a7553d6804a5932d7aace75c39" integrity sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw== +"@bjorn3/browser_wasi_shim@^0.3.0": + version "0.3.0" + resolved "https://registry.yarnpkg.com/@bjorn3/browser_wasi_shim/-/browser_wasi_shim-0.3.0.tgz#8aa310eed2298bab435bd1f73ab100fbc3f018da" + integrity sha512-FlRBYttPRLcWORzBe6g8nmYTafBkOEFeOqMYM4tAHJzFsQy4+xJA94z85a9BCs8S+Uzfh9LrkpII7DXr2iUVFg== + "@bugsnag/browser@^7.25.0": version "7.25.0" resolved "https://registry.yarnpkg.com/@bugsnag/browser/-/browser-7.25.0.tgz#aa56a8e138dfff268ac29c5fe374cfc3c9b42a76" @@ -2055,6 +2060,21 @@ dependencies: "@codemirror/state" "^6.2.1" +"@ruby/3.4-wasm-wasi@^2.7.1": + version "2.7.1" + resolved "https://registry.yarnpkg.com/@ruby/3.4-wasm-wasi/-/3.4-wasm-wasi-2.7.1.tgz#4c42ed6521982b1ae2cde8ca0b7c870cc6d814d8" + integrity sha512-uPRvWNL0eR3nYV1iiuJ/QBREUCqc3QKQIJw1iDmuLjiWsftqPfLxN6t0cEtF5mOGh90EZG/NptJoJ4SlJrfzHw== + dependencies: + "@ruby/wasm-wasi" "^2.0.0" + +"@ruby/wasm-wasi@^2.0.0", "@ruby/wasm-wasi@^2.7.1": + version "2.7.1" + resolved "https://registry.yarnpkg.com/@ruby/wasm-wasi/-/wasm-wasi-2.7.1.tgz#72816add3dd665e6c6bcb39b69567f76b4c28ccb" + integrity sha512-2f4NqiJuFoeYiXNr60PH3TbH5c+z/xP2Hq36Av2yahp05AaLDyJxWZwr9EGmfoFsmVTmeDdEW2KjPTfpue6xeg== + dependencies: + "@bjorn3/browser_wasi_shim" "^0.3.0" + tslib "^2.8.1" + "@sector-labs/postcss-inline-class@^0.0.6": version "0.0.6" resolved "https://registry.yarnpkg.com/@sector-labs/postcss-inline-class/-/postcss-inline-class-0.0.6.tgz#b88965715d6eed5b079df31b33ee66d212fb5cd6"