|
| 1 | +#!/usr/bin/env bash |
| 2 | +# test_resolution_map.sh — Verify the tsserver-hook worker builds a correct |
| 3 | +# resolution map from the rules_typescript workspace. |
| 4 | +# |
| 5 | +# Usage (direct): |
| 6 | +# bash tests/lsp/test_resolution_map.sh |
| 7 | +# |
| 8 | +# Usage (via Bazel): |
| 9 | +# bazel test //tests/lsp:test_resolution_map --test_output=all |
| 10 | +# |
| 11 | +# What it tests: |
| 12 | +# 1. The hook script loads without errors. |
| 13 | +# 2. The worker builds a resolution map that contains npm packages (zod, |
| 14 | +# vitest) pointing at real .d.ts files in the Bazel output base. |
| 15 | +# 3. At least one resolution entry points at an existing file on disk. |
| 16 | +# |
| 17 | +# Prerequisites for the npm-package checks to pass: |
| 18 | +# bazel build @npm//... (or bazel build //...) must have run at least |
| 19 | +# once to populate the @npm external repo in the Bazel output base. |
| 20 | +# |
| 21 | +# Exit code: 0 = all assertions passed, non-zero = failure. |
| 22 | + |
| 23 | +set -euo pipefail |
| 24 | + |
| 25 | +pass() { echo "PASS: $*"; } |
| 26 | +fail() { echo "FAIL: $*" >&2; exit 1; } |
| 27 | + |
| 28 | +# ── Locate files ─────────────────────────────────────────────────────────────── |
| 29 | +# When run via `bazel test`, the runfiles tree is set up and the DATA deps |
| 30 | +# (//tools:tsserver-hook.js, //tools:tsserver-hook-worker.js) are accessible |
| 31 | +# via the standard runfiles layout under $RUNFILES_DIR or $TEST_SRCDIR. |
| 32 | +# |
| 33 | +# When run directly (bash tests/lsp/test_resolution_map.sh from workspace root), |
| 34 | +# we use relative paths. |
| 35 | + |
| 36 | +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" |
| 37 | + |
| 38 | +# Portable realpath (macOS lacks readlink -f). |
| 39 | +_realpath() { |
| 40 | + python3 -c "import os,sys; print(os.path.realpath(sys.argv[1]))" "$1" |
| 41 | +} |
| 42 | + |
| 43 | +# Detect Bazel test environment. |
| 44 | +if [[ -n "${TEST_SRCDIR:-}" ]]; then |
| 45 | + # Bazel test: tools/ is under the _main runfiles repo. |
| 46 | + _RUNFILES_MAIN="${TEST_SRCDIR}/_main" |
| 47 | + [[ -d "${_RUNFILES_MAIN}" ]] || _RUNFILES_MAIN="${TEST_SRCDIR}" |
| 48 | + TOOLS_DIR="${_RUNFILES_MAIN}/tools" |
| 49 | + |
| 50 | + # Derive the real workspace root from the MODULE.bazel symlink. |
| 51 | + # The test has `data = ["//:MODULE.bazel"]` which causes MODULE.bazel to |
| 52 | + # appear as a symlink in the runfiles tree pointing at the real source file. |
| 53 | + _MODULE_SYMLINK="${_RUNFILES_MAIN}/MODULE.bazel" |
| 54 | + if [[ -L "${_MODULE_SYMLINK}" ]]; then |
| 55 | + WORKSPACE_ROOT="$(dirname "$(_realpath "${_MODULE_SYMLINK}")")" |
| 56 | + elif [[ -f "${_MODULE_SYMLINK}" ]]; then |
| 57 | + WORKSPACE_ROOT="$(_realpath "$(dirname "${_MODULE_SYMLINK}")")" |
| 58 | + else |
| 59 | + # Fallback: assume workspace root is the real rules_typescript checkout, |
| 60 | + # which we derive by resolving the SCRIPT_DIR symlink chain. |
| 61 | + WORKSPACE_ROOT="$(dirname "$(dirname "$(_realpath "${SCRIPT_DIR}")")")" |
| 62 | + fi |
| 63 | +else |
| 64 | + # Direct invocation: tools/ is two directories up from this script. |
| 65 | + WORKSPACE_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)" |
| 66 | + TOOLS_DIR="${WORKSPACE_ROOT}/tools" |
| 67 | +fi |
| 68 | + |
| 69 | +[[ -f "${TOOLS_DIR}/tsserver-hook.js" ]] || \ |
| 70 | + fail "tsserver-hook.js not found at ${TOOLS_DIR}/tsserver-hook.js" |
| 71 | +[[ -f "${TOOLS_DIR}/tsserver-hook-worker.js" ]] || \ |
| 72 | + fail "tsserver-hook-worker.js not found at ${TOOLS_DIR}/tsserver-hook-worker.js" |
| 73 | + |
| 74 | +# Derive the Bazel output base so the worker can locate the @npm external repo |
| 75 | +# without needing to run `bazel info output_base` (which is blocked by the |
| 76 | +# outer `bazel test` server lock). |
| 77 | +# |
| 78 | +# Strategy: |
| 79 | +# 1. If running inside a Bazel test, derive the output base from the |
| 80 | +# runfiles directory path (which is always under |
| 81 | +# <output_base>/execroot/_main/bazel-out/.../bin/...). |
| 82 | +# 2. Fall back to `bazel info output_base` for direct invocations. |
| 83 | +BAZEL_OUTPUT_BASE="" |
| 84 | + |
| 85 | +if [[ -n "${TEST_SRCDIR:-}" ]]; then |
| 86 | + # Inside Bazel test: TEST_SRCDIR is something like |
| 87 | + # <output_base>/execroot/_main/bazel-out/.../bin/<target>.runfiles |
| 88 | + # Extract output_base by stripping everything from /execroot/ onward. |
| 89 | + if [[ "${TEST_SRCDIR}" == */execroot/* ]]; then |
| 90 | + BAZEL_OUTPUT_BASE="${TEST_SRCDIR%%/execroot/*}" |
| 91 | + fi |
| 92 | +fi |
| 93 | + |
| 94 | +if [[ -z "${BAZEL_OUTPUT_BASE}" ]]; then |
| 95 | + # Direct invocation or fallback: ask Bazel. |
| 96 | + BAZEL_OUTPUT_BASE="$(bazel info output_base 2>/dev/null || true)" |
| 97 | +fi |
| 98 | + |
| 99 | +echo "INFO: workspace_root = ${WORKSPACE_ROOT}" |
| 100 | +echo "INFO: tools_dir = ${TOOLS_DIR}" |
| 101 | +echo "INFO: bazel_output_base = ${BAZEL_OUTPUT_BASE:-<not found>}" |
| 102 | + |
| 103 | +pass "hook files exist" |
| 104 | + |
| 105 | +# ── Prerequisite: Node.js available ─────────────────────────────────────────── |
| 106 | +command -v node >/dev/null 2>&1 || fail "node not found on PATH" |
| 107 | +NODE_VERSION=$(node --version) |
| 108 | +echo "INFO: node ${NODE_VERSION}" |
| 109 | + |
| 110 | +# ── Test 1: Hook script loads without errors ────────────────────────────────── |
| 111 | +echo "INFO: testing that hook loads without errors..." |
| 112 | +node --require "${TOOLS_DIR}/tsserver-hook.js" --eval "process.exit(0)" |
| 113 | +pass "hook loads without errors" |
| 114 | + |
| 115 | +# ── Test 2: Run worker and capture resolution map ───────────────────────────── |
| 116 | +echo "INFO: running worker to build resolution map (may take up to 60 s)..." |
| 117 | + |
| 118 | +RESOLUTION_MAP_FILE="$(mktemp -t resolution_map.XXXXXX.json)" |
| 119 | +WORKER_SCRIPT="$(mktemp -t tsserver_hook_test.XXXXXX.js)" |
| 120 | +cleanup() { |
| 121 | + rm -f "${RESOLUTION_MAP_FILE}" "${WORKER_SCRIPT}" |
| 122 | +} |
| 123 | +trap cleanup EXIT |
| 124 | + |
| 125 | +# Write the worker-runner as a temp file to avoid bash/JS interpolation issues. |
| 126 | +cat > "${WORKER_SCRIPT}" << WORKER_EOF |
| 127 | +'use strict'; |
| 128 | +const { Worker } = require('worker_threads'); |
| 129 | +const path = require('path'); |
| 130 | +const fs = require('fs'); |
| 131 | +
|
| 132 | +const workerPath = path.resolve(process.argv[2]); |
| 133 | +const workspaceRoot = process.argv[3]; |
| 134 | +const outputFile = process.argv[4]; |
| 135 | +const outputBase = process.argv[5] || ''; // optional, passed when Bazel lock is held |
| 136 | +
|
| 137 | +const workerData = { workspaceRoot }; |
| 138 | +if (outputBase) workerData.outputBase = outputBase; |
| 139 | +
|
| 140 | +const w = new Worker(workerPath, { workerData }); |
| 141 | +
|
| 142 | +const timeout = setTimeout(() => { |
| 143 | + w.terminate(); |
| 144 | + process.stderr.write('FAIL: worker did not send resolution map within 60 s\\n'); |
| 145 | + process.exit(1); |
| 146 | +}, 60000); |
| 147 | +
|
| 148 | +w.on('message', (msg) => { |
| 149 | + clearTimeout(timeout); |
| 150 | + w.terminate(); |
| 151 | + if (msg.type !== 'resolution-map') { |
| 152 | + process.stderr.write('FAIL: unexpected message type: ' + msg.type + '\\n'); |
| 153 | + process.exit(1); |
| 154 | + } |
| 155 | + fs.writeFileSync(outputFile, JSON.stringify(msg.data, null, 2)); |
| 156 | + process.exit(0); |
| 157 | +}); |
| 158 | +
|
| 159 | +w.on('error', (err) => { |
| 160 | + clearTimeout(timeout); |
| 161 | + process.stderr.write('FAIL: worker error: ' + err.message + '\\n'); |
| 162 | + process.exit(1); |
| 163 | +}); |
| 164 | +WORKER_EOF |
| 165 | + |
| 166 | +node "${WORKER_SCRIPT}" \ |
| 167 | + "${TOOLS_DIR}/tsserver-hook-worker.js" \ |
| 168 | + "${WORKSPACE_ROOT}" \ |
| 169 | + "${RESOLUTION_MAP_FILE}" \ |
| 170 | + "${BAZEL_OUTPUT_BASE:-}" |
| 171 | + |
| 172 | +[[ -s "${RESOLUTION_MAP_FILE}" ]] || fail "resolution map file is empty" |
| 173 | +pass "worker produced a resolution map" |
| 174 | + |
| 175 | +echo "INFO: resolution map written to ${RESOLUTION_MAP_FILE}" |
| 176 | + |
| 177 | +# ── Test 3: Check npm packages are present ───────────────────────────────────── |
| 178 | +echo "INFO: checking npm packages in resolution map..." |
| 179 | + |
| 180 | +python3 - "${RESOLUTION_MAP_FILE}" << 'PYEOF' |
| 181 | +import json |
| 182 | +import os |
| 183 | +import sys |
| 184 | +
|
| 185 | +map_path = sys.argv[1] |
| 186 | +
|
| 187 | +with open(map_path) as f: |
| 188 | + data = json.load(f) |
| 189 | +
|
| 190 | +errors = [] |
| 191 | +
|
| 192 | +# zod and vitest are in the rules_typescript test lockfile. |
| 193 | +# They must be present after `bazel build @npm//...`. |
| 194 | +REQUIRED_PACKAGES = ["zod", "vitest"] |
| 195 | +
|
| 196 | +for pkg in REQUIRED_PACKAGES: |
| 197 | + if pkg not in data: |
| 198 | + errors.append("'{}' not in resolution map".format(pkg)) |
| 199 | + else: |
| 200 | + dts_path = data[pkg] |
| 201 | + if not os.path.exists(dts_path): |
| 202 | + errors.append("'{}' path does not exist: {!r}".format(pkg, dts_path)) |
| 203 | + elif not (dts_path.endswith('.d.ts') or |
| 204 | + dts_path.endswith('.d.mts') or |
| 205 | + dts_path.endswith('.d.cts')): |
| 206 | + errors.append("'{}' path is not a .d.ts file: {!r}".format(pkg, dts_path)) |
| 207 | + else: |
| 208 | + print("PASS: '{}' -> {!r}".format(pkg, dts_path)) |
| 209 | +
|
| 210 | +module_entries = [k for k in data.keys() if not k.startswith('__alias__')] |
| 211 | +alias_entries = [k for k in data.keys() if k.startswith('__alias__')] |
| 212 | +print("INFO: {} module entries, {} alias entries".format( |
| 213 | + len(module_entries), len(alias_entries))) |
| 214 | +
|
| 215 | +if errors: |
| 216 | + for e in errors: |
| 217 | + print("FAIL: {}".format(e), file=sys.stderr) |
| 218 | + sys.exit(1) |
| 219 | +
|
| 220 | +sys.exit(0) |
| 221 | +PYEOF |
| 222 | +pass "npm packages present in resolution map" |
| 223 | + |
| 224 | +# ── Test 4: At least one module points at a real .d.ts ──────────────────────── |
| 225 | +echo "INFO: verifying .d.ts paths exist on disk..." |
| 226 | + |
| 227 | +python3 - "${RESOLUTION_MAP_FILE}" << 'PYEOF' |
| 228 | +import json |
| 229 | +import os |
| 230 | +import sys |
| 231 | +
|
| 232 | +with open(sys.argv[1]) as f: |
| 233 | + data = json.load(f) |
| 234 | +
|
| 235 | +valid = 0 |
| 236 | +invalid = 0 |
| 237 | +for k, v in data.items(): |
| 238 | + if k.startswith('__alias__'): |
| 239 | + continue |
| 240 | + if os.path.exists(v): |
| 241 | + valid += 1 |
| 242 | + else: |
| 243 | + invalid += 1 |
| 244 | +
|
| 245 | +print("INFO: {} paths exist, {} paths missing (may be unbuilt packages)".format( |
| 246 | + valid, invalid)) |
| 247 | +
|
| 248 | +if valid == 0: |
| 249 | + print("FAIL: no resolution map entries point to existing files", file=sys.stderr) |
| 250 | + sys.exit(1) |
| 251 | +
|
| 252 | +sys.exit(0) |
| 253 | +PYEOF |
| 254 | +pass "at least one resolution entry points to an existing file" |
| 255 | + |
| 256 | +# ── Summary ──────────────────────────────────────────────────────────────────── |
| 257 | +echo "" |
| 258 | +echo "ALL PASSED" |
0 commit comments