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"