Skip to content

Commit 1756940

Browse files
v3 using chainguard wolfi-base
Support interface version 3 https://exercism.org/docs/building/tooling/test-runners/interface We move from debian to chainguard wolfi-base to reduce image size. (Alpine doesn't expose readdir64_r, expected by Factor.) Each exercise now includes exercism-tools testing library. This defines - STOP-HERE - TASK - run-exercism-tests https://github.com/exercism/factor/ has been updated to use this testing library. We omit some unused files from the factor distribution: we don't need to support interactive help.
1 parent b025776 commit 1756940

42 files changed

Lines changed: 1170 additions & 75 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

Dockerfile

Lines changed: 73 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,12 +17,80 @@ RUN rm -rf .git build vm src misc Factor.app \
1717
extra GNUmakefile Nmakefile LICENSE.txt README.md \
1818
build.sh build.cmd unmaintained
1919

20-
FROM debian:bookworm-slim
20+
# Prune basis subdirs that exercism tests cannot reach. Source files only —
21+
# the precompiled bytecode for these is in factor.image, which is unaffected.
22+
# - GUI and graphics: ui, opengl, cairo, gdk2/3/4, gtk2/3/4, gdk-pixbuf, gsk4,
23+
# glib, gmodule, gobject, gio, graphene, atk, gobject-introspection, gir,
24+
# pango, x11, fonts, images
25+
# - macOS-specific: cocoa, core-foundation, core-graphics, core-text, iokit
26+
# - Windows-specific: windows
27+
# - Linux-specific: linux
28+
# - Networking, RPC, web: dns, ftp, html, http, mime, oauth1, oauth2, openssl,
29+
# resolv-conf, smtp, syndication, urls, webbrowser, xml, xml-rpc
30+
# - Data formats: cbor, csv, ini-file, json, msgpack, pack, quoted-printable,
31+
# serialize, toml, uu
32+
# - Persistence / db: couchdb, db
33+
# - Encoding / hashing / compression: base16, base24, base32, base36, base45,
34+
# base58, base62, base64, base85, base91, base92, checksums, compression,
35+
# crypto, hex-strings
36+
# - Specialised data structures with no exercise use: biassocs, bit-arrays,
37+
# bit-sets, bit-vectors, bitstreams, bloom-filters, boxes, circular,
38+
# columns, cuckoo-filters, disjoint-sets, dlists, heaps, interval-maps,
39+
# interval-sets, lazy, linked-assocs, linked-sets, lists, named-tuples,
40+
# nibble-arrays, reservoir-sampling, search-deques, specialized-arrays,
41+
# specialized-vectors, suffix-arrays, tuple-arrays, unrolled-lists, vlists
42+
# - Pattern, parsing, text utilities: globs, lcs, match, peg,
43+
# porter-stemmer, regexp, simple-tokenizer, tr, wrap
44+
# - Editor / interactive only: documents, inspector, listener, see, xdg
45+
# - Other unused: xmode (syntax highlighting), game, farkup (markup),
46+
# calendar, colors, delegate, escape-strings, etc-hosts, eval, interpolate,
47+
# ip-parser, logging, memoize, method-chains, mirrors, models, nmake, ntp,
48+
# protocols, quoting, refs, retries, roman, simple-flat-file, system-info,
49+
# timers, typed, uuid, validators
50+
RUN cd basis && rm -rf \
51+
atk cairo cocoa core-foundation core-graphics core-text fonts \
52+
farkup game gdk2 gdk3 gdk4 gdk-pixbuf gio gir glib gmodule \
53+
gobject gobject-introspection graphene gsk4 gtk2 gtk3 gtk4 \
54+
images iokit linux opengl pango ui windows x11 xmode \
55+
editors furnace help \
56+
dns ftp html http mime oauth1 oauth2 openssl resolv-conf smtp \
57+
syndication urls webbrowser xml xml-rpc \
58+
cbor csv ini-file json msgpack pack quoted-printable serialize toml uu \
59+
couchdb db \
60+
base16 base24 base32 base36 base45 base58 base62 base64 base85 base91 \
61+
base92 checksums compression crypto hex-strings \
62+
biassocs bit-arrays bit-sets bit-vectors bitstreams bloom-filters boxes \
63+
circular columns cuckoo-filters disjoint-sets dlists heaps \
64+
interval-maps interval-sets lazy linked-assocs linked-sets lists \
65+
named-tuples nibble-arrays reservoir-sampling search-deques \
66+
specialized-arrays specialized-vectors suffix-arrays tuple-arrays \
67+
unrolled-lists vlists \
68+
globs lcs match peg porter-stemmer regexp simple-tokenizer tr wrap \
69+
documents inspector listener see xdg \
70+
calendar colors delegate escape-strings etc-hosts eval interpolate \
71+
ip-parser logging memoize method-chains mirrors models nmake ntp \
72+
protocols quoting refs retries roman simple-flat-file system-info \
73+
timers typed uuid validators \
74+
bootstrap environment persistent random
2175

22-
RUN apt-get update && apt-get install -y --no-install-recommends \
23-
bash jq coreutils libstdc++6 \
24-
&& rm -rf /var/lib/apt/lists/* /usr/share/doc /usr/share/man \
25-
/usr/share/zoneinfo /usr/share/perl5 /var/lib/dpkg /var/cache
76+
# Asian and rare encodings: only utf8 / latin1 / strict are kept (everything
77+
# bundled streams use). 8-bit is the multi-codepage non-utf umbrella.
78+
RUN cd basis/io/encodings && rm -rf \
79+
8-bit big5 euc euc-jp euc-kr gb18030 iso2022 johab shift-jis utf32 utf7
80+
81+
# Drop *-docs.factor — only used by Factor's interactive help browser.
82+
# *-tests.factor and tags.txt / summary.txt / authors.txt are kept: Factor's
83+
# vocab loader and `test` word reference them and removing them breaks
84+
# at least basis/binary-search at runtime.
85+
RUN find . -name '*-docs.factor' -delete
86+
87+
88+
FROM cgr.dev/chainguard/wolfi-base
89+
90+
# Wolfi is glibc-based, so the Factor binary built on Debian above runs
91+
# without a compat shim. bash for run.sh; gawk + jq for the parser;
92+
# coreutils for realpath / mktemp.
93+
RUN apk add --no-cache bash coreutils gawk jq libstdc++
2694

2795
COPY --from=builder /opt/factor /opt/factor
2896
ENV PATH="/opt/factor:${PATH}" \

bin/run-tests.sh

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
#!/usr/bin/env sh
22

33
# Synopsis:
4-
# Test the test runner by running it against a predefined set of solutions
4+
# Test the test runner by running it against a predefined set of solutions
55
# with an expected output.
66

77
# Output:
@@ -13,20 +13,19 @@
1313

1414
exit_code=0
1515

16-
# Iterate over all test directories
16+
# Iterate over all test directories. bin/run.py copies each fixture to its own
17+
# temp dir before running Factor, so the committed fixture files are not
18+
# mutated. results.json is written to the fixture dir for post-run
19+
# investigation (gitignored).
1720
for test_dir in tests/*; do
1821
test_dir_name=$(basename "${test_dir}")
1922
test_dir_path=$(realpath "${test_dir}")
2023

2124
bin/run.sh "${test_dir_name}" "${test_dir_path}" "${test_dir_path}"
2225

23-
# Normalize paths in results to match Docker environment
24-
file="results.json"
25-
sed -i "s~${test_dir_path}~/opt/test-runner/tests/${test_dir_name}~g" "${test_dir_path}/${file}"
26-
expected_file="expected_${file}"
27-
echo "${test_dir_name}: comparing ${file} to ${expected_file}"
28-
29-
if ! diff "${test_dir_path}/${file}" "${test_dir_path}/${expected_file}"; then
26+
expected_file="expected_results.json"
27+
echo "${test_dir_name}: comparing results.json to ${expected_file}"
28+
if ! diff "${test_dir_path}/results.json" "${test_dir_path}/${expected_file}"; then
3029
exit_code=1
3130
fi
3231
done

bin/run.sh

Lines changed: 192 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,63 +1,214 @@
1-
#!/usr/bin/env sh
1+
#!/usr/bin/env bash
22

33
# Synopsis:
4-
# Run the test runner on a solution.
4+
# Run the test runner on a solution, producing v3-format results.json.
5+
#
6+
# Strategy: post-process the text output from
7+
# `factor -roots=. -run=exercism-tools <slug>`. Factor's tools.test prints a
8+
# `Unit Test: <args>` header before each test, the test body's stdout, then
9+
# `--> test failed!` on failure. After all tests run, exercism-tools prints
10+
# per-failure blocks bracketed by `###FAIL_BEGIN###`/`###FAIL_END###`,
11+
# each containing `<path>: <line#>` and either an `=== Expected: / === Got:`
12+
# diff or a thrown error message.
13+
#
14+
# Spec: https://exercism.org/docs/building/tooling/test-runners/interface
515

616
# Arguments:
717
# $1: exercise slug
818
# $2: path to solution folder
919
# $3: path to output directory
1020

11-
# Output:
12-
# Writes the test results to a results.json file in the passed-in output directory.
13-
# The test results are formatted according to the specifications at https://github.com/exercism/docs/blob/main/building/tooling/test-runners/interface.md
21+
set -euo pipefail
1422

15-
# Example:
16-
# ./bin/run.sh two-fer path/to/solution/folder/ path/to/output/directory/
17-
18-
# If any required arguments is missing, print the usage and exit
19-
if [ -z "$1" ] || [ -z "$2" ] || [ -z "$3" ]; then
20-
echo "usage: ./bin/run.sh exercise-slug path/to/solution/folder/ path/to/output/directory/"
23+
if [[ -z "${1:-}" || -z "${2:-}" || -z "${3:-}" ]]; then
24+
echo "usage: $0 <slug> <solution-dir> <output-dir>" >&2
2125
exit 1
2226
fi
2327

2428
slug="$1"
2529
solution_dir=$(realpath "${2%/}")
2630
output_dir=$(realpath "${3%/}")
31+
mkdir -p "$output_dir"
2732
results_file="${output_dir}/results.json"
28-
29-
# Create the output directory if it doesn't exist
30-
mkdir -p "${output_dir}"
33+
canonical_root="/opt/test-runner/tests/${slug}"
34+
test_file="${solution_dir}/${slug}/${slug}-tests.factor"
3135

3236
echo "${slug}: testing..."
3337

34-
# Remove STOP-HERE lines to unskip all tests
35-
sed -i '/STOP-HERE/d' "${solution_dir}/${slug}/${slug}-tests.factor"
36-
37-
# Run the tests for the provided implementation file and redirect stdout and
38-
# stderr to capture it
39-
test_output=$(cd "${solution_dir}" && factor -e="USING: vocabs.loader tools.test tools.test.private namespaces kernel system ; \".\" add-vocab-root \"${slug}\" require \"${slug}\" test test-failures get empty? [ 0 exit ] [ 1 exit ] if" 2>&1)
40-
test_exit=$?
41-
test_output=$(printf '%s\n' "${test_output}" | grep -v "^fatal error for monitor root" | sed '/^(U) \[/,$d' | sed '/^$/d')
42-
43-
# Write the results.json file based on the exit code of the command that was
44-
# just executed that tested the implementation file
45-
if [ $test_exit -eq 0 ]; then
46-
jq -n '{version: 1, status: "pass"}' > ${results_file}
47-
else
48-
# OPTIONAL: Sanitize the output
49-
# In some cases, the test output might be overly verbose, in which case stripping
50-
# the unneeded information can be very helpful to the student
51-
# sanitized_test_output=$(printf "${test_output}" | sed -n '/Test results:/,$p')
52-
53-
# OPTIONAL: Manually add colors to the output to help scanning the output for errors
54-
# If the test output does not contain colors to help identify failing (or passing)
55-
# tests, it can be helpful to manually add colors to the output
56-
# colorized_test_output=$(echo "${test_output}" \
57-
# | GREP_COLOR='01;31' grep --color=always -E -e '^(ERROR:.*|.*failed)$|$' \
58-
# | GREP_COLOR='01;32' grep --color=always -E -e '^.*passed$|$')
59-
60-
jq -n --arg output "${test_output}" '{version: 1, status: "fail", message: $output}' > ${results_file}
38+
if [[ ! -f "$test_file" ]]; then
39+
jq -n --arg msg "test file not found: ${slug}-tests.factor" \
40+
'{version: 3, status: "error", message: $msg}' >"$results_file"
41+
exit 0
6142
fi
6243

44+
# Copy the fixture to a fresh temp dir so the rewrite below does not mutate
45+
# the source.
46+
tmp_dir=$(mktemp -d -t "factor-runner-${slug}-XXXXX")
47+
trap 'rm -rf "$tmp_dir"' EXIT
48+
cp -r "${solution_dir}/." "$tmp_dir"
49+
stripped_tests="${tmp_dir}/${slug}/${slug}-tests.factor"
50+
awk '!/^STOP-HERE$/' "$stripped_tests" > "${stripped_tests}.new"
51+
mv "${stripped_tests}.new" "$stripped_tests"
52+
53+
# Run Factor; capture combined stdout/stderr.
54+
set +e
55+
raw_output=$(cd "$tmp_dir" && factor -roots=. -run=exercism-tools "$slug" 2>&1)
56+
set -e
57+
# Normalize the tmp path to the canonical Docker path.
58+
raw_output=${raw_output//$tmp_dir/$canonical_root}
59+
60+
# Awk parser shared by all stages: JSON-escape a single string field.
61+
read -r -d '' AWK_JSON <<'AWK' || true
62+
function json_str(s, r) {
63+
r = s
64+
gsub(/\\/, "\\\\", r)
65+
gsub(/"/, "\\\"", r)
66+
gsub(/\b/, "\\b", r)
67+
gsub(/\f/, "\\f", r)
68+
gsub(/\n/, "\\n", r)
69+
gsub(/\r/, "\\r", r)
70+
gsub(/\t/, "\\t", r)
71+
return "\"" r "\""
72+
}
73+
AWK
74+
75+
# 1. Extract source-test records (one JSON object per line, NDJSON):
76+
# {"line_no":N,"task_id":N|null,"test_code":"..."}
77+
# Reads the post-strip file so line numbers match what Factor reports.
78+
src_tests=$(awk "$AWK_JSON"'
79+
BEGIN { task = "null" }
80+
/^[[:space:]]*TASK:[[:space:]]+[0-9]+/ {
81+
match($0, /TASK:[[:space:]]+[0-9]+/)
82+
s = substr($0, RSTART, RLENGTH)
83+
sub(/^TASK:[[:space:]]+/, "", s)
84+
task = s
85+
next
86+
}
87+
/(unit-test|unit-test~|unit-test-v~|long-unit-test|must-fail-with|must-fail|must-not-fail|must-infer|must-infer-as)[[:space:]]*$/ {
88+
line = $0
89+
sub(/^[[:space:]]+/, "", line)
90+
sub(/[[:space:]]+$/, "", line)
91+
printf "{\"line_no\":%d,\"task_id\":%s,\"test_code\":%s}\n", NR, task, json_str(line)
92+
}
93+
' "${tmp_dir}/${slug}/${slug}-tests.factor")
94+
95+
# 2. Parse Factor stdout into NDJSON segments and failures:
96+
# segments: {"type":"segment","idx":N,"failed":bool,"output":"..."}
97+
# failures: {"type":"failure","line_no":N,"message":"..."}
98+
parsed=$(printf '%s\n' "$raw_output" | awk "$AWK_JSON"'
99+
function close_segment( out, i) {
100+
if (idx == 0) return
101+
out = ""
102+
for (i = 1; i <= seg_n; i++) out = out (i > 1 ? "\n" : "") seg[i]
103+
sub(/^\n+/, "", out); sub(/\n+$/, "", out)
104+
printf "{\"type\":\"segment\",\"idx\":%d,\"failed\":%s,\"output\":%s}\n",
105+
idx, (seg_failed ? "true" : "false"), json_str(out)
106+
}
107+
function close_failure( body, i) {
108+
body = ""
109+
for (i = 1; i <= fail_n; i++) body = body (i > 1 ? "\n" : "") fail[i]
110+
sub(/^\n+/, "", body); sub(/\n+$/, "", body)
111+
printf "{\"type\":\"failure\",\"line_no\":%d,\"message\":%s}\n",
112+
fail_line, json_str(body)
113+
}
114+
# Factor renders each test-word name into a title, e.g.:
115+
# unit-test → "Unit Test:"
116+
# unit-test~ → "Unit Test~:"
117+
# unit-test-v~ → "Unit Test V~:"
118+
# long-unit-test → "Long Unit Test:"
119+
# must-fail → "Must Fail:"
120+
# must-fail-with → "Must Fail With:"
121+
# must-not-fail → "Must Not Fail:"
122+
# must-infer → "Must Infer:"
123+
# must-infer-as → "Must Infer As:"
124+
BEGIN {
125+
state = "inline"; idx = 0
126+
header_re = "^(Unit Test|Unit Test~|Unit Test V~|Long Unit Test|Must Fail|Must Fail With|Must Not Fail|Must Infer|Must Infer As): "
127+
}
128+
state == "inline" && $0 ~ header_re {
129+
close_segment()
130+
idx++; seg_failed = 0; seg_n = 0; delete seg
131+
next
132+
}
133+
state == "inline" && $0 == "###FAIL_BEGIN###" {
134+
close_segment(); idx = 0
135+
state = "fail_loc"; next
136+
}
137+
state == "inline" && $0 == "--> test failed!" {
138+
seg_failed = 1; next
139+
}
140+
state == "inline" {
141+
if (idx > 0) { seg_n++; seg[seg_n] = $0 }
142+
next
143+
}
144+
state == "fail_loc" {
145+
if (match($0, /:[[:space:]]*[0-9]+[[:space:]]*$/)) {
146+
n = substr($0, RSTART, RLENGTH); gsub(/[^0-9]/, "", n)
147+
fail_line = n + 0
148+
} else { fail_line = 0 }
149+
fail_n = 0; delete fail
150+
state = "fail_body"; next
151+
}
152+
state == "fail_body" && $0 == "###FAIL_END###" {
153+
close_failure()
154+
state = "fail_between"; next
155+
}
156+
state == "fail_body" {
157+
fail_n++; fail[fail_n] = $0; next
158+
}
159+
state == "fail_between" && $0 == "###FAIL_BEGIN###" {
160+
state = "fail_loc"; next
161+
}
162+
END { close_segment() }
163+
')
164+
165+
segments=$(printf '%s\n' "$parsed" | awk '/"type":"segment"/' || true)
166+
failures=$(printf '%s\n' "$parsed" | awk '/"type":"failure"/' || true)
167+
168+
# 3. If no segments emitted, surface a top-level error from the raw output.
169+
if [[ -z "$segments" ]]; then
170+
cleaned=$(printf '%s\n' "$raw_output" | awk '/^\([UO]\) /{exit} {print}' \
171+
| awk 'NF { print; blank = 0; next } !blank { print; blank = 1 }')
172+
if [[ -z "$cleaned" ]]; then cleaned="No tests were executed"; fi
173+
jq -n --arg msg "$cleaned" '{version:3, status:"error", message:$msg}' >"$results_file"
174+
exit 0
175+
fi
176+
177+
# 4. Compose the v3 JSON.
178+
# --slurpfile would require files; instead pass NDJSON via --argjson after
179+
# converting each line. We use jq -s on a pipeline of NDJSON inputs.
180+
jq -n \
181+
--argjson srcs "$(printf '%s\n' "$src_tests" | jq -s '.')" \
182+
--argjson segs "$(printf '%s\n' "$segments" | jq -s '.')" \
183+
--argjson fails "$(printf '%s\n' "$failures" | jq -s '.')" \
184+
'
185+
($fails | map({(.line_no|tostring): .message}) | add // {}) as $fail_by_line
186+
| $segs | sort_by(.idx)
187+
| to_entries
188+
| map(
189+
.value as $seg
190+
| (.key) as $i
191+
| ($srcs[$i] // null) as $src
192+
| ($src.line_no | tostring) as $ln
193+
| ($fail_by_line[$ln] // null) as $msg
194+
| (
195+
if $seg.failed then
196+
if ($msg // "" | startswith("=== Expected:")) then "fail"
197+
elif $msg then "error"
198+
else "fail" end
199+
else "pass" end
200+
) as $status
201+
| {
202+
name: ("Test " + ((.key + 1) | tostring)),
203+
status: $status,
204+
test_code: ($src.test_code // ""),
205+
}
206+
+ (if $src.task_id then {task_id: $src.task_id} else {} end)
207+
+ (if $seg.failed then {message: ($msg // "test failed")} else {} end)
208+
+ (if $seg.output != "" then {output: ($seg.output[0:500])} else {} end)
209+
)
210+
| (if all(.status == "pass") then "pass" else "fail" end) as $top
211+
| {version: 3, status: $top, tests: .}
212+
' >"$results_file"
213+
63214
echo "${slug}: done"
Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,8 @@
1-
USING: all-fail tools.test ;
1+
USING: all-fail exercism-tools tools.test ;
2+
IN: all-fail.tests
3+
24
{ "hello" } [ greet ] unit-test
5+
6+
STOP-HERE
7+
38
{ "world" } [ greet ] unit-test

0 commit comments

Comments
 (0)