diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 9722d6d1..8e4d2ef4 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -42,6 +42,11 @@ jobs: npm install -g @devcontainers/cli devcontainer build --workspace-folder . --config devcontainer.json --image-name cs50/codespace:${{ github.sha }} --image-name cs50/codespace:latest + - name: Smoke-test (latest) + if: ${{ github.ref == 'refs/heads/main' }} + run: | + bash tests/smoke.sh cs50/codespace:latest + - name: Push (latest) to GitHub Packages if: ${{ github.ref == 'refs/heads/main' }} run: | @@ -72,6 +77,11 @@ jobs: npm install -g @devcontainers/cli devcontainer build --workspace-folder . --config devcontainer.json --image-name cs50/codespace:${{ github.sha }} --image-name cs50/codespace:${{ github.ref_name }} + - name: Smoke-test (${{ github.ref_name }}) + if: ${{ github.ref != 'refs/heads/main' }} + run: | + bash tests/smoke.sh cs50/codespace:${{ github.ref_name }} + - name: Squash (${{ github.ref_name }}) if: ${{ github.ref != 'refs/heads/main' }} run: | diff --git a/Dockerfile b/Dockerfile index af014aa5..32a395ea 100644 --- a/Dockerfile +++ b/Dockerfile @@ -94,6 +94,13 @@ RUN npm install --global @vscode/vsce yarn && \ mv design50-1.0.0.vsix /opt/cs50/extensions && \ cd /tmp && \ rm --force --recursive design50.vsix && \ + git clone https://github.com/cs50/help50.vsix.git && \ + cd help50.vsix && \ + npm install && \ + vsce package && \ + mv help50-0.0.1.vsix /opt/cs50/extensions && \ + cd /tmp && \ + rm --force --recursive help50.vsix && \ npm uninstall --global vsce yarn diff --git a/devcontainer.json b/devcontainer.json index 3ce0bc46..ba6d181c 100644 --- a/devcontainer.json +++ b/devcontainer.json @@ -14,6 +14,7 @@ "/opt/cs50/extensions/cs50-0.0.1.vsix", "/opt/cs50/extensions/design50-1.0.0.vsix", "/opt/cs50/extensions/ddb50-2.0.0.vsix", + "/opt/cs50/extensions/help50-0.0.1.vsix", "/opt/cs50/extensions/phpliteadmin-0.0.1.vsix", "/opt/cs50/extensions/style50-0.0.1.vsix", "cs50.extension-uninstaller", @@ -189,7 +190,11 @@ "terminal.integrated.defaultProfile.linux": "bash", "terminal.integrated.gpuAcceleration": "off", "terminal.integrated.profiles.linux": { - "JavaScript Debug Terminal": null + "JavaScript Debug Terminal": null, + "Sysadmins": { + "path": "sudo", + "args": ["bash"] + } }, "terminal.integrated.persistentSessionReviveProcess": "never", "terminal.integrated.sendKeybindingsToShell": true, diff --git a/etc/profile.d/codespace.sh b/etc/profile.d/codespace.sh index f49ed703..e55cc267 100644 --- a/etc/profile.d/codespace.sh +++ b/etc/profile.d/codespace.sh @@ -1,5 +1,10 @@ # If not root -if [ "$(whoami)" != "root" ]; then +if [ `id -u` -ne 0 ]; then + + # Library (from cs50/cli; absent in images built before help50 landed there) + if [ -f /opt/cs50/lib/cli ]; then + . /opt/cs50/lib/cli + fi # Check if running locally and set $RepositoryName if not already set if [[ "$CODESPACES" != "true" && -z "$RepositoryName" ]]; then @@ -7,6 +12,9 @@ if [ "$(whoami)" != "root" ]; then export LOCAL_WORKSPACE_FOLDER="/workspaces/$RepositoryName" fi + # Where help50's helpers look for misplaced files (cs50/cli defaults this to $HOME) + export WORKDIR="/workspaces/$RepositoryName" + # Rewrites URLs of the form http://HOST:PORT as https://$CODESPACE_NAME.app.github.dev:PORT _hostname() { @@ -79,4 +87,53 @@ if [ "$(whoami)" != "root" ]; then http-server() { command http-server "$@" | _hostname | _version | uniq } + + # help50 hooks, called by _help50 in /etc/profile.d/help50.sh after each command. + # When no local helper has advice, these relay the failed command's output to the + # help50 VS Code extension via command50, which shows a "help50" button in the + # terminal's title bar; clicking it asks the CS50 Duck to explain the error. + # command50 runs detached with output discarded so the prompt isn't delayed and a + # missing extension server degrades silently. + _help50_button() { + ( command50 help50.showButton "$1" "$2" > /dev/null 2>&1 & ) + _HELP50_BUTTON=1 + } + _help50_hide() { + if [[ -n "$_HELP50_BUTTON" ]]; then + ( command50 help50.hideButton > /dev/null 2>&1 & ) + unset _HELP50_BUTTON + fi + } + + # A helper had advice: show it here, as in cs50/cli. No button, since the advice is + # already on screen; a button still showing is about an earlier command. + _helpful() { + for name in n no y yes; do + alias $name=_rhetorical # Intercept answers to the rhetorical question + done + _alert "$(_ansi "$1")" + _help50_hide + } + + # No helper matched: offer the duck the failed command's output to explain. + # If there's no output (e.g., grep with no match, or a program exiting 1), there's + # nothing to explain, and any button still showing is about an earlier command. + # $1 is the output, $2 the command line; the duck gets both, as a transcript. + _helpless() { + if [[ -z "${1//[[:space:]]/}" ]]; then + _help50_hide + return + fi + _alert "$(_ansi "🦆 Click \`help50\` above for help with that error.")" + if [[ -n "$2" ]]; then + _help50_button ask "$ $2"$'\n'"$1" + else + _help50_button ask "$1" + fi + } + + # Command succeeded: hide the button, if showing + _helped() { + _help50_hide + } fi diff --git a/tests/smoke.sh b/tests/smoke.sh new file mode 100755 index 00000000..cafbb5fd --- /dev/null +++ b/tests/smoke.sh @@ -0,0 +1,59 @@ +#!/bin/bash +# Smoke-tests a built cs50/codespace image. Usage: tests/smoke.sh [IMAGE] +# Each check has a timeout so that a regression that hangs the shell fails loudly. + +set -o errexit -o errtrace -o nounset -o pipefail +step="startup" +trap 'echo "FAILED: $step" >&2' ERR + +IMAGE="${1:-cs50/codespace}" +run() { timeout 60 docker run --rm --env CODESPACES=true --env RepositoryName=smoke "$@"; } +check() { step="$1"; echo "- $step"; } +skip() { echo "- SKIPPED: $1"; } + +echo "Checking $IMAGE" + +check "login shells start without errors" +test -z "$(run "$IMAGE" bash --login -c 'true' 2>&1)" + +check "non-interactive login shell exits (help50 must not start without a terminal)" +run "$IMAGE" bash --login -c 'echo ok' | grep -qx ok +echo true | run --interactive "$IMAGE" bash --login + +# The rest applies once the cs50/cli base image carries help50 (cs50/cli#210). +# Branch builds use cs50/cli:amd64 (main), canary uses cs50/cli:canary. +if run "$IMAGE" bash --login -c 'test -f /etc/profile.d/help50.sh -a -f /opt/cs50/lib/cli'; then + + check "help50 is inherited from cs50/cli and its hooks are overridden by codespace.sh" + run "$IMAGE" bash --login -c ' + test "$(type -P help50)" = /opt/cs50/bin/help50 && + test "$WORKDIR" = /workspaces/smoke && + for f in _helped _helpful _helpless _help50_button _alert _ansi; do declare -F "$f" > /dev/null || { echo "missing $f" >&2; exit 1; }; done && + declare -f _help50_button | grep -q command50' + + check "hooks behave: advice is printed, empty output is ignored (no extension server needed)" + run "$IMAGE" bash --login -c ' + _helpful "Did you mean \`ls\`?" 2>&1 | grep -q "Did you mean" && + test -z "$(_helpless "" 2>&1)" && + test -z "$(_helpless " " 2>&1)" && + _helpless "cat: x: No such file" 2>&1 | grep -q help50' + + # _help50_button discards command50's output, so stub it to see the payload + check "the duck gets the command line and output as a transcript (or output alone without a command)" + run "$IMAGE" bash --login -c ' + _alert() { :; } + _help50_button() { printf "%s" "$2"; } + test "$(_helpless "cat: x: No such file" "cat x")" = "$(printf "%s\n%s" "$ cat x" "cat: x: No such file")" && + test "$(_helpless "cat: x: No such file")" = "cat: x: No such file" && + test -z "$(_helpless " " "cat x")"' + + check "root shells (Sysadmins profile) do not start help50" + run --user root "$IMAGE" bash --login -c 'test -z "${HELP50:-}" && ! declare -F _helpful > /dev/null' +else + skip "help50 checks: base image has no help50 (cs50/cli without #210)" +fi + +check "help50 extension is packaged for installation" +run "$IMAGE" bash --login -c 'test -f /opt/cs50/extensions/help50-0.0.1.vsix && command -v command50 > /dev/null' + +echo "OK"