Skip to content

Add working WASM Ruby runner #7840

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 7 commits into
base: main
Choose a base branch
from
Open
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -20,6 +20,7 @@
!/storage/.keep

/public/assets
/public/wasm
.byebug_history

# Ignore master key for decrypting credentials and more.
22 changes: 22 additions & 0 deletions app/commands/solution/generate_test_run_config.rb
Original file line number Diff line number Diff line change
@@ -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
19 changes: 11 additions & 8 deletions app/commands/submission/create.rb
Original file line number Diff line number Diff line change
@@ -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!
6 changes: 3 additions & 3 deletions app/commands/submission/test_run/process.rb
Original file line number Diff line number Diff line change
@@ -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
29 changes: 29 additions & 0 deletions app/commands/submission/test_run/process_client_side_results.rb
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion app/controllers/api/solutions/submissions_controller.rb
Original file line number Diff line number Diff line change
@@ -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
9 changes: 8 additions & 1 deletion app/helpers/react_components/editor.rb
Original file line number Diff line number Diff line change
@@ -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)

77 changes: 43 additions & 34 deletions app/javascript/components/Editor.tsx
Original file line number Diff line number Diff line change
@@ -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(() => {
Original file line number Diff line number Diff line change
@@ -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
}
}
Original file line number Diff line number Diff line change
@@ -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(<span style='color:#A00;'>received</span>).toEqual(<span style='color:#0A0;'>expected</span>) // deep equality\n\nExpected: <span style='color:#0A0;'>&quot;Hello, World!&quot;</span>\nReceived: <span style='color:#A00;'>&quot;Goodbye, Mars!&quot;</span>",
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',
},
}
Original file line number Diff line number Diff line change
@@ -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(<span style='color:#A00;'>received</span>).toEqual(<span style='color:#0A0;'>expected</span>) // deep equality\n\nExpected: <span style='color:#0A0;'>&quot;Hello, World!&quot;</span>\nReceived: <span style='color:#A00;'>&quot;Goodbye, Mars!&quot;</span>",
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
`)
}
Loading
Oops, something went wrong.
Loading
Oops, something went wrong.