Skip to content

Commit 96f0c52

Browse files
dem4roniHiD
andauthored
Add clientside js test-runner (exercism#7865)
* Add local JavaScript test runner (exercism#7838) * Add test_results_json option to submission create * Insert stub test-result json into create testrun link * Fix things up * Add ClientSide test runner proto * Make work * Add local setup files * Wire in `@exercism/javascript-browser-test-runner` * Update app/commands/solution/generate_test_run_config.rb * Refactor slightly * Refactor plugin, organise files better * Update jest transformIgnorePatterns * Make runTestsClientSide more robust * Make submission/create_test green * Fix create_test --------- Co-authored-by: dem4ron <[email protected]> * Fix submitting status as 'grace' (exercism#7864) * Add shims to esbuild * Lazy import runTestsClientSide * Remove esbuild shizzle --------- Co-authored-by: Jeremy Walker <[email protected]>
1 parent da28833 commit 96f0c52

File tree

25 files changed

+428
-53
lines changed

25 files changed

+428
-53
lines changed
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
class Solution::GenerateTestRunConfig
2+
include Mandate
3+
4+
initialize_with :solution
5+
6+
def call
7+
return nil unless [1530, 38_366, 757_288].include?(solution.user_id)
8+
return nil unless track.slug == "javascript"
9+
10+
{
11+
files: exercise_repo.tooling_files
12+
}
13+
end
14+
15+
private
16+
delegate :track, to: :solution
17+
18+
memoize
19+
def exercise_repo
20+
Git::Exercise.new(
21+
solution.git_slug,
22+
solution.git_type,
23+
solution.git_sha,
24+
repo_url: solution.track.repo_url
25+
)
26+
end
27+
end

app/commands/submission/create.rb

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,15 @@
11
class Submission::Create
22
include Mandate
33

4-
def initialize(solution, files, submitted_via)
4+
def initialize(solution, raw_files, submitted_via, test_results_json = nil)
55
@submission_uuid = SecureRandom.compact_uuid
6-
76
@solution = solution
8-
@submitted_files = files
97
@submitted_via = submitted_via
8+
@test_results_json = test_results_json
109

1110
# TODO: (Optional) - Move this into another service
1211
# TODO: (Optional) - Consider risks around filenames
13-
@submitted_files.each do |f|
12+
@submitted_files = raw_files.each do |f|
1413
f[:digest] = Digest::SHA1.hexdigest(f[:content])
1514
end
1615
end
@@ -21,7 +20,7 @@ def call
2120

2221
create_submission!
2322
create_files!
24-
init_test_run!
23+
handle_test_run!
2524
schedule_jobs!
2625
log_metric!
2726

@@ -30,7 +29,7 @@ def call
3029
end
3130

3231
private
33-
attr_reader :solution, :submitted_files, :submission_uuid, :submitted_via, :submission
32+
attr_reader :solution, :submitted_files, :submission_uuid, :submitted_via, :submission, :test_results_json
3433

3534
delegate :track, :user, to: :solution
3635

@@ -61,8 +60,12 @@ def create_files!
6160
end
6261
end
6362

64-
def init_test_run!
65-
Submission::TestRun::Init.(submission)
63+
def handle_test_run!
64+
if test_results_json.present?
65+
Submission::TestRun::ProcessClientSideResults.(submission, test_results_json)
66+
else
67+
Submission::TestRun::Init.(submission)
68+
end
6669
end
6770

6871
def schedule_jobs!
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
class Submission::TestRun::ProcessClientSideResults
2+
include Mandate
3+
4+
initialize_with :submission, :test_results_json
5+
6+
def call
7+
Submission::TestRun::Process.(
8+
FauxToolingJob.new(submission, test_results_json)
9+
)
10+
end
11+
12+
# Rather than rewrite this critical component, for now
13+
# we're just stubbing a tooling job as if it had come back
14+
# from the server.
15+
class FauxToolingJob
16+
include Mandate
17+
18+
initialize_with :submission, :test_results_json do
19+
@id = SecureRandom.uuid
20+
end
21+
22+
attr_reader :id
23+
24+
delegate :uuid, to: :submission, prefix: true
25+
def execution_status = 200
26+
def source = { "exercise_git_sha" => submission.solution.git_sha }
27+
def execution_output = { "results.json" => test_results_json }
28+
end
29+
end

app/controllers/api/solutions/submissions_controller.rb

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ def create
1616

1717
# TODO: (Required) Allow rerunning of tests if previous submission was an error / ops error / timeout
1818
begin
19-
submission = Submission::Create.(solution, files, :api)
19+
submission = Submission::Create.(solution, files, :api, params[:test_results_json])
2020
rescue DuplicateSubmissionError
2121
return render_error(400, :duplicate_submission)
2222
end
@@ -28,6 +28,6 @@ def create
2828

2929
private
3030
def submission_params
31-
params.permit(files: %i[filename content])
31+
params.permit(:test_results_json, :solution_uuid, files: %i[filename content type])
3232
end
3333
end

app/helpers/react_components/editor.rb

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,8 @@ def data
7575
icon_url: track.icon_url,
7676
median_wait_time: track.median_wait_time
7777
},
78-
show_deep_dive_video: show_deep_dive_video?
78+
show_deep_dive_video: show_deep_dive_video?,
79+
local_test_runner:
7980
}
8081
end
8182

@@ -102,6 +103,10 @@ def show_deep_dive_video?
102103
true
103104
end
104105

106+
def local_test_runner
107+
Solution::GenerateTestRunConfig.(solution)
108+
end
109+
105110
def mark_video_as_seen_endpoint
106111
return nil if solution.user.watched_video?(:youtube, exercise.deep_dive_youtube_id)
107112

app/javascript/components/Editor.tsx

Lines changed: 56 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,7 @@ export default ({
108108
showDeepDiveVideo,
109109
hasAvailableMentoringSlot,
110110
features = { theme: false, keybindings: false },
111+
localTestRunner,
111112
}: Props): JSX.Element => {
112113
const editorRef = useRef<FileEditorHandle>()
113114
const runTestsButtonRef = useRef<HTMLButtonElement>(null)
@@ -127,7 +128,9 @@ export default ({
127128
current: submission,
128129
set: setSubmission,
129130
remove: removeSubmission,
130-
} = useSubmissionsList(defaultSubmissions, { create: links.runTests })
131+
} = useSubmissionsList(defaultSubmissions, {
132+
create: links.runTests,
133+
})
131134
const { revertToExerciseStart, revertToLastIteration } = useFileRevert()
132135
const { create: createIteration } = useIteration()
133136
const { get: getFiles, set: setFiles } = useEditorFiles({
@@ -162,44 +165,63 @@ export default ({
162165
else setIsProcessing(false)
163166
}, [status, testRunStatus])
164167

165-
const runTests = useCallback(() => {
168+
const runTests = useCallback(async () => {
166169
dispatch({ status: EditorStatus.CREATING_SUBMISSION })
167170

168-
createSubmission(files, {
169-
onSuccess: () => {
170-
dispatch({ status: EditorStatus.INITIALIZED })
171-
setSubmissionFiles(files)
172-
setHasLatestIteration(false)
173-
},
174-
onError: async (error) => {
175-
let editorError: null | Promise<{ type: string; message: string }> =
176-
null
171+
let testResults: any = null
172+
try {
173+
const { runTestsClientSide } = await import(
174+
'./editor/ClientSideTestRunner/generalTestRunner'
175+
)
176+
177+
testResults = await runTestsClientSide({
178+
trackSlug: track.slug,
179+
exerciseSlug: exercise.slug,
180+
config: localTestRunner,
181+
files,
182+
})
183+
} catch (e) {
184+
console.warn('There was an error running tests clientside:', e)
185+
}
177186

178-
if (error instanceof Error) {
179-
editorError = Promise.resolve({
180-
type: 'unknown',
181-
message: 'Unable to submit file. Please try again.',
182-
})
183-
} else if (error instanceof Response) {
184-
editorError = error
185-
.json()
186-
.then((json) => json.error)
187-
.catch(() => {
188-
return {
189-
type: 'unknown',
190-
message: 'Unable to submit file. Please try again.',
191-
}
187+
createSubmission(
188+
{ files, testResults },
189+
{
190+
onSuccess: () => {
191+
dispatch({ status: EditorStatus.INITIALIZED })
192+
setSubmissionFiles(files)
193+
setHasLatestIteration(false)
194+
},
195+
onError: async (error) => {
196+
let editorError: null | Promise<{ type: string; message: string }> =
197+
null
198+
199+
if (error instanceof Error) {
200+
editorError = Promise.resolve({
201+
type: 'unknown',
202+
message: 'Unable to submit file. Please try again.',
192203
})
193-
}
204+
} else if (error instanceof Response) {
205+
editorError = error
206+
.json()
207+
.then((json) => json.error)
208+
.catch(() => {
209+
return {
210+
type: 'unknown',
211+
message: 'Unable to submit file. Please try again.',
212+
}
213+
})
214+
}
194215

195-
if (editorError) {
196-
dispatch({
197-
status: EditorStatus.CREATE_SUBMISSION_FAILED,
198-
error: await editorError,
199-
})
200-
}
201-
},
202-
})
216+
if (editorError) {
217+
dispatch({
218+
status: EditorStatus.CREATE_SUBMISSION_FAILED,
219+
error: await editorError,
220+
})
221+
}
222+
},
223+
}
224+
)
203225
}, [createSubmission, dispatch, files])
204226

205227
const showFeedbackModal = useCallback(() => {
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import { OutputInterface } from '@exercism/javascript-browser-test-runner/src/output'
2+
import { File } from '../../types'
3+
import { runTests } from '@exercism/javascript-browser-test-runner'
4+
5+
type FileMap = Record<string, string>
6+
7+
interface RunTestsClientSideParams {
8+
trackSlug: string
9+
exerciseSlug: string
10+
config?: { files?: FileMap }
11+
files: File[]
12+
}
13+
14+
export async function runTestsClientSide({
15+
trackSlug,
16+
exerciseSlug,
17+
config = {},
18+
files,
19+
}: RunTestsClientSideParams): Promise<OutputInterface | null> {
20+
try {
21+
if (!trackSlug || !exerciseSlug || !Array.isArray(files)) {
22+
console.warn('Missing required params in runTestsClientSide')
23+
return null
24+
}
25+
26+
switch (trackSlug) {
27+
case 'javascript': {
28+
const studentFileMap: FileMap = Object.fromEntries(
29+
files
30+
.filter(
31+
(f): f is File => !!f.filename && typeof f.content === 'string'
32+
)
33+
.map(({ filename, content }) => [filename, content])
34+
)
35+
36+
if (Object.keys(studentFileMap).length === 0) {
37+
console.warn('studentFileMap is empty in runTestsClientSide')
38+
return null
39+
}
40+
41+
const allFiles: FileMap = {
42+
...(config.files || {}),
43+
...studentFileMap,
44+
}
45+
46+
const studentFilenames = Object.keys(studentFileMap)
47+
48+
return await runTests(exerciseSlug, allFiles, studentFilenames)
49+
}
50+
51+
default:
52+
return null
53+
}
54+
} catch (error) {
55+
console.error('runTestsClientSide failed:', error)
56+
return null
57+
}
58+
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
export function runJsTests() {
2+
// run tests here with the js test-runner from npm and return results
3+
4+
return TEST_RESULTS
5+
}
6+
7+
const TEST_RESULTS = {
8+
version: 3,
9+
status: 'pass',
10+
message: null,
11+
messageHtml: null,
12+
output: null,
13+
outputHtml: null,
14+
tests: [
15+
{
16+
name: 'Hello World > Say Hi!',
17+
status: 'pass',
18+
testCode: "expect(hello()).toEqual('Hello, World!');",
19+
message:
20+
'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',
21+
messageHtml:
22+
"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>",
23+
expected: null,
24+
output: null,
25+
outputHtml: null,
26+
taskId: null,
27+
},
28+
],
29+
tasks: [],
30+
highlightjsLanguage: 'javascript',
31+
links: {
32+
self: 'http://local.exercism.io:3020/api/v2/solutions/b714573e50244417a0812ca49cc76a1d/submissions/71487f490f584bfaa61a0051bd244932/test_run',
33+
},
34+
}

app/javascript/components/editor/Props.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,4 +108,5 @@ export type Props = {
108108
trackObjectives: string
109109
showDeepDiveVideo: boolean
110110
hasAvailableMentoringSlot: boolean
111+
localTestRunner: { files: Record<string, string> }
111112
}

0 commit comments

Comments
 (0)