Skip to content
Closed
Show file tree
Hide file tree
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
Expand Up @@ -20,6 +20,7 @@
!/storage/.keep

/public/assets
/public/wasm
.byebug_history

# Ignore master key for decrypting credentials and more.
Expand Down
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
Expand All @@ -21,7 +20,7 @@ def call

create_submission!
create_files!
init_test_run!
handle_test_run!
schedule_jobs!
log_metric!

Expand All @@ -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

Expand Down Expand Up @@ -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!
Expand Down
6 changes: 3 additions & 3 deletions app/commands/submission/test_run/process.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
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
Expand Up @@ -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
Expand Down
9 changes: 8 additions & 1 deletion app/helpers/react_components/editor.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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)

Expand Down
77 changes: 43 additions & 34 deletions app/javascript/components/Editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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(() => {
Expand Down
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
Loading