diff --git a/.build/checks.sh b/.build/checks.sh new file mode 100644 index 00000000..fac520aa --- /dev/null +++ b/.build/checks.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash +set -euo pipefail +cd "$(dirname "$0")/.." +configuration="${FSCRIPT_BUILD_CONFIG:-Release}" +cli=(dotnet "src/FScript/bin/$configuration/net10.0/fscript.dll") +run_node_tests() { + local report=$1 + shift + mkdir -p "$(dirname "$report")" + node --test --test-reporter=spec --test-reporter-destination=stdout \ + --test-reporter=junit --test-reporter-destination="$report" "$@" +} +case "${1:?Expected debugger, javascript, vscode, reports, or smoke}" in + debugger) + run_node_tests tests/FScript.DebugAdapter.Tests/TestResults/debugger.xml tests/FScript.DebugAdapter.Tests/*Tests.js + ;; + javascript) + cd src/FScript.JavaScript + run_node_tests TestResults/javascript.xml test/*.test.mjs + ;; + vscode) + cd vscode-fscript + run_node_tests TestResults/vscode.xml ../tests/FScript.LanguageServer.Tests/*Tests.js + ;; + reports) + find src tests samples vscode-fscript \ + -type d \( -name bin -o -name obj -o -name node_modules \) -prune -o \ + -type f -path '*/TestResults/*' \( -name '*.trx' -o -name '*.xml' \) -print + ;; + smoke) + for script in samples/*.fss; do + echo "Running $script" + "${cli[@]}" "$script" > /dev/null + done + "${cli[@]}" samples/basic/basic.fss -- helloworld.bas > /dev/null + "${cli[@]}" samples/debugging/main.fss > /dev/null + "${cli[@]}" samples/ffi/main.fss -- --headless + for script in samples/task-sharing/*.fss; do "${cli[@]}" analyze "$script"; done + "${cli[@]}" samples/task-sharing/ordered.fss > /dev/null + "${cli[@]}" --no-result samples/task-sharing/thread-safe.fss > /dev/null + echo "All smoke tests passed." + ;; + *) echo "Unknown check: $1" >&2; exit 2 ;; +esac diff --git a/.build/ci-shards.json b/.build/ci-shards.json new file mode 100644 index 00000000..82eabb2d --- /dev/null +++ b/.build/ci-shards.json @@ -0,0 +1,11 @@ +{ + "include": [ + {"name": "core", "projects": ["ci_core", "ci_ffi_managed"]}, + {"name": "backend", "projects": ["ci_backend"]}, + {"name": "cli", "projects": ["ci_cli"]}, + {"name": "ffi-default", "projects": ["ci_ffi_default"]}, + {"name": "ffi-size", "projects": ["ci_ffi_size"]}, + {"name": "web", "projects": ["ci_web", "ci_website"]}, + {"name": "integration", "projects": ["ci_integration"]} + ] +} diff --git a/.build/ci/backend/PROJECT b/.build/ci/backend/PROJECT new file mode 100644 index 00000000..0283d19e --- /dev/null +++ b/.build/ci/backend/PROJECT @@ -0,0 +1,11 @@ +project ci_backend { + depends_on = [ project.fscript_backend_dotnet_tests ] + includes = [ "../../checks.sh" ] +} + +target test { + build = ~always + artifacts = ~none + depends_on = [ target.^test ] + workflow run { script = "../../checks.sh" args = "reports" } +} diff --git a/.build/ci/cli/PROJECT b/.build/ci/cli/PROJECT new file mode 100644 index 00000000..2da621a3 --- /dev/null +++ b/.build/ci/cli/PROJECT @@ -0,0 +1,11 @@ +project ci_cli { + depends_on = [ project.fscript_cli_tests ] + includes = [ "../../checks.sh" ] +} + +target test { + build = ~always + artifacts = ~none + depends_on = [ target.^test ] + workflow run { script = "../../checks.sh" args = "reports" } +} diff --git a/.build/ci/core/PROJECT b/.build/ci/core/PROJECT new file mode 100644 index 00000000..b84b60ae --- /dev/null +++ b/.build/ci/core/PROJECT @@ -0,0 +1,11 @@ +project ci_core { + depends_on = [ project.fscript_language_tests project.fscript_runtime_tests project.fscript_compiler_tests project.fscript_conformance_tests project.fscript_interpreter_lir_tests project.fscript_languageserver_tests ] + includes = [ "../../checks.sh" ] +} + +target test { + build = ~always + artifacts = ~none + depends_on = [ target.^test ] + workflow run { script = "../../checks.sh" args = "reports" } +} diff --git a/.build/ci/ffi-default/PROJECT b/.build/ci/ffi-default/PROJECT new file mode 100644 index 00000000..3ab1b4cf --- /dev/null +++ b/.build/ci/ffi-default/PROJECT @@ -0,0 +1,11 @@ +project ci_ffi_default { + depends_on = [ project.ffisample_tests ] + includes = [ "../../checks.sh" ] +} + +target test { + build = ~always + artifacts = ~none + depends_on = [ target.^test_ffi_default ] + workflow run { script = "../../checks.sh" args = "reports" } +} diff --git a/.build/ci/ffi-managed/PROJECT b/.build/ci/ffi-managed/PROJECT new file mode 100644 index 00000000..73fd5405 --- /dev/null +++ b/.build/ci/ffi-managed/PROJECT @@ -0,0 +1,11 @@ +project ci_ffi_managed { + depends_on = [ project.ffisample_tests ] + includes = [ "../../checks.sh" ] +} + +target test { + build = ~always + artifacts = ~none + depends_on = [ target.^test_ffi_managed ] + workflow run { script = "../../checks.sh" args = "reports" } +} diff --git a/.build/ci/ffi-size/PROJECT b/.build/ci/ffi-size/PROJECT new file mode 100644 index 00000000..2d7ea41c --- /dev/null +++ b/.build/ci/ffi-size/PROJECT @@ -0,0 +1,11 @@ +project ci_ffi_size { + depends_on = [ project.ffisample_tests ] + includes = [ "../../checks.sh" ] +} + +target test { + build = ~always + artifacts = ~none + depends_on = [ target.^test_ffi_size ] + workflow run { script = "../../checks.sh" args = "reports" } +} diff --git a/.build/ci/integration/PROJECT b/.build/ci/integration/PROJECT new file mode 100644 index 00000000..fb70e680 --- /dev/null +++ b/.build/ci/integration/PROJECT @@ -0,0 +1,11 @@ +project ci_integration { + depends_on = [ project.checks ] + includes = [ "../../checks.sh" ] +} + +target test { + build = ~always + artifacts = ~none + depends_on = [ target.^test target.^smoke target.^sdk ] + workflow run { script = "../../checks.sh" args = "reports" } +} diff --git a/.build/ci/web/PROJECT b/.build/ci/web/PROJECT new file mode 100644 index 00000000..7f62e8b8 --- /dev/null +++ b/.build/ci/web/PROJECT @@ -0,0 +1,11 @@ +project ci_web { + depends_on = [ project.javascript project.vscode ] + includes = [ "../../checks.sh" ] +} + +target test { + build = ~always + artifacts = ~none + depends_on = [ target.^test ] + workflow run { script = "../../checks.sh" args = "reports" } +} diff --git a/.build/ci/website/PROJECT b/.build/ci/website/PROJECT new file mode 100644 index 00000000..c8a65dbb --- /dev/null +++ b/.build/ci/website/PROJECT @@ -0,0 +1,11 @@ +project ci_website { + depends_on = [ project.website ] + includes = [ "../../checks.sh" ] +} + +target test { + build = ~always + artifacts = ~none + depends_on = [ target.^test_ci ] + workflow run { script = "../../checks.sh" args = "reports" } +} diff --git a/.build/read-tool-versions.mjs b/.build/read-tool-versions.mjs new file mode 100644 index 00000000..1d894b86 --- /dev/null +++ b/.build/read-tool-versions.mjs @@ -0,0 +1,25 @@ +import fs from 'node:fs'; +import { pathToFileURL } from 'node:url'; + +export function readToolVersions(source) { + const blocks = [...source.matchAll(/\bversions\s*=\s*\{([^}]*)\}/g)]; + if (blocks.length !== 1) throw new Error('WORKSPACE must define one locals.versions block.'); + const versions = {}; + for (const line of blocks[0][1].split('\n')) { + if (/^\s*(?:#.*)?$/.test(line)) continue; + const match = line.match(/^\s*([a-z][a-z0-9_]*)\s*:\s*"([a-zA-Z0-9][a-zA-Z0-9.+_-]*)"\s*(?:#.*)?$/); + if (!match) throw new Error(`Tool versions must be quoted literal values: ${line.trim()}`); + const [, name, value] = match; + if (Object.hasOwn(versions, name)) throw new Error(`Duplicate tool version: ${name}`); + versions[name] = value; + } + for (const name of ['dotnet_sdk', 'node', 'terrabuild', 'vsce', 'ovsx']) { + if (!versions[name]) throw new Error(`Missing tool version: ${name}`); + } + return versions; +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + const versions = readToolVersions(fs.readFileSync('WORKSPACE', 'utf8')); + for (const [name, value] of Object.entries(versions)) console.log(`${name}=${value}`); +} diff --git a/.build/reports/PROJECT b/.build/reports/PROJECT new file mode 100644 index 00000000..b801d2a2 --- /dev/null +++ b/.build/reports/PROJECT @@ -0,0 +1,13 @@ +project test_reports { + depends_on = [ project.fscript_language_tests project.fscript_runtime_tests + project.fscript_compiler_tests project.fscript_conformance_tests + project.fscript_interpreter_lir_tests project.fscript_backend_dotnet_tests + project.fscript_cli_tests project.fscript_languageserver_tests + project.ffisample_tests project.javascript project.vscode project.checks ] + includes = [ "../checks.sh" ] +} + +# An executing consumer restores cached test outputs before GitHub collects them. +target reports { + workflow run { script = "../checks.sh" args = "reports" } +} diff --git a/.build/run-terrabuild.sh b/.build/run-terrabuild.sh new file mode 100755 index 00000000..4644ff4b --- /dev/null +++ b/.build/run-terrabuild.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +set -euo pipefail +cd "$(dirname "$0")/.." +mkdir -p .terrabuild +report=.terrabuild/result.json +rm -f "$report" +parallel_args=() +if [[ -n "${TB_PARALLEL:-}" ]]; then + parallel_args=(--parallel "$TB_PARALLEL") +fi +group_args=() +if [[ -n "${TERRABUILD_GROUP:-}" ]]; then + group_args=(--group "$TERRABUILD_GROUP") +fi +terrabuild run "$@" --variable "config=${FSCRIPT_BUILD_CONFIG:-Release}" ${parallel_args[@]+"${parallel_args[@]}"} ${group_args[@]+"${group_args[@]}"} --out "$report" 2>&1 | tee .terrabuild/run.log +# Terrabuild 0.200.1 can exit zero after reporting a failed graph. Require the +# machine-readable completion status too, including for a fully cached run. +if ! grep -Eq '"status"[[:space:]]*:[[:space:]]*"success"' "$report"; then + echo "Terrabuild did not report a successful build; inspect the task logs above." >&2 + exit 1 +fi diff --git a/.build/sdk-check.sh b/.build/sdk-check.sh new file mode 100644 index 00000000..ef869882 --- /dev/null +++ b/.build/sdk-check.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +# Publishing different assembly versions must not mutate outputs used by tests. +set -euo pipefail +cd "$(dirname "$0")/.." +work=$(mktemp -d) +trap 'rm -rf "$work"' EXIT +node - "$work" <<'JS' +const fs = require('node:fs'); +const path = require('node:path'); +const {execFileSync} = require('node:child_process'); +const destination = process.argv[2]; +const files = execFileSync('git', ['ls-files', '--cached', '--others', '--exclude-standard', '-z'], {maxBuffer: 16 * 1024 * 1024}).toString().split('\0').filter(Boolean); +for (const file of new Set(files)) { + if (!fs.existsSync(file) || !fs.statSync(file).isFile()) continue; + const output = path.join(destination, file); + fs.mkdirSync(path.dirname(output), {recursive: true}); + fs.copyFileSync(file, output); +} +JS +bash "$work/.github/scripts/verify-sdk-provider.sh" diff --git a/.build/toolchain/Dockerfile b/.build/toolchain/Dockerfile new file mode 100644 index 00000000..f0180a8b --- /dev/null +++ b/.build/toolchain/Dockerfile @@ -0,0 +1,23 @@ +# check=skip=InvalidDefaultArgInFrom +# Versions are required build arguments supplied by WORKSPACE. +ARG NODE_VERSION +ARG DOTNET_SDK_VERSION +FROM node:${NODE_VERSION}-bookworm-slim AS node +FROM mcr.microsoft.com/dotnet/sdk:${DOTNET_SDK_VERSION} + +LABEL org.opencontainers.image.source="https://github.com/MagnusOpera/FScript" + +# AOT integration tests invoke the native compiler. Node is also needed by the +# debugger tests, Fable package, VS Code extension, and documentation build. +RUN apt-get update \ + && apt-get install -y --no-install-recommends clang zlib1g-dev git ca-certificates \ + && rm -rf /var/lib/apt/lists/* +COPY --from=node /usr/local/bin/node /usr/local/bin/node +COPY --from=node /usr/local/lib/node_modules /usr/local/lib/node_modules +RUN ln -s ../lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm \ + && ln -s ../lib/node_modules/npm/bin/npx-cli.js /usr/local/bin/npx +ENV DOTNET_CLI_TELEMETRY_OPTOUT=1 DOTNET_NOLOGO=1 NPM_CONFIG_AUDIT=false NPM_CONFIG_FUND=false + +# Tests capture subprocess output. Persistent SDK/compiler servers inherit those +# pipes and can keep ReadToEnd waiting after the child command has exited. +ENV MSBUILDDISABLENODEREUSE=1 DOTNET_CLI_USE_MSBUILD_SERVER=0 UseSharedCompilation=false diff --git a/.build/toolchain/PROJECT b/.build/toolchain/PROJECT new file mode 100644 index 00000000..bcbfd692 --- /dev/null +++ b/.build/toolchain/PROJECT @@ -0,0 +1,17 @@ +project toolchain { + labels = [ "toolchain" ] + includes = [ "**/*" "../../WORKSPACE" ] + @docker { } +} + +target dist { + phase = phase.toolchains + @docker build { + # GHCR returned missing child manifests for the attested index. + args = "--provenance=false" + build_args = { + DOTNET_SDK_VERSION: local.versions.dotnet_sdk + NODE_VERSION: local.versions.node + } + } +} diff --git a/.build/workflow.fss b/.build/workflow.fss new file mode 100644 index 00000000..73317314 --- /dev/null +++ b/.build/workflow.fss @@ -0,0 +1,12 @@ +// Build/test scripts share a pinned container and can cache successful results. +// @shell is reserved for the host-side Docker image bootstrap. +export let run (context: Protocol.ActionContext) (script: string) (args: string option) = + let arguments = Option.defaultValue "" args + { PreserveProvenance = Broken + Batchable = false + Operations = + [ { Command = "bash" + Arguments = $"{script} {arguments}" + ErrorLevel = 0 } ] } + +{ [nameof run] = [Cache Remote] } diff --git a/.github/actions/collect-build-results/action.yml b/.github/actions/collect-build-results/action.yml new file mode 100644 index 00000000..5ec68c23 --- /dev/null +++ b/.github/actions/collect-build-results/action.yml @@ -0,0 +1,51 @@ +name: Collect build results +description: Upload test results and Terrabuild logs, and publish GitHub test summaries. + +inputs: + suffix: + description: Artifact and report suffix for this workflow + required: true + + publish: + description: Publish reports immediately instead of deferring to the CI join job + default: 'true' + +runs: + using: composite + steps: + - name: Collect current test results + if: always() + shell: bash + run: node .github/scripts/collect-test-results.mjs + + - name: Upload test results + if: always() + uses: actions/upload-artifact@v4 + with: + name: test-results-${{ inputs.suffix }} + path: | + .out/test-results/**/*.trx + .out/test-results/**/*.xml + include-hidden-files: true + if-no-files-found: warn + + - name: Upload Terrabuild logs + if: always() + uses: actions/upload-artifact@v4 + with: + name: terrabuild-logs-${{ inputs.suffix }} + path: | + .terrabuild/result.json + .terrabuild/run.log + terrabuild-debug.* + ~/.terrabuild/cache/**/status + ~/.terrabuild/cache/**/summary.json + ~/.terrabuild/cache/**/*.log + include-hidden-files: true + if-no-files-found: warn + + - name: Publish test summaries + if: always() && inputs.publish == 'true' + uses: ./.github/actions/publish-test-results + with: + suffix: ${{ inputs.suffix }} diff --git a/.github/actions/publish-test-results/action.yml b/.github/actions/publish-test-results/action.yml new file mode 100644 index 00000000..6e1c7b9e --- /dev/null +++ b/.github/actions/publish-test-results/action.yml @@ -0,0 +1,34 @@ +name: Publish test results +description: Publish .NET and Node test summaries from the selected report directory. +inputs: + suffix: + description: Names used for GitHub check reports + required: true + path: + description: Root containing TRX and JUnit reports + default: .out/test-results +runs: + using: composite + steps: + - name: Publish .NET test report + if: always() + continue-on-error: true + uses: magnusopera/test-reporter@main + with: + name: .NET Tests (${{ inputs.suffix }}) + path: '${{ inputs.path }}/**/*.trx' + reporter: dotnet-trx + fail-on-error: false + fail-on-empty: false + use-actions-summary: true + - name: Publish JavaScript and debugger test report + if: always() + continue-on-error: true + uses: magnusopera/test-reporter@main + with: + name: JavaScript and Debugger Tests (${{ inputs.suffix }}) + path: '${{ inputs.path }}/**/*.xml' + reporter: java-junit + fail-on-error: false + fail-on-empty: false + use-actions-summary: true diff --git a/.github/actions/setup-tools/action.yml b/.github/actions/setup-tools/action.yml new file mode 100644 index 00000000..8ad422ac --- /dev/null +++ b/.github/actions/setup-tools/action.yml @@ -0,0 +1,69 @@ +name: Setup Terrabuild Tools +description: Install Terrabuild and configure workspace caching. + +inputs: + terrabuild_version: + description: Optional Terrabuild version override + default: '' + terrabuild_next: + description: Whether to install a Terrabuild prerelease + default: 'false' + terrabuild_workspace: + description: Terrabuild workspace to use + required: true + TERRABUILD_TOKEN: + description: Terrabuild token for authentication + required: false + TERRABUILD_MASTER_KEY: + description: Terrabuild master key for encryption + required: false + GITHUB_TOKEN: + description: GitHub token for pulling and publishing toolchain images + required: false + +outputs: + connected: + description: Whether shared caching was configured + value: ${{ steps.cache.outputs.connected }} + +runs: + using: composite + steps: + - name: Read workspace tool versions + id: tool-versions + uses: ./.github/actions/workspace-versions + + - name: Install Terrabuild + uses: magnusopera/action-install-gh-release@feature/prerelease + with: + repo: magnusopera/terrabuild + tag: ${{ inputs.terrabuild_version || steps.tool-versions.outputs.terrabuild }} + prerelease: ${{ inputs.terrabuild_next }} + + - name: Login to GitHub Container Registry + if: inputs.GITHUB_TOKEN != '' + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ inputs.GITHUB_TOKEN }} + + - name: Configure Terrabuild caching + id: cache + shell: bash + env: + TERRABUILD_WORKSPACE: ${{ inputs.terrabuild_workspace }} + TERRABUILD_TOKEN: ${{ inputs.TERRABUILD_TOKEN }} + TERRABUILD_MASTER_KEY: ${{ inputs.TERRABUILD_MASTER_KEY }} + run: | + terrabuild version + if [[ -n "$TERRABUILD_TOKEN" && -n "$TERRABUILD_MASTER_KEY" ]]; then + terrabuild login \ + --workspace "$TERRABUILD_WORKSPACE" \ + --token "$TERRABUILD_TOKEN" \ + --masterkey "$TERRABUILD_MASTER_KEY" + echo "connected=true" >> "$GITHUB_OUTPUT" + else + echo "TB_FLAGS=--local-only" >> "$GITHUB_ENV" + echo "connected=false" >> "$GITHUB_OUTPUT" + fi diff --git a/.github/actions/workspace-versions/action.yml b/.github/actions/workspace-versions/action.yml new file mode 100644 index 00000000..e0f63146 --- /dev/null +++ b/.github/actions/workspace-versions/action.yml @@ -0,0 +1,27 @@ +name: Read workspace tool versions +description: Read shared build-tool versions from WORKSPACE before installing tools. + +outputs: + dotnet_sdk: + description: .NET SDK version + value: ${{ steps.versions.outputs.dotnet_sdk }} + node: + description: Node.js version + value: ${{ steps.versions.outputs.node }} + terrabuild: + description: Terrabuild release selector + value: ${{ steps.versions.outputs.terrabuild }} + vsce: + description: VS Code packaging and publishing tool version + value: ${{ steps.versions.outputs.vsce }} + ovsx: + description: Open VSX publishing tool version + value: ${{ steps.versions.outputs.ovsx }} + +runs: + using: composite + steps: + - name: Read tool versions + id: versions + shell: bash + run: node .build/read-tool-versions.mjs >> "$GITHUB_OUTPUT" diff --git a/.github/scripts/collect-test-results.mjs b/.github/scripts/collect-test-results.mjs new file mode 100644 index 00000000..b74f5417 --- /dev/null +++ b/.github/scripts/collect-test-results.mjs @@ -0,0 +1,40 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +const root = process.cwd(); +const destination = path.join(root, '.out/test-results'); +const latestTrx = new Map(); +const reports = []; +const ignored = new Set(['bin', 'obj', 'node_modules', '.git', '.fscript']); + +function visit(directory, inResults = false) { + if (!fs.existsSync(directory)) return; + for (const entry of fs.readdirSync(directory, { withFileTypes: true })) { + const file = path.join(directory, entry.name); + if (entry.isDirectory() && !ignored.has(entry.name)) { + visit(file, inResults || entry.name === 'TestResults'); + } else if (entry.isFile() && inResults) { + if (entry.name.endsWith('.xml')) reports.push(file); + if (!entry.name.endsWith('.trx')) continue; + const finish = fs.readFileSync(file, 'utf8').match(/]*\bfinish="([^"]+)"/); + const parsed = finish ? Date.parse(finish[1]) : NaN; + const timestamp = Number.isFinite(parsed) ? parsed : fs.statSync(file).mtimeMs; + const previous = latestTrx.get(directory); + if (!previous || timestamp > previous.timestamp) { + latestTrx.set(directory, { file, timestamp }); + } + } + } +} + +for (const directory of ['src', 'tests', 'samples', 'vscode-fscript']) { + visit(path.join(root, directory)); +} +reports.push(...Array.from(latestTrx.values(), result => result.file)); +fs.rmSync(destination, { recursive: true, force: true }); +for (const file of reports) { + const output = path.join(destination, path.relative(root, file)); + fs.mkdirSync(path.dirname(output), { recursive: true }); + fs.copyFileSync(file, output); +} +console.log(`Collected ${latestTrx.size} .NET reports and ${reports.length - latestTrx.size} JUnit reports.`); diff --git a/.github/scripts/run-ci-shard.mjs b/.github/scripts/run-ci-shard.mjs new file mode 100644 index 00000000..d969a1cd --- /dev/null +++ b/.github/scripts/run-ci-shard.mjs @@ -0,0 +1,12 @@ +import { spawnSync } from 'node:child_process'; +import fs from 'node:fs'; + +const matrix = JSON.parse(fs.readFileSync('.build/ci-shards.json', 'utf8')); +const shard = matrix.include.find(shard => shard.name === process.argv[2]); +if (!shard) throw new Error(`Unknown CI shard: ${process.argv[2]}`); +const args = ['test', '--project', ...shard.projects, '--debug', '--log']; +// setup-tools sets this only for a runner without shared-cache credentials. +if (process.env.TB_FLAGS === '--local-only') args.push('--local-only'); +const result = spawnSync('bash', ['.build/run-terrabuild.sh', ...args], { stdio: 'inherit' }); +if (result.error) throw result.error; +process.exit(result.status ?? 1); diff --git a/.github/scripts/test-ci-shards.mjs b/.github/scripts/test-ci-shards.mjs new file mode 100644 index 00000000..7b863b89 --- /dev/null +++ b/.github/scripts/test-ci-shards.mjs @@ -0,0 +1,98 @@ +import assert from 'node:assert/strict'; +import { execFileSync, spawnSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { test } from 'node:test'; +import { fileURLToPath } from 'node:url'; + +const matrix = JSON.parse(fs.readFileSync('.build/ci-shards.json', 'utf8')).include; +const expected = { + core: ['tests/fscript.language.tests:test', 'tests/fscript.runtime.tests:test', 'tests/fscript.compiler.tests:test', + 'tests/fscript.conformance.tests:test', 'tests/fscript.interpreter.lir.tests:test', + 'tests/fscript.languageserver.tests:test', 'samples/ffi/tests:test_ffi_managed'], + backend: ['tests/fscript.backend.dotnet.tests:test'], + cli: ['tests/fscript.cli.tests:test'], + 'ffi-default': ['samples/ffi/tests:test_ffi_default'], + 'ffi-size': ['samples/ffi/tests:test_ffi_size'], + web: ['src/fscript.javascript:test', 'vscode-fscript:test', 'website:test_ci'], + integration: ['tests/fscript.debugadapter.tests:test', 'tests/fscript.debugadapter.tests:smoke', + 'tests/fscript.debugadapter.tests:sdk'] +}; + +test('CI runner forwards the exact project selection and propagates failures', () => { + const fixture = fs.mkdtempSync(path.join(os.tmpdir(), 'fscript-ci-shard-')); + try { + fs.mkdirSync(path.join(fixture, '.build')); + fs.copyFileSync('.build/ci-shards.json', path.join(fixture, '.build/ci-shards.json')); + fs.writeFileSync(path.join(fixture, '.build/run-terrabuild.sh'), + 'printf "%s\\n" "$@"\nexit "${CI_FIXTURE_EXIT:-0}"\n'); + const script = fileURLToPath(new URL('./run-ci-shard.mjs', import.meta.url)); + const run = (name, extra = {}) => spawnSync(process.execPath, [script, name], { + cwd: fixture, encoding: 'utf8', env: { ...process.env, TB_FLAGS: '--local-only', ...extra } + }); + const core = run('core'); + assert.equal(core.status, 0, core.stderr); + assert.deepEqual(core.stdout.trim().split('\n'), + ['test', '--project', 'ci_core', 'ci_ffi_managed', '--debug', '--log', '--local-only']); + assert.equal(run('ffi-default', { CI_FIXTURE_EXIT: '7' }).status, 7); + assert.notEqual(run('missing').status, 0); + const shared = run('ffi-size', { TB_FLAGS: '' }); + assert.equal(shared.status, 0); + assert.ok(!shared.stdout.includes('--local-only')); + } finally { + fs.rmSync(fixture, { recursive: true, force: true }); + } +}); + +test('static CI shards select exactly their checks, restore reports, and cover every test project', () => { + assert.deepEqual(matrix.map(shard => shard.name).sort(), Object.keys(expected).sort()); + const covered = []; + for (const shard of matrix) { + execFileSync('terrabuild', ['run', 'test', '--project', ...shard.projects, + '--dry-run', '--local-only', '--debug'], { stdio: 'pipe', timeout: 30000 }); + const graph = JSON.parse(fs.readFileSync('terrabuild-debug.json', 'utf8')); + const selected = graph.nodes.filter(node => node.selected); + const consumers = selected.filter(node => node.projectDirectory.startsWith('.build/ci/')); + const checks = selected.filter(node => !consumers.includes(node) && /^(test(?:_ffi_.*|_ci)?|smoke|sdk)$/.test(node.target)); + const names = checks.map(node => `${node.projectDirectory.toLowerCase()}:${node.target}`).sort(); + assert.deepEqual(names, expected[shard.name].toSorted(), shard.name); + assert.ok(consumers.length > 0, shard.name); + for (const consumer of consumers) { + assert.equal(consumer.target, 'test'); + assert.equal(consumer.build, 'always'); + assert.ok(consumer.resolvedOperations.length > 0, 'Cached reports need an executing consumer.'); + } + for (const check of checks) { + const direct = check.dependencies.map(id => graph.nodes.find(node => node.id === id)); + if (check.projectName === 'ffisample_tests') { + assert.ok(direct.some(node => node?.projectName === 'fscript' && node.target === 'build'), + `${check.target} must directly restore the CLI even when its own build is cached.`); + } + if (check.projectName === 'website') { + assert.equal(check.target, 'test_ci'); + assert.deepEqual(check.resolvedOperations.map(operation => operation.metaCommand), ['@npm install', '@npm run'], + 'The CI website target must recreate npm launchers before typechecking.'); + assert.ok(direct.some(node => node?.projectName === 'javascript' && node.target === 'build')); + } + } + covered.push(...checks.filter(node => node.target.startsWith('test')).map(node => + `${node.projectDirectory.toLowerCase()}:test`)); + } + const allTests = []; + function scan(directory) { + if (directory === '.build/ci') return; + for (const entry of fs.readdirSync(directory, { withFileTypes: true })) { + if (['node_modules', 'bin', 'obj', '.git', '.terrabuild', 'TestResults', '.out'].includes(entry.name)) continue; + const file = path.join(directory, entry.name); + if (entry.isDirectory()) scan(file); + else if (entry.name === 'PROJECT' && /target test\s*\{/.test(fs.readFileSync(file, 'utf8'))) { + allTests.push(`${directory.replace(/^\.\//, '').toLowerCase()}:test`); + } + } + } + scan('.'); + assert.deepEqual([...new Set(covered)].sort(), allTests.sort()); + const duplicates = covered.filter((name, index) => covered.indexOf(name) !== index); + assert.deepEqual(duplicates, ['samples/ffi/tests:test', 'samples/ffi/tests:test']); +}); diff --git a/.github/scripts/test-collect-results.mjs b/.github/scripts/test-collect-results.mjs new file mode 100644 index 00000000..b6ebdd87 --- /dev/null +++ b/.github/scripts/test-collect-results.mjs @@ -0,0 +1,32 @@ +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { test } from 'node:test'; +import { fileURLToPath } from 'node:url'; + +test('collection selects current TRX results, retains JUnit, and replaces stale artifacts', () => { + const fixture = fs.mkdtempSync(path.join(os.tmpdir(), 'fscript-report-collection-')); + try { + const reports = path.join(fixture, 'tests/Suite/TestResults'); + fs.mkdirSync(reports, { recursive: true }); + fs.writeFileSync(path.join(reports, 'old.trx'), ''); + fs.writeFileSync(path.join(reports, 'current.trx'), ''); + // Cache extraction time must not make an older test result look current. + fs.utimesSync(path.join(reports, 'current.trx'), 0, 0); + fs.writeFileSync(path.join(reports, 'node.xml'), ''); + const destination = path.join(fixture, '.out/test-results'); + fs.mkdirSync(destination, { recursive: true }); + fs.writeFileSync(path.join(destination, 'stale.trx'), 'stale'); + + const script = fileURLToPath(new URL('./collect-test-results.mjs', import.meta.url)); + const output = execFileSync(process.execPath, [script], { cwd: fixture, encoding: 'utf8' }); + assert.match(output, /Collected 1 .NET reports and 1 JUnit reports/); + assert.deepEqual(fs.readdirSync(path.join(destination, 'tests/Suite/TestResults')).sort(), ['current.trx', 'node.xml']); + assert.equal(fs.existsSync(path.join(destination, 'stale.trx')), false); + assert.equal(fs.existsSync(path.join(reports, 'old.trx')), true); + } finally { + fs.rmSync(fixture, { recursive: true, force: true }); + } +}); diff --git a/.github/scripts/test-node-reports.sh b/.github/scripts/test-node-reports.sh new file mode 100644 index 00000000..6a3f3ee5 --- /dev/null +++ b/.github/scripts/test-node-reports.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash +set -euo pipefail + +root="$(cd "$(dirname "$0")/../.." && pwd)" +fixture=$(mktemp -d) +trap 'rm -rf "$fixture"' EXIT +mkdir -p "$fixture/.build" "$fixture/vscode-fscript" "$fixture/src/FScript.JavaScript/test" \ + "$fixture/tests/FScript.DebugAdapter.Tests" "$fixture/tests/FScript.LanguageServer.Tests" +cp "$root/.build/checks.sh" "$fixture/.build/" + +for suite in debugger javascript vscode; do + case "$suite" in + debugger) + test_file=tests/FScript.DebugAdapter.Tests/FixtureTests.js + report=tests/FScript.DebugAdapter.Tests/TestResults/debugger.xml + import="const test = require('node:test');" ;; + javascript) + test_file=src/FScript.JavaScript/test/fixture.test.mjs + report=src/FScript.JavaScript/TestResults/javascript.xml + import="import test from 'node:test';" ;; + vscode) + test_file=tests/FScript.LanguageServer.Tests/FixtureTests.js + report=vscode-fscript/TestResults/vscode.xml + import="const test = require('node:test');" ;; + esac + printf '%s\ntest("passing report fixture", () => {});\n' "$import" > "$fixture/$test_file" + bash "$fixture/.build/checks.sh" "$suite" > "$fixture/console.log" 2>&1 + grep -q '&2 + exit 1 + fi + + printf '%s\ntest("failing report fixture", () => { throw new Error("expected failure"); });\n' "$import" > "$fixture/$test_file" + if bash "$fixture/.build/checks.sh" "$suite" > "$fixture/console.log" 2>&1; then + echo "ERROR: $suite accepted a failing test." >&2 + exit 1 + fi + grep -q ' "$fixture/bin/terrabuild" <<'STUB' +#!/usr/bin/env bash +set -euo pipefail +printf '%s\n' "$@" > "$TB_TEST_ARGS" +echo 'Terrabuild test output' +report= +while [[ $# -gt 0 ]]; do + if [[ "$1" == --out ]]; then report=$2; shift; fi + shift +done +case "$TB_TEST_RESULT" in + success) printf '{"status":"success"}\n' > "$report" ;; + failed-graph) printf '{"status":"failure"}\n' > "$report" ;; + failed-process) printf '{"status":"success"}\n' > "$report"; exit 7 ;; + missing-report) ;; +esac +STUB +cat > "$fixture/bin/dotnet" <<'STUB' +#!/usr/bin/env bash +printf 'called\n' > "$TB_TEST_DOTNET" +exit 1 +STUB +chmod +x "$fixture/bin/terrabuild" "$fixture/bin/dotnet" +export TB_TEST_ARGS="$fixture/args" TB_TEST_DOTNET="$fixture/dotnet-called" +# The ungrouped cases model normal local builds, even when tested inside CI. +unset TERRABUILD_GROUP + +run_build() { + PATH="$fixture/bin" TB_TEST_RESULT="$1" "$make_command" --no-print-directory -C "$fixture/repo" \ + build config=Debug TB_PARALLEL="${2-2}" TB_FLAGS='--local-only --force' > "$fixture/log" 2>&1 +} + +if ! run_build success; then cat "$fixture/log"; exit 1; fi +grep -qx 'Terrabuild test output' "$fixture/repo/.terrabuild/run.log" +grep -qx '{"status":"success"}' "$fixture/repo/.terrabuild/result.json" +cat > "$fixture/expected-args" <<'ARGS' +run +build +--local-only +--force +--variable +config=Debug +--parallel +2 +--out +ARGS +head -n 9 "$fixture/args" > "$fixture/actual-args" +diff -u "$fixture/expected-args" "$fixture/actual-args" +test "$(wc -l < "$fixture/args" | tr -d ' ')" = 10 + +if ! run_build success ''; then cat "$fixture/log"; exit 1; fi +if grep -q -- '--parallel' "$fixture/args"; then + echo "ERROR: make build overrides Terrabuild's default parallelism." >&2 + exit 1 +fi +test "$(wc -l < "$fixture/args" | tr -d ' ')" = 8 + +export TERRABUILD_GROUP='MagnusOpera/FScript:12345:2' +if ! run_build success ''; then cat "$fixture/log"; exit 1; fi +cat > "$fixture/expected-group-args" <<'ARGS' +run +build +--local-only +--force +--variable +config=Debug +--group +MagnusOpera/FScript:12345:2 +--out +.terrabuild/result.json +ARGS +diff -u "$fixture/expected-group-args" "$fixture/args" +unset TERRABUILD_GROUP + +for result in failed-graph failed-process missing-report; do + if run_build "$result"; then + echo "ERROR: make build accepted $result." >&2 + exit 1 + fi + grep -qx 'Terrabuild test output' "$fixture/repo/.terrabuild/run.log" +done +rm "$fixture/bin/terrabuild" +if run_build success; then + echo "ERROR: make build succeeded without Terrabuild on PATH." >&2 + exit 1 +fi +test ! -e "$TB_TEST_DOTNET" +echo "Terrabuild Makefile checks passed." diff --git a/.github/scripts/test-workspace-versions.mjs b/.github/scripts/test-workspace-versions.mjs new file mode 100644 index 00000000..3710f0b4 --- /dev/null +++ b/.github/scripts/test-workspace-versions.mjs @@ -0,0 +1,119 @@ +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { test } from 'node:test'; +import { fileURLToPath } from 'node:url'; +import { readToolVersions } from '../../.build/read-tool-versions.mjs'; + +const source = `locals { + versions = { + dotnet_sdk: "10.0.999" # SDK used by Docker and Actions + node: "99" + terrabuild: "latest" + vsce: "9.8.7" + ovsx: "9.6" + } +}`; + +test('workflow outputs follow the checked-out WORKSPACE without version fallbacks', () => { + const fixture = fs.mkdtempSync(path.join(os.tmpdir(), 'fscript-tool-versions-')); + try { + fs.writeFileSync(path.join(fixture, 'WORKSPACE'), source); + const script = fileURLToPath(new URL('../../.build/read-tool-versions.mjs', import.meta.url)); + const output = execFileSync(process.execPath, [script], { cwd: fixture, encoding: 'utf8' }); + assert.equal(output, 'dotnet_sdk=10.0.999\nnode=99\nterrabuild=latest\nvsce=9.8.7\novsx=9.6\n'); + } finally { + fs.rmSync(fixture, { recursive: true, force: true }); + } +}); + +test('missing, duplicate, or computed versions fail before tool installation', () => { + assert.throws(() => readToolVersions('locals {}'), /one locals.versions block/); + assert.throws(() => readToolVersions(source.replace('node: "99"', '')), /Missing tool version: node/); + assert.throws(() => readToolVersions(source.replace('node: "99"', 'node: "99"\nnode: "98"')), /Duplicate tool version/); + assert.throws(() => readToolVersions(source.replace('node: "99"', 'node: var.node')), /quoted literal values/); + assert.throws(() => readToolVersions(source.replace('node: "99"', 'node: "$(whoami)"')), /quoted literal values/); +}); + +test('native toolchain tags follow workspace tool versions and Dockerfile inputs', () => { + const root = fileURLToPath(new URL('../../', import.meta.url)); + const fixture = fs.mkdtempSync(path.join(os.tmpdir(), 'fscript-toolchain-identity-')); + try { + fs.mkdirSync(path.join(fixture, '.build/toolchain'), { recursive: true }); + for (const file of ['WORKSPACE', '.build/toolchain/PROJECT', '.build/toolchain/Dockerfile']) { + fs.copyFileSync(path.join(root, file), path.join(fixture, file)); + } + fs.mkdirSync(path.join(fixture, 'app')); + fs.writeFileSync(path.join(fixture, 'app/App.csproj'), 'net10.0'); + fs.writeFileSync(path.join(fixture, 'app/PROJECT'), 'project fixture { @dotnet { } }\ntarget build { @dotnet build { dependencies = false } }\n'); + execFileSync('git', ['init', '--quiet'], { cwd: fixture }); + execFileSync('git', ['add', '.'], { cwd: fixture }); + execFileSync('git', ['-c', 'user.name=Test', '-c', 'user.email=test@example.com', 'commit', '--quiet', '-m', 'fixture'], { cwd: fixture }); + const image = () => { + const output = execFileSync('terrabuild', ['explain', 'build', '--local-only'], { cwd: fixture, encoding: 'utf8', timeout: 30000 }); + const match = output.match(/container: (ghcr\.io\/magnusopera\/fscript-toolchain:[^\s]+)/); + assert.ok(match, 'The build must use the workspace toolchain image.'); + return match[1]; + }; + const original = image(); + const workspace = path.join(fixture, 'WORKSPACE'); + const contents = fs.readFileSync(workspace, 'utf8'); + fs.writeFileSync(workspace, fs.readFileSync(workspace, 'utf8').replace(/dotnet_sdk: "[^"]+"/, 'dotnet_sdk: "10.0.999"')); + assert.notEqual(image(), original); + fs.writeFileSync(workspace, contents.replace(/node: "[^"]+"/, 'node: "99"')); + assert.notEqual(image(), original); + fs.writeFileSync(workspace, contents); + const dockerfile = path.join(fixture, '.build/toolchain/Dockerfile'); + fs.appendFileSync(dockerfile, '\nENV TOOLCHAIN_IDENTITY_REGRESSION=1\n'); + assert.notEqual(image(), original); + } finally { + fs.rmSync(fixture, { recursive: true, force: true }); + } +}); +test('workflow and changelog edits preserve VS Code cache keys while website tracks release notes', () => { + const root = fileURLToPath(new URL('../../', import.meta.url)); + const fixture = fs.mkdtempSync(path.join(os.tmpdir(), 'fscript-frontend-inputs-')); + try { + // Copy tracked inputs only; generated packaging files must stay untracked. + const files = execFileSync('git', ['ls-files', '-z'], { cwd: root }).toString().split('\0').filter(Boolean); + for (const file of files) { + const source = path.join(root, file); + const destination = path.join(fixture, file); + fs.mkdirSync(path.dirname(destination), { recursive: true }); + if (fs.lstatSync(source).isSymbolicLink()) fs.symlinkSync(fs.readlinkSync(source), destination); + else fs.copyFileSync(source, destination); + } + const packageFile = path.join(fixture, 'vscode-fscript/package.json'); + const manifest = JSON.parse(fs.readFileSync(packageFile, 'utf8')); + // Exercise the real packaging hook without installing the compiler in this fixture. + manifest.scripts.compile = 'node -e ""'; + fs.writeFileSync(packageFile, JSON.stringify(manifest)); + execFileSync('git', ['init', '--quiet'], { cwd: fixture }); + execFileSync('git', ['add', '.'], { cwd: fixture }); + execFileSync('git', ['-c', 'user.name=Test', '-c', 'user.email=test@example.com', 'commit', '--quiet', '-m', 'fixture'], { cwd: fixture }); + const keys = () => { + execFileSync('terrabuild', ['run', 'build', '--project', 'vscode', 'website', '--dry-run', '--local-only', '--debug'], + { cwd: fixture, stdio: 'pipe', timeout: 30000 }); + const graph = JSON.parse(fs.readFileSync(path.join(fixture, 'terrabuild-debug.json'), 'utf8')); + return Object.fromEntries(graph.nodes.filter(node => ['vscode', 'website'].includes(node.projectName) && node.target === 'build') + .map(node => [node.projectName, node.fingerprint.cacheKey])); + }; + const original = keys(); + fs.appendFileSync(path.join(fixture, '.github/workflows/ci.yml'), '\n# Workflow-only change\n'); + assert.deepEqual(keys(), original, 'Workflow changes must not invalidate frontend builds.'); + fs.appendFileSync(path.join(fixture, 'CHANGELOG.md'), '\n- Release-note-only change.\n'); + const releaseNotes = keys(); + assert.equal(releaseNotes.vscode, original.vscode, 'Release notes are not a compilation input.'); + assert.notEqual(releaseNotes.website, original.website, 'The website must still track the changelog.'); + execFileSync('npm', ['run', 'vscode:prepublish'], { cwd: path.join(fixture, 'vscode-fscript'), stdio: 'pipe' }); + assert.equal(fs.readFileSync(path.join(fixture, 'vscode-fscript/CHANGELOG.md'), 'utf8'), + fs.readFileSync(path.join(fixture, 'CHANGELOG.md'), 'utf8'), 'Packaging must include current release notes.'); + assert.deepEqual(keys(), releaseNotes, 'Generated packaging notes must not invalidate builds.'); + fs.appendFileSync(path.join(fixture, 'vscode-fscript/extension.js'), '\n// Source change\n'); + assert.notEqual(keys().vscode, original.vscode, 'Extension source changes must still invalidate its build.'); + } finally { + fs.rmSync(fixture, { recursive: true, force: true }); + } +}); diff --git a/.github/scripts/verify-release-cli.sh b/.github/scripts/verify-release-cli.sh index 27a976b5..4ece4a37 100755 --- a/.github/scripts/verify-release-cli.sh +++ b/.github/scripts/verify-release-cli.sh @@ -20,12 +20,12 @@ case "$reported_version" in esac printf 'Testing released CLI %s\n' "$reported_version" -dotnet build "$repo_root/samples/ffi/FfiSample.fsproj" -c Release +dotnet build "$repo_root/samples/ffi/Provider/FfiSample.fsproj" -c Release "$cli" analyze "$repo_root/samples/ffi/main.fss" mkdir -p "$test_root/source" printf "[runtime]\nextern-assemblies = ['%s']\n" \ - "$repo_root/samples/ffi/bin/Release/net10.0/FScript.Ffi.Sample.dll" > "$test_root/source/fscript.toml" + "$repo_root/samples/ffi/Provider/bin/Release/net10.0/FScript.Ffi.Sample.dll" > "$test_root/source/fscript.toml" printf 'Math.sqrt 81.0\n' > "$test_root/source/check.fss" for target in csharp dotnet; do "$cli" compile "$test_root/source/check.fss" --target "$target" --output "$test_root/$target" diff --git a/.github/workflows/ci-main.yml b/.github/workflows/ci-main.yml index ab86a07b..71caee9d 100644 --- a/.github/workflows/ci-main.yml +++ b/.github/workflows/ci-main.yml @@ -1,90 +1,20 @@ -name: CI Main +name: CI Branches on: push: branches: - - main + - '**' workflow_dispatch: permissions: contents: read checks: write + packages: write jobs: - build-and-test: - runs-on: ubuntu-latest - timeout-minutes: 25 - - steps: - - name: Checkout - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Enforce changelog updates for functional changes - run: .github/scripts/check-unreleased-changelog.sh - - - name: Verify changelog release formatting - run: make test-release-changelog - - - name: Setup .NET - uses: actions/setup-dotnet@v4 - with: - dotnet-version: 10.0.401 - - - name: Install Native AOT toolchain - run: sudo apt-get update && sudo apt-get install -y clang zlib1g-dev - - - name: Restore - run: dotnet restore FScript.sln - - - name: Build - run: dotnet build FScript.sln --configuration Release --no-restore - - - name: Test - run: dotnet test FScript.sln --configuration Release --no-build --logger "trx" --results-directory TestResults - - - name: Verify independently versioned SDK provider - run: make verify-sdk-provider - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: '22' - cache: npm - cache-dependency-path: website/package-lock.json - - - name: Test CLI debugger adapter - run: node --test tests/FScript.DebugAdapter.Tests/*Tests.js - - - name: Test VS Code extension - working-directory: vscode-fscript - run: | - npm ci - npm test - - - name: Run Fable JS tests - run: make fable-test - - - name: Build website - run: | - cd website - npm ci - npm run build - - - name: Upload test results - if: always() - uses: actions/upload-artifact@v4 - with: - name: test-results-main - path: TestResults/**/*.trx - if-no-files-found: warn - - - name: Publish test report - if: always() - continue-on-error: true - uses: dorny/test-reporter@v2 - with: - name: Test Results (Main) - path: TestResults/**/*.trx - reporter: dotnet-trx + ci: + uses: ./.github/workflows/ci.yml + with: + suffix: branch + require-changelog: false + secrets: inherit diff --git a/.github/workflows/ci-pr.yml b/.github/workflows/ci-pr.yml index c944a758..43516092 100644 --- a/.github/workflows/ci-pr.yml +++ b/.github/workflows/ci-pr.yml @@ -13,84 +13,12 @@ on: permissions: contents: read checks: write + packages: write jobs: - build-and-test: - runs-on: ubuntu-latest - timeout-minutes: 25 - - steps: - - name: Checkout - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Enforce unreleased changelog updates - env: - REQUIRE_CHANGELOG_ALWAYS: "true" - run: .github/scripts/check-unreleased-changelog.sh - - - name: Verify changelog release formatting - run: make test-release-changelog - - - name: Setup .NET - uses: actions/setup-dotnet@v4 - with: - dotnet-version: 10.0.401 - - - name: Install Native AOT toolchain - run: sudo apt-get update && sudo apt-get install -y clang zlib1g-dev - - - name: Restore - run: dotnet restore FScript.sln - - - name: Build - run: dotnet build FScript.sln --configuration Release --no-restore - - - name: Test - run: dotnet test FScript.sln --configuration Release --no-build --logger "trx" --results-directory TestResults - - - name: Verify independently versioned SDK provider - run: make verify-sdk-provider - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: '22' - cache: npm - cache-dependency-path: website/package-lock.json - - - name: Test CLI debugger adapter - run: node --test tests/FScript.DebugAdapter.Tests/*Tests.js - - - name: Test VS Code extension - working-directory: vscode-fscript - run: | - npm ci - npm test - - - name: Run Fable JS tests - run: make fable-test - - - name: Build website - run: | - cd website - npm ci - npm run build - - - name: Upload test results - if: always() - uses: actions/upload-artifact@v4 - with: - name: test-results-pr - path: TestResults/**/*.trx - if-no-files-found: warn - - - name: Publish test report - if: always() - continue-on-error: true - uses: dorny/test-reporter@v2 - with: - name: Test Results (PR) - path: TestResults/**/*.trx - reporter: dotnet-trx + ci: + uses: ./.github/workflows/ci.yml + with: + suffix: pr + require-changelog: true + secrets: inherit diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..263519df --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,138 @@ +name: Terrabuild CI + +on: + workflow_call: + inputs: + suffix: + type: string + required: true + require-changelog: + type: boolean + default: false + secrets: + TERRABUILD_TOKEN: + required: false + TERRABUILD_MASTER_KEY: + required: false + +concurrency: + group: fscript-ci-${{ github.event.pull_request.head.repo.full_name || github.repository }}-${{ github.head_ref || github.ref_name }} + cancel-in-progress: true + +permissions: + contents: read + checks: write + packages: read + +env: + TERRABUILD_GROUP: ${{ github.repository }}:${{ github.run_id }}:${{ github.run_attempt }} + +jobs: + build: + permissions: + contents: read + checks: write + packages: write + runs-on: ubuntu-latest + timeout-minutes: 25 + outputs: + matrix: ${{ steps.shards.outputs.matrix }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Enforce changelog updates + env: + REQUIRE_CHANGELOG_ALWAYS: ${{ inputs.require-changelog }} + run: .github/scripts/check-unreleased-changelog.sh + - name: Verify release formatting + run: make test-release-changelog + - name: Setup Tools + id: setup-tools + uses: ./.github/actions/setup-tools + with: + terrabuild_workspace: "6ae115ba87040d459b6ccf40" + TERRABUILD_TOKEN: ${{ secrets.TERRABUILD_TOKEN }} + TERRABUILD_MASTER_KEY: ${{ secrets.TERRABUILD_MASTER_KEY }} + GITHUB_TOKEN: ${{ github.token }} + - name: Verify CI integration and shard coverage + run: | + make test-terrabuild + node --test .github/scripts/test-ci-shards.mjs + - name: Read static test matrix + id: shards + run: node -e 'console.log("matrix=" + JSON.stringify(require("./.build/ci-shards.json")))' >> "$GITHUB_OUTPUT" + - name: Build and populate the Terrabuild cache + run: make build TB_FLAGS="${TB_FLAGS:-} --debug --log" + - name: Logout from Terrabuild + if: always() && steps.setup-tools.outputs.connected == 'true' + run: terrabuild logout --space "6ae115ba87040d459b6ccf40" + - name: Collect build diagnostics + if: always() + uses: ./.github/actions/collect-build-results + with: + suffix: ${{ inputs.suffix }}-build + publish: 'false' + + tests: + needs: build + name: Tests (${{ matrix.name }}) + runs-on: ubuntu-latest + timeout-minutes: 25 + strategy: + fail-fast: false + matrix: ${{ fromJSON(needs.build.outputs.matrix) }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Setup Tools + id: setup-tools + uses: ./.github/actions/setup-tools + with: + terrabuild_workspace: "6ae115ba87040d459b6ccf40" + TERRABUILD_TOKEN: ${{ secrets.TERRABUILD_TOKEN }} + TERRABUILD_MASTER_KEY: ${{ secrets.TERRABUILD_MASTER_KEY }} + GITHUB_TOKEN: ${{ github.token }} + - name: Run selected tests and restore their reports + env: + CI_SHARD: ${{ matrix.name }} + run: node .github/scripts/run-ci-shard.mjs "$CI_SHARD" + - name: Logout from Terrabuild + if: always() && steps.setup-tools.outputs.connected == 'true' + run: terrabuild logout --space "6ae115ba87040d459b6ccf40" + - name: Collect shard results and diagnostics + if: always() + uses: ./.github/actions/collect-build-results + with: + suffix: ${{ inputs.suffix }}-${{ matrix.name }} + publish: 'false' + + build-and-test: + name: Build and test + needs: [build, tests] + if: always() + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Gather shard test results + if: needs.tests.result != 'skipped' + uses: actions/download-artifact@v4 + with: + pattern: test-results-${{ inputs.suffix }}-* + path: .out/ci-results + merge-multiple: false + - name: Publish combined test reports + if: always() && needs.tests.result != 'skipped' + uses: ./.github/actions/publish-test-results + with: + suffix: ${{ inputs.suffix }} + path: .out/ci-results + - name: Require the build and every shard to pass + if: always() + env: + BUILD_RESULT: ${{ needs.build.result }} + TEST_RESULT: ${{ needs.tests.result }} + run: | + test "$BUILD_RESULT" = success + test "$TEST_RESULT" = success diff --git a/.github/workflows/on-push-tag.yml b/.github/workflows/on-push-tag.yml index 4f42ecce..14ec1bb7 100644 --- a/.github/workflows/on-push-tag.yml +++ b/.github/workflows/on-push-tag.yml @@ -20,15 +20,19 @@ jobs: - name: Checkout uses: actions/checkout@v4 + - name: Read workspace tool versions + id: tool-versions + uses: ./.github/actions/workspace-versions + - name: Setup .NET uses: actions/setup-dotnet@v4 with: - dotnet-version: 10.0.401 + dotnet-version: ${{ steps.tool-versions.outputs.dotnet_sdk }} - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: '22' + node-version: ${{ steps.tool-versions.outputs.node }} - name: Install extension dependencies working-directory: vscode-fscript @@ -58,7 +62,9 @@ jobs: run: dotnet test tests/FScript.LanguageServer.Tests/FScript.LanguageServer.Tests.csproj -c Release --filter "FullyQualifiedName~ReleasePackagingTests|FullyQualifiedName~EnvironmentTests" - name: Install extension packaging tool - run: npm install -g @vscode/vsce@4.0.0 + env: + VSCE_VERSION: ${{ steps.tool-versions.outputs.vsce }} + run: npm install -g "@vscode/vsce@$VSCE_VERSION" - name: Package extension working-directory: vscode-fscript @@ -121,10 +127,14 @@ jobs: - name: Checkout uses: actions/checkout@v4 + - name: Read workspace tool versions + id: tool-versions + uses: ./.github/actions/workspace-versions + - name: Setup .NET uses: actions/setup-dotnet@v4 with: - dotnet-version: 10.0.401 + dotnet-version: ${{ steps.tool-versions.outputs.dotnet_sdk }} - name: Download cross-built macOS CLI env: diff --git a/.github/workflows/on-release-published.yml b/.github/workflows/on-release-published.yml index e1a49fec..0ddd9e7c 100644 --- a/.github/workflows/on-release-published.yml +++ b/.github/workflows/on-release-published.yml @@ -28,10 +28,19 @@ jobs: contents: read id-token: write steps: + - name: Checkout release + uses: actions/checkout@v4 + with: + ref: ${{ github.event.release.tag_name }} + + - name: Read workspace tool versions + id: tool-versions + uses: ./.github/actions/workspace-versions + - name: Setup .NET uses: actions/setup-dotnet@v4 with: - dotnet-version: 10.0.401 + dotnet-version: ${{ steps.tool-versions.outputs.dotnet_sdk }} - name: Download GitHub release artifacts uses: robinraju/release-downloader@v1.11 @@ -65,10 +74,19 @@ jobs: RELEASE_VERSION: ${{ github.event.release.tag_name }} PACKAGE_NAME: '@magnusopera/fscript' steps: + - name: Checkout release + uses: actions/checkout@v4 + with: + ref: ${{ github.event.release.tag_name }} + + - name: Read workspace tool versions + id: tool-versions + uses: ./.github/actions/workspace-versions + - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: '22' + node-version: ${{ steps.tool-versions.outputs.node }} registry-url: 'https://registry.npmjs.org' - name: Download npm package artifact @@ -108,10 +126,14 @@ jobs: - name: Checkout uses: actions/checkout@v4 + - name: Read workspace tool versions + id: tool-versions + uses: ./.github/actions/workspace-versions + - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: '22' + node-version: ${{ steps.tool-versions.outputs.node }} - name: Download VSIX artifact uses: robinraju/release-downloader@v1.11 @@ -120,7 +142,9 @@ jobs: fileName: '*.vsix' - name: Install VS Code publishing tool - run: npm install -g @vscode/vsce@4.0.0 + env: + VSCE_VERSION: ${{ steps.tool-versions.outputs.vsce }} + run: npm install -g "@vscode/vsce@$VSCE_VERSION" - name: Publish to VS Code Marketplace shell: bash @@ -136,10 +160,14 @@ jobs: - name: Checkout uses: actions/checkout@v4 + - name: Read workspace tool versions + id: tool-versions + uses: ./.github/actions/workspace-versions + - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: '22' + node-version: ${{ steps.tool-versions.outputs.node }} - name: Download VSIX artifact uses: robinraju/release-downloader@v1.11 @@ -148,7 +176,9 @@ jobs: fileName: '*.vsix' - name: Install Open VSX publishing tool - run: npm install -g ovsx@0.9 + env: + OVSX_VERSION: ${{ steps.tool-versions.outputs.ovsx }} + run: npm install -g "ovsx@$OVSX_VERSION" - name: Publish to Open VSX shell: bash @@ -160,10 +190,19 @@ jobs: if: github.event_name == 'release' runs-on: macos-latest steps: + - name: Checkout release + uses: actions/checkout@v4 + with: + ref: ${{ github.event.release.tag_name }} + + - name: Read workspace tool versions + id: tool-versions + uses: ./.github/actions/workspace-versions + - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: '22' + node-version: ${{ steps.tool-versions.outputs.node }} - name: Download unsigned macOS artifacts uses: robinraju/release-downloader@v1.11 diff --git a/.github/workflows/publish-website.yml b/.github/workflows/publish-website.yml index ac3247ad..495f510a 100644 --- a/.github/workflows/publish-website.yml +++ b/.github/workflows/publish-website.yml @@ -26,15 +26,19 @@ jobs: ref: main persist-credentials: false + - name: Read workspace tool versions + id: tool-versions + uses: ./.github/actions/workspace-versions + - name: Setup .NET uses: actions/setup-dotnet@v4 with: - dotnet-version: 10.0.401 + dotnet-version: ${{ steps.tool-versions.outputs.dotnet_sdk }} - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: '22' + node-version: ${{ steps.tool-versions.outputs.node }} cache: npm cache-dependency-path: website/package-lock.json diff --git a/.gitignore b/.gitignore index 160ce827..8f072c92 100644 --- a/.gitignore +++ b/.gitignore @@ -43,3 +43,10 @@ vscode-fscript/dist/ vscode-fscript/*.vsix src/FScript.JavaScript/dist/ + +# Terrabuild planning/batch files and local diagnostics +.terrabuild/ +terrabuild-debug.* + +# Generated by vscode:prepublish, rather than tracked as a build input. +/vscode-fscript/CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md index b61340ad..fa850e00 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,23 @@ All notable changes to FScript are documented in this file. ## [Unreleased] +- Keep VS Code builds cached when release notes change by adding the changelog only during extension packaging. +- Cancel superseded branch and pull-request CI runs for the same source branch while keeping test shards parallel. +- Label GitHub test shards as test instead of ci in Insights. +- Keep GitHub workflow reruns in separate Insights build groups while correlating each attempt's build and test shards. +- Publish the CI toolchain as a single image manifest so test runners can pull it from GHCR. +- Use Terrabuild's native Docker publishing and hash-based toolchain tags. +- Reuse GHCR toolchain images across CI runners. +- Restore the CLI and website test dependencies when CI shards start with cached builds on fresh runners. +- Report the GitHub build and all test shards as one Insights build group per workflow attempt. +- Split GitHub CI into a cached build, parallel test shards, and combined test reports, including separate Native AOT variants. +- Allow the native teapot debugger test to finish mesh generation on busy CI runners and report clearer event timeouts. +- Define shared build-tool versions in WORKSPACE for container builds and GitHub workflows. +- Update the .NET build toolchain to SDK 10.0.403 so Native AOT publishing uses the patched compiler. +- Publish .NET, JavaScript, and debugger test summaries and downloadable Terrabuild logs in CI. +- Install the latest stable Terrabuild release in CI. +- Run Terrabuild CI on pushes to every branch. +- Add container-backed Terrabuild builds and cached test suites, with separate FFI provider, renderer, and test projects. - Fix NuGet trusted publishing authentication and allow retrying existing release packages without rebuilding. ## [0.90.1] diff --git a/FScript.sln b/FScript.sln index 6e66d5c9..2e586719 100644 --- a/FScript.sln +++ b/FScript.sln @@ -35,7 +35,7 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "samples", "samples", "{5D20 EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "ffi", "ffi", "{9690432B-97EE-2C44-DC05-BE42F5F4E82F}" EndProject -Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "FfiSample", "samples\ffi\FfiSample.fsproj", "{337DC273-3133-42EA-B2B8-71E8E56BF17F}" +Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "FfiSample", "samples\ffi\Provider\FfiSample.fsproj", "{337DC273-3133-42EA-B2B8-71E8E56BF17F}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Renderer", "samples\ffi\Renderer\Renderer.csproj", "{F489A79F-CB10-4177-9228-B4B1CD5B7574}" EndProject diff --git a/Makefile b/Makefile index 9d5b49d4..1106e0c1 100644 --- a/Makefile +++ b/Makefile @@ -1,9 +1,8 @@ .PHONY: build test benchmark-interpreters benchmark-front-end benchmark-inference benchmark-evaluation benchmark-dotnet-comparison vscode-debug-smoke fable-build fable-test smoke-tests verify-changelog release-prepare clean publish publish-darwin publish-linux publish-windows pack-nuget publish-all website website-install website-build website-version website-typecheck -build: - dotnet build FScript.sln -c Release +build: tb-build -test: check-changelog-format test-release-changelog +test: check-changelog-format test-release-changelog test-terrabuild dotnet test FScript.sln -c Release node --test tests/FScript.DebugAdapter.Tests/*Tests.js @@ -42,7 +41,7 @@ smoke-tests: echo "Running samples/debugging/main.fss"; \ dotnet run --project src/FScript -- samples/debugging/main.fss > /dev/null; \ echo "Running samples/ffi (headless)"; \ - dotnet build samples/ffi/FfiSample.fsproj -c Release > /dev/null; \ + dotnet build samples/ffi/Provider/FfiSample.fsproj -c Release > /dev/null; \ dotnet run --project src/FScript -- samples/ffi/main.fss -- --headless; \ echo "Analyzing samples/task-sharing"; \ dotnet build samples/task-sharing/TaskSharingSample.fsproj -c Release > /dev/null; \ @@ -120,3 +119,29 @@ check-changelog-format: test-release-changelog: ./.github/scripts/test-release-changelog.sh + +# Container-backed whole-repository checks use Terrabuild from PATH. +.PHONY: tb-build tb-test tb-smoke tb-sdk tb-check test-terrabuild +TB_FLAGS ?= +TB_PARALLEL ?= + +test-terrabuild: + bash .github/scripts/test-terrabuild.sh + bash .github/scripts/test-node-reports.sh + node --test .github/scripts/test-collect-results.mjs + node --test .github/scripts/test-workspace-versions.mjs + +tb-build: + FSCRIPT_BUILD_CONFIG=$(config) TB_PARALLEL=$(TB_PARALLEL) .build/run-terrabuild.sh build $(TB_FLAGS) + +tb-test: + FSCRIPT_BUILD_CONFIG=$(config) TB_PARALLEL=$(TB_PARALLEL) .build/run-terrabuild.sh test $(TB_FLAGS) + +tb-smoke: + FSCRIPT_BUILD_CONFIG=$(config) TB_PARALLEL=$(TB_PARALLEL) .build/run-terrabuild.sh smoke $(TB_FLAGS) + +tb-sdk: + FSCRIPT_BUILD_CONFIG=$(config) TB_PARALLEL=$(TB_PARALLEL) .build/run-terrabuild.sh sdk $(TB_FLAGS) + +tb-check: + FSCRIPT_BUILD_CONFIG=$(config) TB_PARALLEL=$(TB_PARALLEL) .build/run-terrabuild.sh build test smoke sdk reports $(TB_FLAGS) diff --git a/WORKSPACE b/WORKSPACE new file mode 100644 index 00000000..f21ec822 --- /dev/null +++ b/WORKSPACE @@ -0,0 +1,116 @@ +workspace { + id = "6ae115ba87040d459b6ccf40" + engine = ~docker + ignores = [ "**/bin" "**/obj" "**/node_modules" "**/TestResults" + "**/.fscript" "**/.docusaurus" ".out" "out" ".terrabuild" ] +} + +phase toolchains { } +phase application { depends_on = [ phase.toolchains ] } + +variable config { default = "Release" } + +locals { + versions = { + dotnet_sdk: "10.0.403" + node: "22" + terrabuild: "latest" + vsce: "4.0.0" + ovsx: "0.9" + } +} + +target install { + phase = phase.application + build = ~lazy + artifacts = ~workspace +} + +target build { + batch = ~never + phase = phase.application + artifacts = ~managed + depends_on = [ target.install target.^build ] +} + +target dist { + phase = phase.application + artifacts = ~external + depends_on = [ target.build target.^dist ] +} + +target test { + batch = ~never + phase = phase.application + artifacts = ~managed + depends_on = [ target.build ] +} + +target smoke { + phase = phase.application + artifacts = ~managed + depends_on = [ target.build target.^build ] +} + +target sdk { + phase = phase.application + artifacts = ~managed + depends_on = [ target.build target.^build ] +} + +target reports { + phase = phase.application + build = ~always + artifacts = ~none + depends_on = [ target.^test ] +} + +target test_ffi_managed { + phase = phase.application + artifacts = ~managed + depends_on = [ target.build ] +} + +target test_ffi_default { + phase = phase.application + artifacts = ~managed + depends_on = [ target.build ] +} + +target test_ffi_size { + phase = phase.application + artifacts = ~managed + depends_on = [ target.build ] +} + +target test_ci { + phase = phase.application + artifacts = ~managed + depends_on = [ target.build target.^build ] +} + +extension @dotnet { + image = "ghcr.io/magnusopera/fscript-toolchain:${project.toolchain.version}" + platform = "linux/${terrabuild.arch}" + defaults { configuration = var.config } +} + +extension @npm { + image = "ghcr.io/magnusopera/fscript-toolchain:${project.toolchain.version}" + platform = "linux/${terrabuild.arch}" + env { CI = "true" } +} + +extension workflow { + script = ".build/workflow.fss" + image = "ghcr.io/magnusopera/fscript-toolchain:${project.toolchain.version}" + platform = "linux/${terrabuild.arch}" + env { FSCRIPT_BUILD_CONFIG = var.config } +} + +extension @docker { + defaults { + image = "ghcr.io/magnusopera/fscript-${terrabuild.project}" + platforms = [ "linux/${terrabuild.arch}" ] + } +} diff --git a/benchmarks/FScript.DotNet.Comparison/PROJECT b/benchmarks/FScript.DotNet.Comparison/PROJECT new file mode 100644 index 00000000..65905a5b --- /dev/null +++ b/benchmarks/FScript.DotNet.Comparison/PROJECT @@ -0,0 +1,9 @@ +project fscript_dotnet_comparison { + includes = [ "**/*" "../../Directory.Build.props" "../../.config/dotnet-tools.json" ] + ignores = [ "**/bin/**" "**/obj/**" "**/TestResults/**" "**/node_modules/**" "**/.fscript/**" ] + @dotnet { } +} + +target build { + @dotnet build { dependencies = false } +} diff --git a/benchmarks/FScript.DotNet.Reference/PROJECT b/benchmarks/FScript.DotNet.Reference/PROJECT new file mode 100644 index 00000000..c314323a --- /dev/null +++ b/benchmarks/FScript.DotNet.Reference/PROJECT @@ -0,0 +1,9 @@ +project fscript_dotnet_reference { + includes = [ "**/*" "../../Directory.Build.props" "../../.config/dotnet-tools.json" ] + ignores = [ "**/bin/**" "**/obj/**" "**/TestResults/**" "**/node_modules/**" "**/.fscript/**" ] + @dotnet { } +} + +target build { + @dotnet build { dependencies = false } +} diff --git a/benchmarks/FScript.Evaluation.Benchmarks/PROJECT b/benchmarks/FScript.Evaluation.Benchmarks/PROJECT new file mode 100644 index 00000000..39fe014e --- /dev/null +++ b/benchmarks/FScript.Evaluation.Benchmarks/PROJECT @@ -0,0 +1,9 @@ +project fscript_evaluation_benchmarks { + includes = [ "**/*" "../../Directory.Build.props" "../../.config/dotnet-tools.json" ] + ignores = [ "**/bin/**" "**/obj/**" "**/TestResults/**" "**/node_modules/**" "**/.fscript/**" ] + @dotnet { } +} + +target build { + @dotnet build { dependencies = false } +} diff --git a/benchmarks/FScript.FrontEnd.Benchmarks/PROJECT b/benchmarks/FScript.FrontEnd.Benchmarks/PROJECT new file mode 100644 index 00000000..ba806631 --- /dev/null +++ b/benchmarks/FScript.FrontEnd.Benchmarks/PROJECT @@ -0,0 +1,9 @@ +project fscript_frontend_benchmarks { + includes = [ "**/*" "../../Directory.Build.props" "../../.config/dotnet-tools.json" ] + ignores = [ "**/bin/**" "**/obj/**" "**/TestResults/**" "**/node_modules/**" "**/.fscript/**" ] + @dotnet { } +} + +target build { + @dotnet build { dependencies = false } +} diff --git a/benchmarks/FScript.Inference.Benchmarks/PROJECT b/benchmarks/FScript.Inference.Benchmarks/PROJECT new file mode 100644 index 00000000..74b93f8a --- /dev/null +++ b/benchmarks/FScript.Inference.Benchmarks/PROJECT @@ -0,0 +1,9 @@ +project fscript_inference_benchmarks { + includes = [ "**/*" "../../Directory.Build.props" "../../.config/dotnet-tools.json" ] + ignores = [ "**/bin/**" "**/obj/**" "**/TestResults/**" "**/node_modules/**" "**/.fscript/**" ] + @dotnet { } +} + +target build { + @dotnet build { dependencies = false } +} diff --git a/benchmarks/FScript.Interpreter.Benchmarks/PROJECT b/benchmarks/FScript.Interpreter.Benchmarks/PROJECT new file mode 100644 index 00000000..ffa561e9 --- /dev/null +++ b/benchmarks/FScript.Interpreter.Benchmarks/PROJECT @@ -0,0 +1,9 @@ +project fscript_interpreter_benchmarks { + includes = [ "**/*" "../../Directory.Build.props" "../../.config/dotnet-tools.json" ] + ignores = [ "**/bin/**" "**/obj/**" "**/TestResults/**" "**/node_modules/**" "**/.fscript/**" ] + @dotnet { } +} + +target build { + @dotnet build { dependencies = false } +} diff --git a/docs/README.md b/docs/README.md index d15f6ab6..c7daa1d5 100644 --- a/docs/README.md +++ b/docs/README.md @@ -13,6 +13,10 @@ This folder is organized into three layers: - FScript vs F# / OCaml: [`guides/fsharp-ocaml-differences.md`](./guides/fsharp-ocaml-differences.md) - Language rationale: [`guides/language-choices.md`](./guides/language-choices.md) +## Build and test + +- [Container-backed Terrabuild workflow](./guides/terrabuild.md) + ## Compilation - [Choose a compilation target](./guides/compilation.md) diff --git a/docs/architecture/assemblies-and-roles.md b/docs/architecture/assemblies-and-roles.md index da7fee9f..bf324bd0 100644 --- a/docs/architecture/assemblies-and-roles.md +++ b/docs/architecture/assemblies-and-roles.md @@ -214,7 +214,7 @@ The lexer keeps `export` as an identifier. At a top-level statement boundary, th ## FFI graphics sample -`samples/ffi` separates script-owned geometry and scene decisions from the native renderer. Its F# provider library references the independent Extensibility SDK and a small C# GLFW/OpenGL backend; the backend has no Language or Runtime dependency. `teapot.fss` tessellates Bézier control points and supplies vertices/normals/indices; `scene.fss` supplies camera, light, material, and animation. `main.fss` starts execution through callback-based `Graphics.run`/`Graphics.runHeadless` externs; the provider invokes setup and frame closures through `ExternalCallContext.Apply` on the window thread. Static compiler companions instead bind typed CLR calls and unary setup/render delegates, allowing the same script to compile to Native AOT without SDK dispatch. The renderer calls its small set of GLFW/OpenGL entry points through unmanaged function pointers. GLFW runtime binaries and their license are embedded in the renderer assembly and extracted by content hash only on graphical startup; no managed assembly path or dynamic delegate generation is required. Geometry uses array-backed native records while preserving script list semantics. No sample executable project is required. Sample tests use a recording backend and require no display; graphical checks remain explicit. These sample-only dependencies do not enter the CLI, Runtime package, or JavaScript build. +`samples/ffi` separates script-owned geometry and scene decisions from the native renderer. Its F# provider library lives in `samples/ffi/Provider`, beside `Renderer` and `Tests`, so Terrabuild can discover each project separately. It references the independent Extensibility SDK and a small C# GLFW/OpenGL backend; the backend has no Language or Runtime dependency. `teapot.fss` tessellates Bézier control points and supplies vertices/normals/indices; `scene.fss` supplies camera, light, material, and animation. `main.fss` starts execution through callback-based `Graphics.run`/`Graphics.runHeadless` externs; the provider invokes setup and frame closures through `ExternalCallContext.Apply` on the window thread. Static compiler companions instead bind typed CLR calls and unary setup/render delegates, allowing the same script to compile to Native AOT without SDK dispatch. The renderer calls its small set of GLFW/OpenGL entry points through unmanaged function pointers. GLFW runtime binaries and their license are embedded in the renderer assembly and extracted by content hash only on graphical startup; no managed assembly path or dynamic delegate generation is required. Geometry uses array-backed native records while preserving script list semantics. No sample executable project is required. Sample tests use a recording backend and require no display; graphical checks remain explicit. These sample-only dependencies do not enter the CLI, Runtime package, or JavaScript build. The macOS release entitlement permits external native dependency signatures for explicitly loaded providers. The graphics backend resolves its bundled native assets relative to its provider assembly; the CLI installation directory and system search paths are not modified. @@ -264,3 +264,13 @@ the synchronous continuation driver and contains no debugger API. See the ## Interpreter host policy Language owns the permission enum, native descriptor requirements, registration grants, evaluator checks, and propagation through task/callback contexts and retained states. Runtime classifies standard externs, filters host registries, retains policy on loaded scripts, and gates provider loading. The Extensibility SDK retains its stable record layout: Runtime associates forwarded default implementations with their original requirements using weak implementation identity metadata; unknown SDK implementations require Native. Forwarding delegates also check the original requirement at execution, so wrapping or renaming cannot remove the check. Provider code cannot choose grants. CLI/analysis entry points explicitly authorize all capabilities; compiler inference and generated programs do not enforce the interpreter policy. Terrabuild supplies IO and Threading without Interactive or Native. + +## Container-backed build graph + +`make build` delegates to the Terrabuild executable on `PATH` through `.build/run-terrabuild.sh`, which checks both the process exit code and the JSON completion status. Missing Terrabuild or a failed graph fails the build without falling back to the host .NET SDK. + +GitHub PR and branch-push workflows use the shared `setup-tools` composite action for Terrabuild installation and workspace authentication, then call a shared workflow with a build job, seven parallel test shards, and a result join. The shared workflow holds a concurrency lock keyed by source repository and branch, with in-progress cancellation enabled, so a new push or PR run cancels the active run for the same branch while shards remain parallel. Shards select GitHub-only `test` dependency consumers and restore their prerequisites through Terrabuild. The two Native AOT teapot variants use independent test targets and cache entries. FFI test targets directly consume referenced builds to restore the CLI on fresh runners; the GitHub-only website test target consumes referenced builds and reinstalls Node dependencies before typechecking to recreate launcher symlinks. The local website test target is unchanged. Local Make targets retain their existing execution flow. Runs without cache credentials use local caching. The local `reports` target and GitHub shard consumers restore cached TRX and JUnit files. The join retains shard report paths and fails unless the build and every shard succeed. The build and all shards pass the same repository/run-ID/run-attempt group to Terrabuild, so Insights correlates each workflow attempt as one build group and keeps reruns separate. A shared `collect-build-results` action uploads test files and Terrabuild diagnostics and publishes .NET and Node test summaries even after failures. + +The root `WORKSPACE` and project-local `PROJECT` files define the Terrabuild build/test graph. .NET builds use container-hosted `@dotnet`, JavaScript builds use container-hosted `@npm`, and repository checks use a container-hosted FScript extension. Build-tool versions live in `WORKSPACE` under `locals.versions`. A toolchain phase passes the SDK and Node versions to Docker to create the local image with Clang and zlib headers. The native `@docker build` extension handles image hashing and CI publishing under the `~external` artifact policy. The toolchain includes `WORKSPACE` as a source input so SDK and Node changes invalidate the image. GitHub Actions reads the same version literals through the shared `workspace-versions` action before installing tools. The Docker extension runs on the host during the toolchain phase. GitHub build jobs publish toolchain images to GHCR, and test shards reuse the external-artifact cache and pull images with read-only package access. Explicit Linux platform settings keep ARM64 and AMD64 build/test artifacts in different cache identities. + +Build outputs and test reports are declared per target. Tests depend on their build outputs and use `--no-build --no-restore`; build actions avoid rebuilding project references already represented by graph dependencies. The independent SDK check copies current source to a temporary directory before publishing different product versions, avoiding changes to outputs used by concurrent tests. Native AOT integration tests remain part of the normal graph. Graph execution and local cache reuse need no Insights account; shared caching is configured separately. See [Terrabuild workflow](../guides/terrabuild.md). diff --git a/docs/guides/compile-to-csharp.md b/docs/guides/compile-to-csharp.md index 3c512483..7af5f73e 100644 --- a/docs/guides/compile-to-csharp.md +++ b/docs/guides/compile-to-csharp.md @@ -55,7 +55,7 @@ Manifest assembly paths resolve from the manifest directory. `--config ` c For the teapot sample, build the provider first and use its existing manifest: ```sh -dotnet build samples/ffi/FfiSample.fsproj -c Release +dotnet build samples/ffi/Provider/FfiSample.fsproj -c Release fscript analyze samples/ffi/main.fss fscript compile samples/ffi/main.fss --target csharp --output out/ffi-csharp ``` diff --git a/docs/guides/compile-to-dotnet.md b/docs/guides/compile-to-dotnet.md index 92517711..5833aff5 100644 --- a/docs/guides/compile-to-dotnet.md +++ b/docs/guides/compile-to-dotnet.md @@ -86,13 +86,13 @@ For the teapot example, `samples/ffi/fscript.toml` supplies the provider path: ```toml [runtime] -extern-assemblies = ["bin/Release/net10.0/FScript.Ffi.Sample.dll"] +extern-assemblies = ["Provider/bin/Release/net10.0/FScript.Ffi.Sample.dll"] ``` Build the provider first, then analyze or compile without extra assembly flags: ```sh -dotnet build samples/ffi/FfiSample.fsproj -c Release +dotnet build samples/ffi/Provider/FfiSample.fsproj -c Release dotnet run --project src/FScript -- analyze samples/ffi/main.fss dotnet run --project src/FScript -- compile samples/ffi/main.fss --target dotnet --output out/graphics-dotnet dotnet out/graphics-dotnet/main.dll --headless diff --git a/docs/guides/compiler-cli.md b/docs/guides/compiler-cli.md index 093864be..900762dc 100644 --- a/docs/guides/compiler-cli.md +++ b/docs/guides/compiler-cli.md @@ -24,7 +24,7 @@ For example, `samples/ffi/fscript.toml` contains: ```toml [runtime] -extern-assemblies = ["bin/Release/net10.0/FScript.Ffi.Sample.dll"] +extern-assemblies = ["Provider/bin/Release/net10.0/FScript.Ffi.Sample.dll"] ``` Assembly paths in the manifest resolve from the manifest directory. Local provider projects must be built before either command loads their configured DLLs. For C# and .NET output, compilation automatically references the providers and their colocated managed dependencies; `--reference` adds other managed assemblies when needed. @@ -43,7 +43,7 @@ fscript compile program.fss --target dotnet --output out/program --no-config After building the teapot provider, both commands work with its automatically discovered configuration: ```sh -dotnet build samples/ffi/FfiSample.fsproj -c Release +dotnet build samples/ffi/Provider/FfiSample.fsproj -c Release fscript analyze samples/ffi/main.fss fscript compile samples/ffi/main.fss --target dotnet --output out/ffi dotnet out/ffi/main.dll --headless diff --git a/docs/guides/terrabuild.md b/docs/guides/terrabuild.md new file mode 100644 index 00000000..fb8e1190 --- /dev/null +++ b/docs/guides/terrabuild.md @@ -0,0 +1,78 @@ +# Container-backed builds with Terrabuild + +Install the latest stable Terrabuild release and a working Docker engine. The graph prepares its toolchain image automatically. Trusted GitHub builds publish missing images to GHCR, and test shards pull them. .NET, Node, and Clang are supplied by that image; they need not be installed on the host for these targets. + +```sh +# Build every .NET project, the JavaScript package, extension bundle, and website. +make build TB_FLAGS=--local-only + +# Run all .NET, debugger-adapter, JavaScript, extension, and website type checks. +make tb-test TB_FLAGS=--local-only + +# Run everything, including sample smoke tests and the independent SDK check. +make tb-check TB_FLAGS=--local-only +``` + +`make build` always uses `terrabuild` from `PATH` through the same wrapper as `tb-build`; it fails if Terrabuild is unavailable. There is no host `dotnet build` fallback. For an explicit host build, run `dotnet build FScript.sln -c Release`. + +`tb-test` builds its prerequisites automatically. `tb-smoke` and `tb-sdk` select just those checks and their prerequisites. `config` defaults to Release; Terrabuild defaults to one concurrent task per processor; set `TB_PARALLEL` to override that limit. Existing `test` and release recipes remain available, and targets that depend on `build` now require Terrabuild and Docker. The full test graph currently expects Release because existing CLI integration tests launch Release binaries. + +The underlying command is: + +```sh +terrabuild run build test smoke sdk reports --local-only +``` + +The Make targets check Terrabuild's JSON completion status as well as its process exit code. Use them in automation with Terrabuild 0.200.1, which can return zero after reporting failed tasks. A missing or unsuccessful completion report fails the Make target. The wrapper retains the report as `.terrabuild/result.json` and console output as `.terrabuild/run.log`, including when a task fails. + +## Shared tool versions + +`locals.versions` in `WORKSPACE` defines the .NET SDK, Node, Terrabuild release selector, VS Code packaging tool, and Open VSX publishing tool. Change these quoted literal values in one place. Terrabuild defaults to `latest`. + +The toolchain project passes the SDK and Node versions as Docker build arguments. Terrabuild generates the image hash tag. The toolchain includes `WORKSPACE` as an input so SDK and Node updates invalidate the image; this currently also invalidates it for other workspace edits. The shared `workspace-versions` GitHub action reads the same values before tool installation; release jobs read them from the checked-out release tag. The reader fails on missing, duplicate, or computed version values rather than choosing a fallback. + +## Containers and project layout + +The toolchain phase builds `.build/toolchain/Dockerfile` with the .NET SDK and Node versions from `WORKSPACE`, plus Clang and zlib headers. Only this Docker image preparation runs on the host. `@dotnet`, `@npm`, and the custom `workflow` extension all specify the GHCR toolchain image and an explicit Linux architecture. Images use `ghcr.io/magnusopera/fscript-toolchain` and carry the source repository label. No application deployment occurs. Persistent MSBuild nodes and shared compiler servers are disabled in the image because CLI tests capture subprocess output; retained server processes can keep those pipes open after a command exits. + +Terrabuild stops discovery at a `PROJECT` file. The FFI sample therefore keeps scripts and `fscript.toml` at `samples/ffi`, with sibling `Provider`, `Renderer`, and `Tests` projects below it. Build the provider directly with `dotnet build samples/ffi/Provider/FfiSample.fsproj -c Release` when using the existing host workflow. + +The website includes the root changelog as a build input. The VS Code extension generates its untracked changelog copy in `vscode:prepublish`, so release-note edits do not invalidate extension compilation or tests. + +Project-reference dependencies build before their consumers. Tests run already-built binaries with `--no-build --no-restore`. Tests that launch another project's executable must directly depend on that project's build target, not just their own cached build. FFI test targets consume `target.^build` to restore the CLI. The GitHub-only website `test_ci` target directly consumes `target.^build` for generated JavaScript and runs `npm ci` before typechecking to recreate package launcher symlinks on a fresh runner. Its normal local `test` target is unchanged. Conditional sibling-SDK project references are declared explicitly in the graph; ordinary builds use the published SDK package. Non-.NET checks declare their inputs and upstream builds too. The SDK compatibility check runs in a temporary source copy because it deliberately publishes different assembly versions. + +Builds use the normal `bin`, `obj`, `node_modules`, and generated website directories on the mounted checkout. They contain Linux outputs after a container run. Rebuild through the host toolchain before relying on native host apphosts or platform-specific Node dependencies. Avoid running host and container builds concurrently in the same checkout. + +## Local and shared caching + +The workspace ID is configured in `WORKSPACE`. Local caching works without an Insights account; `--local-only` disables shared caching. Omit `TB_FLAGS=--local-only` after connecting the workspace to allow shared reuse. Do not put tokens or master keys in repository files. + +The PR and branch-push GitHub workflows share `.github/actions/setup-tools` to install the latest stable Terrabuild release and configure authentication. Both call the shared `ci.yml` workflow, which builds once, scatters tests across seven runners, and joins the results. The build job runs `make build`; each test runner requests only its shard's `test` target and lets Terrabuild restore prerequisite outputs from the shared cache. The graph supplies .NET, Node, and the Native AOT toolchain through Docker. Configure repository secrets `TERRABUILD_TOKEN` and `TERRABUILD_MASTER_KEY` to enable shared caching. Without both secrets, including on fork PRs, CI uses local caching and rebuilds prerequisites on each test runner; cache sharing across jobs requires both credentials. The shared `collect-build-results` action collects the latest TRX result from each test output directory and all Node JUnit files into `.out/test-results`, uploads them and publishes both report formats in the GitHub build summary, including after a failed build. Fable, VS Code, and debugger tests retain console output while also writing JUnit XML. A reporting target consumes test outputs so cached reports are restored before collection. Terrabuild diagnostics, its JSON completion report, and captured console output are uploaded as a separate `terrabuild-logs-*` artifact. Its `terrabuild-debug.json` includes performance timings, slowest tasks, the critical chain, and FScript performance data for build optimization. + +Managed build outputs and successful test results are cacheable separately. npm dependencies are kept in the local workspace cache. The toolchain mirrors `matis-next`: a project with the `@docker` initializer and a `dist` target uses `@docker build`, with `~external` artifact policy. Terrabuild generates the hash tag, builds and publishes the image in CI, and caches the external artifact. Test jobs reuse the successful target and Docker pulls the image when a container needs it. The shared `setup-tools` action authenticates Docker with `GITHUB_TOKEN`. Only the build job has package write permission; test jobs have package read permission. Publishing a missing image requires registry write access. Fully cached requests skip execution; the phase barrier does not force application cache hits to rebuild. The configured Linux architecture separates ARM64 and AMD64 artifacts. + +Use `terrabuild explain build test --local-only` to inspect cache decisions. Repeating `make tb-check TB_FLAGS=--local-only` should reuse successful unchanged work. Use `TB_FLAGS='--local-only --force'` to execute deliberately, or `--retry` to rerun a failed cached task after fixing its environment. + +Cached test success is not a fresh execution. Force a run when investigating a flaky test or an external dependency. Deleting generated output and repeating a fully cached request is not a restoration test: Terrabuild restores prerequisite files when an executing consumer needs them. + +`ci-main.yml` runs on pushes to every branch, including names containing `/`, and supports manual dispatch. Tag pushes remain handled by the release workflows. `ci-pr.yml` separately validates pull requests targeting `main`, so a push to an open PR triggers both branch and PR CI. The shared workflow holds one concurrency lock per source repository and branch, with `cancel-in-progress: true`. A new branch, PR, or manual run cancels the active run for the same source branch; separate branches can run concurrently. Test shards remain parallel inside the active workflow. GitHub retains one pending run per lock and replaces an older pending run if another arrives. + +## GitHub test matrix + +The GitHub-only `.build/ci-shards.json` lists a static matrix. `.build/ci/*/PROJECT` defines each shard's dependency selection. The build job validates that the union covers every test project without selecting unrelated tests. Local `make build`, `make test`, and `make tb-check` retain their existing behavior; local full FFI tests still run both Native AOT variants. + +| Shard | Checks | +| --- | --- | +| core | Language, runtime, compiler, conformance, interpreter, language server, and managed FFI tests | +| backend | .NET backend tests | +| cli | CLI tests | +| ffi-default | Native AOT teapot without size optimization | +| ffi-size | Native AOT teapot with size optimization | +| web | Fable, VS Code, and website tests | +| integration | Debugger adapter, all sample smoke tests, and SDK compatibility | + +The FFI categories partition its cases into managed tests and the two Native AOT variants; their results and cache entries are separate. Each shard's executing `test` target consumes just its test outputs, restoring cached reports before artifact upload. Every runner retains its own diagnostics and test artifact. The join downloads the artifacts into separate directories, publishes combined TRX and JUnit summaries, and requires the build and every shard to succeed. A failed shard does not cancel the others. The shared workflow sets `TERRABUILD_GROUP` to `::`; the wrapper passes it explicitly with `--group` for the build and every shard. Each rerun gets a separate group, so Insights shows one build group per workflow attempt. Local commands do not set a group by default. + +The baseline [successful run](https://github.com/MagnusOpera/FScript/actions/runs/42246670574) took 7m34s in Terrabuild. Its trace identified FFI tests at 5m32s, SDK compatibility at 1m59s, and the website build at 1m36s. The matrix separates those workloads and splits the sequential AOT pair. Runner setup, cache transfers, and the build barrier add overhead; compare subsequent GitHub timings and `terrabuild-logs-*` artifacts before claiming a speedup. + +The toolchain image is `ghcr.io/magnusopera/fscript-toolchain:`. `WORKSPACE` configures the Docker image repository and platform defaults, and consumers reference `project.toolchain.version`. There is no custom image preparation script or manually constructed version suffix. The native Docker build disables provenance attestations with `--provenance=false` to publish a single platform manifest. The previous attested index was readable in GHCR, but its referenced image manifest returned `manifest unknown` on fresh runners after successful registry login. diff --git a/samples/ffi/FfiSample.fsproj b/samples/ffi/Provider/FfiSample.fsproj similarity index 77% rename from samples/ffi/FfiSample.fsproj rename to samples/ffi/Provider/FfiSample.fsproj index d59ff0ec..47c789ce 100644 --- a/samples/ffi/FfiSample.fsproj +++ b/samples/ffi/Provider/FfiSample.fsproj @@ -2,10 +2,10 @@ FScript.Ffi.Sampletrue - + - + diff --git a/samples/ffi/Graphics.fs b/samples/ffi/Provider/Graphics.fs similarity index 100% rename from samples/ffi/Graphics.fs rename to samples/ffi/Provider/Graphics.fs diff --git a/samples/ffi/Provider/PROJECT b/samples/ffi/Provider/PROJECT new file mode 100644 index 00000000..924520ef --- /dev/null +++ b/samples/ffi/Provider/PROJECT @@ -0,0 +1,10 @@ +project ffi_sample { + outputs = [ "bin/" "obj/" ] + depends_on = [ project.renderer ] + includes = [ "**/*" "../../../Directory.Build.props" "../*.fss" "../fscript.toml" ] + ignores = [ "**/bin/**" "**/obj/**" "**/TestResults/**" ] +} + +target build { + @dotnet build { dependencies = false } +} diff --git a/samples/ffi/README.md b/samples/ffi/README.md index 9f207277..ec1a113e 100644 --- a/samples/ffi/README.md +++ b/samples/ffi/README.md @@ -7,7 +7,7 @@ FScript builds a Utah teapot from 32 bicubic Bézier patches, computes surface n From the repository root, build the provider library once with the .NET 10 SDK: ```sh -dotnet build samples/ffi/FfiSample.fsproj -c Release +dotnet build samples/ffi/Provider/FfiSample.fsproj -c Release ``` Then use the installed FScript CLI (0.81.5 or later): @@ -77,7 +77,7 @@ On macOS ARM64 with SDK 10.0.403, the original 4,378,616-byte executable shrank 1. Read `scene.fss`: ordinary record literals create the camera, light, material, and rotation. `initialize` generates and uploads a mesh; `render` changes its rotation each frame. 2. Read `teapot.fss`: the control points and patch connectivity are script data. Cubic Bézier evaluation, derivatives, cross products, normalized normals, and indexed tessellation all run in FScript. Change `Teapot.generate 8` in the scene to change the resolution (clamped to 2–32 segments per patch edge). Re-run the host after editing; this sample does not hot reload. -3. Read `Graphics.fs`: attributes expose real F# records as script types. `HostTypes.describe` creates descriptors, and `HostFunctions.create` derives function schemes, arity, and conversions from typed handlers. +3. Read `Provider/Graphics.fs`: attributes expose real F# records as script types. `HostTypes.describe` creates descriptors, and `HostFunctions.create` derives function schemes, arity, and conversions from typed handlers. 4. Read `main.fss`: the entry script calls `Graphics.run` with a setup callback. That callback uploads the mesh and returns a render closure retaining it. The provider pumps native events and invokes the closure until the window closes. Interpreted execution uses `ExternalCallContext.Apply`; compiled execution calls typed delegates. 5. Read `Renderer/`: the C# backend contains shaders, view/projection matrix construction, GPU buffers, and window/input handling. It contains no teapot model or scene choices. diff --git a/samples/ffi/Renderer/PROJECT b/samples/ffi/Renderer/PROJECT new file mode 100644 index 00000000..7eccaf4d --- /dev/null +++ b/samples/ffi/Renderer/PROJECT @@ -0,0 +1,8 @@ +project renderer { + includes = [ "**/*" "../../../Directory.Build.props" ] + @dotnet { } +} + +target build { + @dotnet build { dependencies = false } +} diff --git a/samples/ffi/Tests/FfiSample.Tests.fsproj b/samples/ffi/Tests/FfiSample.Tests.fsproj index cc4fd885..0dd32894 100644 --- a/samples/ffi/Tests/FfiSample.Tests.fsproj +++ b/samples/ffi/Tests/FfiSample.Tests.fsproj @@ -6,5 +6,5 @@ - + diff --git a/samples/ffi/Tests/GraphicsTests.fs b/samples/ffi/Tests/GraphicsTests.fs index 3415ef3b..2cc433de 100644 --- a/samples/ffi/Tests/GraphicsTests.fs +++ b/samples/ffi/Tests/GraphicsTests.fs @@ -180,8 +180,8 @@ Graphics.runHeadless 3 (fun window -> fun seconds -> Graphics.clear window { R = Assert.That(output.GetAwaiter().GetResult(), Does.Contain "1 mesh uploads; 3 frames") else Assert.That(errors.GetAwaiter().GetResult(), Does.Contain "frame limit") - [] - [] + [] + [] member _.``native executable compiles the real teapot with automatic FFI configuration and runs alone``(minimizeSize: bool) = let root = Path.GetFullPath(Path.Combine(__SOURCE_DIRECTORY__, "../../..")) #if DEBUG diff --git a/samples/ffi/Tests/PROJECT b/samples/ffi/Tests/PROJECT new file mode 100644 index 00000000..cf14952a --- /dev/null +++ b/samples/ffi/Tests/PROJECT @@ -0,0 +1,43 @@ +project ffisample_tests { + includes = [ "**/*" "../../../Directory.Build.props" "../../../tests/FScript.Runtime.Tests/SdkTestHost.fs" "../*.fss" ] + ignores = [ "**/bin/**" "**/obj/**" "**/TestResults/**" ] + @dotnet { } +} + +target build { + @dotnet build { dependencies = false } +} + +target test { + depends_on = [ target.^build ] + outputs = [ "TestResults/" ] + @dotnet test { build = false restore = false args = "--results-directory TestResults" } +} + +target test_ffi_managed { + depends_on = [ target.^build ] + outputs = [ "TestResults/managed/" ] + @dotnet test { + build = false restore = false + filter = "TestCategory!=NativeAotDefault&TestCategory!=NativeAotSize" + args = "--results-directory TestResults/managed" + } +} + +target test_ffi_default { + depends_on = [ target.^build ] + outputs = [ "TestResults/aot-default/" ] + @dotnet test { + build = false restore = false filter = "TestCategory=NativeAotDefault" + args = "--results-directory TestResults/aot-default" + } +} + +target test_ffi_size { + depends_on = [ target.^build ] + outputs = [ "TestResults/aot-size/" ] + @dotnet test { + build = false restore = false filter = "TestCategory=NativeAotSize" + args = "--results-directory TestResults/aot-size" + } +} diff --git a/samples/ffi/fscript.toml b/samples/ffi/fscript.toml index e3eb7f6d..b54b603b 100644 --- a/samples/ffi/fscript.toml +++ b/samples/ffi/fscript.toml @@ -1,7 +1,7 @@ -# Build first: dotnet build samples/ffi/FfiSample.fsproj -c Release +# Build first: dotnet build samples/ffi/Provider/FfiSample.fsproj -c Release # Select this configuration in VS Code to get Graphics/Math functions and host types. [runtime] -extern-assemblies = ["bin/Release/net10.0/FScript.Ffi.Sample.dll"] +extern-assemblies = ["Provider/bin/Release/net10.0/FScript.Ffi.Sample.dll"] # fscript compile main.fss uses these defaults; explicit CLI options override them. [compile] diff --git a/samples/task-sharing/PROJECT b/samples/task-sharing/PROJECT new file mode 100644 index 00000000..a615ea5d --- /dev/null +++ b/samples/task-sharing/PROJECT @@ -0,0 +1,10 @@ +project task_sharing { + # Conditional sibling SDK references resolve through NuGet in this workspace. + outputs = [ "bin/" "obj/" ] + includes = [ "**/*" "../../Directory.Build.props" "../../.config/dotnet-tools.json" ] + ignores = [ "**/bin/**" "**/obj/**" "**/TestResults/**" "**/node_modules/**" "**/.fscript/**" ] +} + +target build { + @dotnet build { dependencies = false } +} diff --git a/src/FScript.Backend.CSharp/PROJECT b/src/FScript.Backend.CSharp/PROJECT new file mode 100644 index 00000000..bb0a0d17 --- /dev/null +++ b/src/FScript.Backend.CSharp/PROJECT @@ -0,0 +1,9 @@ +project fscript_backend_csharp { + includes = [ "**/*" "../../Directory.Build.props" "../../.config/dotnet-tools.json" ] + ignores = [ "**/bin/**" "**/obj/**" "**/TestResults/**" "**/node_modules/**" "**/.fscript/**" ] + @dotnet { } +} + +target build { + @dotnet build { dependencies = false } +} diff --git a/src/FScript.Backend.DotNet/PROJECT b/src/FScript.Backend.DotNet/PROJECT new file mode 100644 index 00000000..4a691bf0 --- /dev/null +++ b/src/FScript.Backend.DotNet/PROJECT @@ -0,0 +1,9 @@ +project fscript_backend_dotnet { + includes = [ "**/*" "../../Directory.Build.props" "../../.config/dotnet-tools.json" ] + ignores = [ "**/bin/**" "**/obj/**" "**/TestResults/**" "**/node_modules/**" "**/.fscript/**" ] + @dotnet { } +} + +target build { + @dotnet build { dependencies = false } +} diff --git a/src/FScript.CSharpInterop/PROJECT b/src/FScript.CSharpInterop/PROJECT new file mode 100644 index 00000000..77fd2fc9 --- /dev/null +++ b/src/FScript.CSharpInterop/PROJECT @@ -0,0 +1,9 @@ +project fscript_csharpinterop { + includes = [ "**/*" "../../Directory.Build.props" "../../.config/dotnet-tools.json" ] + ignores = [ "**/bin/**" "**/obj/**" "**/TestResults/**" "**/node_modules/**" "**/.fscript/**" ] + @dotnet { } +} + +target build { + @dotnet build { dependencies = false } +} diff --git a/src/FScript.Compiler/PROJECT b/src/FScript.Compiler/PROJECT new file mode 100644 index 00000000..fc60aae9 --- /dev/null +++ b/src/FScript.Compiler/PROJECT @@ -0,0 +1,9 @@ +project fscript_compiler { + includes = [ "**/*" "../../Directory.Build.props" "../../.config/dotnet-tools.json" ] + ignores = [ "**/bin/**" "**/obj/**" "**/TestResults/**" "**/node_modules/**" "**/.fscript/**" ] + @dotnet { } +} + +target build { + @dotnet build { dependencies = false } +} diff --git a/src/FScript.DebugAdapter/PROJECT b/src/FScript.DebugAdapter/PROJECT new file mode 100644 index 00000000..924e84a4 --- /dev/null +++ b/src/FScript.DebugAdapter/PROJECT @@ -0,0 +1,9 @@ +project fscript_debugadapter { + includes = [ "**/*" "../../Directory.Build.props" "../../.config/dotnet-tools.json" ] + ignores = [ "**/bin/**" "**/obj/**" "**/TestResults/**" "**/node_modules/**" "**/.fscript/**" ] + @dotnet { } +} + +target build { + @dotnet build { dependencies = false } +} diff --git a/src/FScript.Interpreter.Lir/PROJECT b/src/FScript.Interpreter.Lir/PROJECT new file mode 100644 index 00000000..fe0d5fbc --- /dev/null +++ b/src/FScript.Interpreter.Lir/PROJECT @@ -0,0 +1,9 @@ +project fscript_interpreter_lir { + includes = [ "**/*" "../../Directory.Build.props" "../../.config/dotnet-tools.json" ] + ignores = [ "**/bin/**" "**/obj/**" "**/TestResults/**" "**/node_modules/**" "**/.fscript/**" ] + @dotnet { } +} + +target build { + @dotnet build { dependencies = false } +} diff --git a/src/FScript.JavaScript/PROJECT b/src/FScript.JavaScript/PROJECT new file mode 100644 index 00000000..0adf1581 --- /dev/null +++ b/src/FScript.JavaScript/PROJECT @@ -0,0 +1,17 @@ +project javascript { + includes = [ "**/*" "../../Directory.Build.props" "../../.config/dotnet-tools.json" "../../.build/checks.sh" ] + ignores = [ "dist/**" "**/bin/**" "**/obj/**" "**/TestResults/**" "**/node_modules/**" "**/.fscript/**" ] + @dotnet { } +} + +target build { + outputs = [ "bin/" "obj/" "dist/" ] + @dotnet build { dependencies = false } + @dotnet tool { args = "restore" } + @npm build { } +} + +target test { + outputs = [ "TestResults/" ] + workflow run { script = "../../.build/checks.sh" args = "javascript" } +} diff --git a/src/FScript.Language/PROJECT b/src/FScript.Language/PROJECT new file mode 100644 index 00000000..c864a842 --- /dev/null +++ b/src/FScript.Language/PROJECT @@ -0,0 +1,9 @@ +project fscript_language { + includes = [ "**/*" "../../Directory.Build.props" "../../.config/dotnet-tools.json" ] + ignores = [ "**/bin/**" "**/obj/**" "**/TestResults/**" "**/node_modules/**" "**/.fscript/**" ] + @dotnet { } +} + +target build { + @dotnet build { dependencies = false } +} diff --git a/src/FScript.LanguageServer/PROJECT b/src/FScript.LanguageServer/PROJECT new file mode 100644 index 00000000..aca56335 --- /dev/null +++ b/src/FScript.LanguageServer/PROJECT @@ -0,0 +1,9 @@ +project fscript_languageserver { + includes = [ "**/*" "../../Directory.Build.props" "../../.config/dotnet-tools.json" ] + ignores = [ "**/bin/**" "**/obj/**" "**/TestResults/**" "**/node_modules/**" "**/.fscript/**" ] + @dotnet { } +} + +target build { + @dotnet build { dependencies = false } +} diff --git a/src/FScript.Runtime/PROJECT b/src/FScript.Runtime/PROJECT new file mode 100644 index 00000000..d4dab435 --- /dev/null +++ b/src/FScript.Runtime/PROJECT @@ -0,0 +1,11 @@ +project fscript_runtime { + # Conditional sibling SDK references resolve through NuGet in this workspace. + outputs = [ "bin/" "obj/" ] + depends_on = [ project.fscript_language ] + includes = [ "**/*" "../../Directory.Build.props" "../../.config/dotnet-tools.json" ] + ignores = [ "**/bin/**" "**/obj/**" "**/TestResults/**" "**/node_modules/**" "**/.fscript/**" ] +} + +target build { + @dotnet build { dependencies = false } +} diff --git a/src/FScript/PROJECT b/src/FScript/PROJECT new file mode 100644 index 00000000..1e506908 --- /dev/null +++ b/src/FScript/PROJECT @@ -0,0 +1,9 @@ +project fscript { + includes = [ "**/*" "../../Directory.Build.props" "../../.config/dotnet-tools.json" ] + ignores = [ "**/bin/**" "**/obj/**" "**/TestResults/**" "**/node_modules/**" "**/.fscript/**" ] + @dotnet { } +} + +target build { + @dotnet build { dependencies = false } +} diff --git a/tests/FScript.Backend.DotNet.Tests/PROJECT b/tests/FScript.Backend.DotNet.Tests/PROJECT new file mode 100644 index 00000000..e99b3200 --- /dev/null +++ b/tests/FScript.Backend.DotNet.Tests/PROJECT @@ -0,0 +1,14 @@ +project fscript_backend_dotnet_tests { + includes = [ "**/*" "../../Directory.Build.props" "../../.config/dotnet-tools.json" "../../samples/**/*.fss" "../../samples/**/*.toml" "../../samples/**/*.bas" "../../tests/FScript.Runtime.Tests/SdkTestHost.fs" ] + ignores = [ "../../samples/**/bin/**" "../../samples/**/obj/**" "../../samples/**/TestResults/**" "../../samples/**/.fscript/**" "**/bin/**" "**/obj/**" "**/TestResults/**" "**/node_modules/**" "**/.fscript/**" ] + @dotnet { } +} + +target build { + @dotnet build { dependencies = false } +} + +target test { + outputs = [ "TestResults/" ] + @dotnet test { build = false restore = false args = "--results-directory TestResults" } +} diff --git a/tests/FScript.Cli.Tests.Fixtures.Conflict/PROJECT b/tests/FScript.Cli.Tests.Fixtures.Conflict/PROJECT new file mode 100644 index 00000000..fa6563b6 --- /dev/null +++ b/tests/FScript.Cli.Tests.Fixtures.Conflict/PROJECT @@ -0,0 +1,10 @@ +project fscript_cli_tests_fixtures_conflict { + # Conditional sibling SDK references resolve through NuGet in this workspace. + outputs = [ "bin/" "obj/" ] + includes = [ "**/*" "../../Directory.Build.props" "../../.config/dotnet-tools.json" "../../samples/**/*.fss" "../../samples/**/*.toml" "../../samples/**/*.bas" "../../tests/FScript.Runtime.Tests/SdkTestHost.fs" ] + ignores = [ "../../samples/**/bin/**" "../../samples/**/obj/**" "../../samples/**/TestResults/**" "../../samples/**/.fscript/**" "**/bin/**" "**/obj/**" "**/TestResults/**" "**/node_modules/**" "**/.fscript/**" ] +} + +target build { + @dotnet build { dependencies = false } +} diff --git a/tests/FScript.Cli.Tests.Fixtures.Consumer/PROJECT b/tests/FScript.Cli.Tests.Fixtures.Consumer/PROJECT new file mode 100644 index 00000000..deb4e74a --- /dev/null +++ b/tests/FScript.Cli.Tests.Fixtures.Consumer/PROJECT @@ -0,0 +1,9 @@ +project fscript_cli_tests_fixtures_consumer { + includes = [ "**/*" "../../Directory.Build.props" "../../.config/dotnet-tools.json" "../../samples/**/*.fss" "../../samples/**/*.toml" "../../samples/**/*.bas" "../../tests/FScript.Runtime.Tests/SdkTestHost.fs" ] + ignores = [ "../../samples/**/bin/**" "../../samples/**/obj/**" "../../samples/**/TestResults/**" "../../samples/**/.fscript/**" "**/bin/**" "**/obj/**" "**/TestResults/**" "**/node_modules/**" "**/.fscript/**" ] + @dotnet { } +} + +target build { + @dotnet build { dependencies = false } +} diff --git a/tests/FScript.Cli.Tests.Fixtures.Invalid/PROJECT b/tests/FScript.Cli.Tests.Fixtures.Invalid/PROJECT new file mode 100644 index 00000000..b1aa3b51 --- /dev/null +++ b/tests/FScript.Cli.Tests.Fixtures.Invalid/PROJECT @@ -0,0 +1,10 @@ +project fscript_cli_tests_fixtures_invalid { + # Conditional sibling SDK references resolve through NuGet in this workspace. + outputs = [ "bin/" "obj/" ] + includes = [ "**/*" "../../Directory.Build.props" "../../.config/dotnet-tools.json" "../../samples/**/*.fss" "../../samples/**/*.toml" "../../samples/**/*.bas" "../../tests/FScript.Runtime.Tests/SdkTestHost.fs" ] + ignores = [ "../../samples/**/bin/**" "../../samples/**/obj/**" "../../samples/**/TestResults/**" "../../samples/**/.fscript/**" "**/bin/**" "**/obj/**" "**/TestResults/**" "**/node_modules/**" "**/.fscript/**" ] +} + +target build { + @dotnet build { dependencies = false } +} diff --git a/tests/FScript.Cli.Tests.Fixtures.Valid/PROJECT b/tests/FScript.Cli.Tests.Fixtures.Valid/PROJECT new file mode 100644 index 00000000..b5c69f3d --- /dev/null +++ b/tests/FScript.Cli.Tests.Fixtures.Valid/PROJECT @@ -0,0 +1,10 @@ +project fscript_cli_tests_fixtures_valid { + # Conditional sibling SDK references resolve through NuGet in this workspace. + outputs = [ "bin/" "obj/" ] + includes = [ "**/*" "../../Directory.Build.props" "../../.config/dotnet-tools.json" "../../samples/**/*.fss" "../../samples/**/*.toml" "../../samples/**/*.bas" "../../tests/FScript.Runtime.Tests/SdkTestHost.fs" ] + ignores = [ "../../samples/**/bin/**" "../../samples/**/obj/**" "../../samples/**/TestResults/**" "../../samples/**/.fscript/**" "**/bin/**" "**/obj/**" "**/TestResults/**" "**/node_modules/**" "**/.fscript/**" ] +} + +target build { + @dotnet build { dependencies = false } +} diff --git a/tests/FScript.Cli.Tests/PROJECT b/tests/FScript.Cli.Tests/PROJECT new file mode 100644 index 00000000..37bc0a95 --- /dev/null +++ b/tests/FScript.Cli.Tests/PROJECT @@ -0,0 +1,14 @@ +project fscript_cli_tests { + includes = [ "**/*" "../../Directory.Build.props" "../../.config/dotnet-tools.json" "../../samples/**/*.fss" "../../samples/**/*.toml" "../../samples/**/*.bas" "../../tests/FScript.Runtime.Tests/SdkTestHost.fs" ] + ignores = [ "../../samples/**/bin/**" "../../samples/**/obj/**" "../../samples/**/TestResults/**" "../../samples/**/.fscript/**" "**/bin/**" "**/obj/**" "**/TestResults/**" "**/node_modules/**" "**/.fscript/**" ] + @dotnet { } +} + +target build { + @dotnet build { dependencies = false } +} + +target test { + outputs = [ "TestResults/" ] + @dotnet test { build = false restore = false args = "--results-directory TestResults" } +} diff --git a/tests/FScript.Compiler.Tests/PROJECT b/tests/FScript.Compiler.Tests/PROJECT new file mode 100644 index 00000000..76d1b28c --- /dev/null +++ b/tests/FScript.Compiler.Tests/PROJECT @@ -0,0 +1,14 @@ +project fscript_compiler_tests { + includes = [ "**/*" "../../Directory.Build.props" "../../.config/dotnet-tools.json" "../../samples/**/*.fss" "../../samples/**/*.toml" "../../samples/**/*.bas" "../../tests/FScript.Runtime.Tests/SdkTestHost.fs" ] + ignores = [ "../../samples/**/bin/**" "../../samples/**/obj/**" "../../samples/**/TestResults/**" "../../samples/**/.fscript/**" "**/bin/**" "**/obj/**" "**/TestResults/**" "**/node_modules/**" "**/.fscript/**" ] + @dotnet { } +} + +target build { + @dotnet build { dependencies = false } +} + +target test { + outputs = [ "TestResults/" ] + @dotnet test { build = false restore = false args = "--results-directory TestResults" } +} diff --git a/tests/FScript.Conformance.Tests/PROJECT b/tests/FScript.Conformance.Tests/PROJECT new file mode 100644 index 00000000..8e568c2d --- /dev/null +++ b/tests/FScript.Conformance.Tests/PROJECT @@ -0,0 +1,14 @@ +project fscript_conformance_tests { + includes = [ "**/*" "../../Directory.Build.props" "../../.config/dotnet-tools.json" "../../samples/**/*.fss" "../../samples/**/*.toml" "../../samples/**/*.bas" "../../tests/FScript.Runtime.Tests/SdkTestHost.fs" ] + ignores = [ "../../samples/**/bin/**" "../../samples/**/obj/**" "../../samples/**/TestResults/**" "../../samples/**/.fscript/**" "**/bin/**" "**/obj/**" "**/TestResults/**" "**/node_modules/**" "**/.fscript/**" ] + @dotnet { } +} + +target build { + @dotnet build { dependencies = false } +} + +target test { + outputs = [ "TestResults/" ] + @dotnet test { build = false restore = false args = "--results-directory TestResults" } +} diff --git a/tests/FScript.DebugAdapter.Tests/AdapterTests.js b/tests/FScript.DebugAdapter.Tests/AdapterTests.js index 8d234723..683bc18a 100644 --- a/tests/FScript.DebugAdapter.Tests/AdapterTests.js +++ b/tests/FScript.DebugAdapter.Tests/AdapterTests.js @@ -4,6 +4,7 @@ const fs = require('node:fs'); const os = require('node:os'); const path = require('node:path'); const { spawn } = require('node:child_process'); +const { waitForMessage } = require('./EventWait'); const root = path.resolve(__dirname, '../..'); const cli = process.env.FSCRIPT_DEBUG_CLI || path.join(root, 'src/FScript/bin/Release/net10.0/fscript.dll'); @@ -54,13 +55,8 @@ class Client { this.pending.set(seq, { resolve, timer }); }); } - wait(predicate) { - const i = this.messages.findIndex(predicate); - if (i >= 0) return Promise.resolve(this.messages.splice(i,1)[0]); - return new Promise((resolve,reject) => { - const timer = setTimeout(() => reject(new Error(`Timed out waiting for event: ${this.errors}`)),10000); - this.waits.push({ predicate, resolve, timer }); - }); + wait(predicate, options) { + return waitForMessage(this, predicate, options); } async launch(program, extras = {}) { const initialized = await this.request('initialize', { adapterID: 'fscript', supportsRunInTerminalRequest: true, linesStartAt1: true, columnsStartAt1: true }); @@ -148,7 +144,11 @@ test('native teapot callbacks can pause and resume on the CLI main thread', asyn const points = await client.request('setBreakpoints',{source:{path:scene},breakpoints:[{line:23}]}); assert.equal(points.body.breakpoints[0].verified,true); await client.request('configurationDone'); - const stop = await client.wait(message => message.event === 'stopped'); + // The real sample generates the full mesh before entering its render callback. + // Concurrent builds on a CI runner can make that startup exceed ten seconds. + const stop = await client.wait(message => message.event === 'stopped', { + timeoutMs: 60000, label: 'first teapot render breakpoint after mesh generation' + }); const stack = await client.request('stackTrace',{threadId:stop.body.threadId}); assert.equal(stack.body.stackFrames[0].name,'render'); const scopes = await client.request('scopes',{frameId:stack.body.stackFrames[0].id}); @@ -159,7 +159,7 @@ test('native teapot callbacks can pause and resume on the CLI main thread', asyn assert.ok(values.body.variables.some(value => value.name === 'seconds')); await client.request('setBreakpoints',{source:{path:scene},breakpoints:[]}); await client.request('continue',{threadId:stop.body.threadId,singleThread:true}); - await client.wait(message => message.event === 'terminated'); + await client.wait(message => message.event === 'terminated', { label: 'teapot termination after continue' }); }); test('DAP zero-based source positions are normalized without changing runtime locations', async t => { diff --git a/tests/FScript.DebugAdapter.Tests/EventWait.js b/tests/FScript.DebugAdapter.Tests/EventWait.js new file mode 100644 index 00000000..dae7c0c3 --- /dev/null +++ b/tests/FScript.DebugAdapter.Tests/EventWait.js @@ -0,0 +1,16 @@ +function waitForMessage(client, predicate, { timeoutMs = 10000, label = 'matching event' } = {}) { + const index = client.messages.findIndex(predicate); + if (index >= 0) return Promise.resolve(client.messages.splice(index, 1)[0]); + return new Promise((resolve, reject) => { + const wait = { predicate, resolve }; + wait.timer = setTimeout(() => { + const index = client.waits.indexOf(wait); + if (index >= 0) client.waits.splice(index, 1); + const received = client.messages.map(message => message.event || message.command || message.type).join(', '); + reject(new Error(`Timed out after ${timeoutMs}ms waiting for ${label}. Received: ${received || 'none'}. Stderr: ${client.errors}`)); + }, timeoutMs); + client.waits.push(wait); + }); +} + +module.exports = { waitForMessage }; diff --git a/tests/FScript.DebugAdapter.Tests/EventWaitTests.js b/tests/FScript.DebugAdapter.Tests/EventWaitTests.js new file mode 100644 index 00000000..181e38ea --- /dev/null +++ b/tests/FScript.DebugAdapter.Tests/EventWaitTests.js @@ -0,0 +1,39 @@ +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const { waitForMessage } = require('./EventWait'); + +test('expensive startup may use a longer event deadline without losing a delayed breakpoint', async () => { + const client = { messages: [], waits: [], errors: '' }; + const stopped = { event: 'stopped' }; + let timer; + try { + const waiting = waitForMessage(client, message => message.event === 'stopped', { timeoutMs: 1000, label: 'first render breakpoint' }); + timer = setTimeout(() => { + const wait = client.waits.shift(); + clearTimeout(wait.timer); + wait.resolve(stopped); + }, 30); + assert.equal(await waiting, stopped); + assert.equal(client.waits.length, 0); + } finally { + clearTimeout(timer); + for (const wait of client.waits) clearTimeout(wait.timer); + } +}); + +test('an expired event wait is removed and identifies the missing event', async () => { + const client = { messages: [{ event: 'output' }], waits: [], errors: 'child diagnostic' }; + await assert.rejects( + waitForMessage(client, message => message.event === 'stopped', { timeoutMs: 5, label: 'first render breakpoint' }), + /waiting for first render breakpoint\. Received: output\. Stderr: child diagnostic/ + ); + assert.equal(client.waits.length, 0); +}); + +test('an already buffered event is consumed without creating a timer', async () => { + const stopped = { event: 'stopped' }; + const client = { messages: [stopped], waits: [], errors: '' }; + assert.equal(await waitForMessage(client, message => message.event === 'stopped'), stopped); + assert.equal(client.messages.length, 0); + assert.equal(client.waits.length, 0); +}); diff --git a/tests/FScript.DebugAdapter.Tests/PROJECT b/tests/FScript.DebugAdapter.Tests/PROJECT new file mode 100644 index 00000000..ad9dbd10 --- /dev/null +++ b/tests/FScript.DebugAdapter.Tests/PROJECT @@ -0,0 +1,26 @@ +project checks { + depends_on = [ project.fscript project.ffi_sample project.task_sharing project.fscript_cli_tests_fixtures_valid ] + includes = [ "**/*" "../../.build/checks.sh" "../../.build/sdk-check.sh" + "../../.github/scripts/verify-sdk-provider.sh" + "../../samples/**/*.fss" "../../samples/**/*.toml" "../../samples/**/*.bas" ] + ignores = [ "../../samples/**/bin/**" "../../samples/**/obj/**" + "../../samples/**/TestResults/**" "../../samples/**/.fscript/**" + "**/bin/**" "**/obj/**" "**/TestResults/**" + "**/node_modules/**" "**/.fscript/**" ] +} + +target test { + depends_on = [ target.^build ] + outputs = [ "TestResults/debugger.xml" ] + workflow run { script = "../../.build/checks.sh" args = "debugger" } +} + +target smoke { + outputs = [] + workflow run { script = "../../.build/checks.sh" args = "smoke" } +} + +target sdk { + outputs = [] + workflow run { script = "../../.build/sdk-check.sh" } +} diff --git a/tests/FScript.Interpreter.Lir.Tests/PROJECT b/tests/FScript.Interpreter.Lir.Tests/PROJECT new file mode 100644 index 00000000..e91efa69 --- /dev/null +++ b/tests/FScript.Interpreter.Lir.Tests/PROJECT @@ -0,0 +1,14 @@ +project fscript_interpreter_lir_tests { + includes = [ "**/*" "../../Directory.Build.props" "../../.config/dotnet-tools.json" "../../samples/**/*.fss" "../../samples/**/*.toml" "../../samples/**/*.bas" "../../tests/FScript.Runtime.Tests/SdkTestHost.fs" ] + ignores = [ "../../samples/**/bin/**" "../../samples/**/obj/**" "../../samples/**/TestResults/**" "../../samples/**/.fscript/**" "**/bin/**" "**/obj/**" "**/TestResults/**" "**/node_modules/**" "**/.fscript/**" ] + @dotnet { } +} + +target build { + @dotnet build { dependencies = false } +} + +target test { + outputs = [ "TestResults/" ] + @dotnet test { build = false restore = false args = "--results-directory TestResults" } +} diff --git a/tests/FScript.Language.Tests/PROJECT b/tests/FScript.Language.Tests/PROJECT new file mode 100644 index 00000000..ecc4fa2b --- /dev/null +++ b/tests/FScript.Language.Tests/PROJECT @@ -0,0 +1,14 @@ +project fscript_language_tests { + includes = [ "**/*" "../../Directory.Build.props" "../../.config/dotnet-tools.json" "../../samples/**/*.fss" "../../samples/**/*.toml" "../../samples/**/*.bas" "../../tests/FScript.Runtime.Tests/SdkTestHost.fs" ] + ignores = [ "../../samples/**/bin/**" "../../samples/**/obj/**" "../../samples/**/TestResults/**" "../../samples/**/.fscript/**" "**/bin/**" "**/obj/**" "**/TestResults/**" "**/node_modules/**" "**/.fscript/**" ] + @dotnet { } +} + +target build { + @dotnet build { dependencies = false } +} + +target test { + outputs = [ "TestResults/" ] + @dotnet test { build = false restore = false args = "--results-directory TestResults" } +} diff --git a/tests/FScript.LanguageServer.Tests/PROJECT b/tests/FScript.LanguageServer.Tests/PROJECT new file mode 100644 index 00000000..e9eeb797 --- /dev/null +++ b/tests/FScript.LanguageServer.Tests/PROJECT @@ -0,0 +1,14 @@ +project fscript_languageserver_tests { + includes = [ "**/*" "../../Directory.Build.props" "../../.config/dotnet-tools.json" "../../samples/**/*.fss" "../../samples/**/*.toml" "../../samples/**/*.bas" "../../tests/FScript.Runtime.Tests/SdkTestHost.fs" ] + ignores = [ "../../samples/**/bin/**" "../../samples/**/obj/**" "../../samples/**/TestResults/**" "../../samples/**/.fscript/**" "**/bin/**" "**/obj/**" "**/TestResults/**" "**/node_modules/**" "**/.fscript/**" ] + @dotnet { } +} + +target build { + @dotnet build { dependencies = false } +} + +target test { + outputs = [ "TestResults/" ] + @dotnet test { build = false restore = false args = "--results-directory TestResults" } +} diff --git a/tests/FScript.Runtime.Tests/PROJECT b/tests/FScript.Runtime.Tests/PROJECT new file mode 100644 index 00000000..c7d43a51 --- /dev/null +++ b/tests/FScript.Runtime.Tests/PROJECT @@ -0,0 +1,14 @@ +project fscript_runtime_tests { + includes = [ "**/*" "../../Directory.Build.props" "../../.config/dotnet-tools.json" "../../samples/**/*.fss" "../../samples/**/*.toml" "../../samples/**/*.bas" "../../tests/FScript.Runtime.Tests/SdkTestHost.fs" ] + ignores = [ "../../samples/**/bin/**" "../../samples/**/obj/**" "../../samples/**/TestResults/**" "../../samples/**/.fscript/**" "**/bin/**" "**/obj/**" "**/TestResults/**" "**/node_modules/**" "**/.fscript/**" ] + @dotnet { } +} + +target build { + @dotnet build { dependencies = false } +} + +target test { + outputs = [ "TestResults/" ] + @dotnet test { build = false restore = false args = "--results-directory TestResults" } +} diff --git a/vscode-fscript/CHANGELOG.md b/vscode-fscript/CHANGELOG.md deleted file mode 120000 index 04c99a55..00000000 --- a/vscode-fscript/CHANGELOG.md +++ /dev/null @@ -1 +0,0 @@ -../CHANGELOG.md \ No newline at end of file diff --git a/vscode-fscript/PROJECT b/vscode-fscript/PROJECT new file mode 100644 index 00000000..4c81192f --- /dev/null +++ b/vscode-fscript/PROJECT @@ -0,0 +1,20 @@ +project vscode { + includes = [ "**/*" "../.build/checks.sh" "../tests/FScript.LanguageServer.Tests/**/*.js" ] + ignores = [ "node_modules/**" "dist/**" "server/**" "*.vsix" "TestResults/**" "CHANGELOG.md" ] + @npm { } +} + +target install { + outputs = [ "node_modules/" ] + @npm install { } +} + +target build { + outputs = [ "dist/" ] + @npm run { target = "compile" } +} + +target test { + outputs = [ "TestResults/" ] + workflow run { script = "../.build/checks.sh" args = "vscode" } +} diff --git a/vscode-fscript/package.json b/vscode-fscript/package.json index 461822ab..66287ffe 100644 --- a/vscode-fscript/package.json +++ b/vscode-fscript/package.json @@ -257,7 +257,7 @@ "test": "node --test ../tests/FScript.LanguageServer.Tests/*Tests.js", "lint": "echo \"No lint configured\"", "compile": "esbuild extension.js --bundle --platform=node --format=cjs --target=node20 --outfile=dist/extension.js --external:vscode", - "vscode:prepublish": "npm run compile", + "vscode:prepublish": "node -e \"require('node:fs').copyFileSync('../CHANGELOG.md', 'CHANGELOG.md')\" && npm run compile", "package": "vsce package", "postinstall": "npm run compile" }, diff --git a/website/PROJECT b/website/PROJECT new file mode 100644 index 00000000..6b4be808 --- /dev/null +++ b/website/PROJECT @@ -0,0 +1,29 @@ +project website { + depends_on = [ project.javascript ] + includes = [ "**/*" "../.config/dotnet-tools.json" "../CHANGELOG.md" ] + ignores = [ "node_modules/**" "build/**" ".docusaurus/**" "src/generated/**" ] + @npm { } +} + +target install { + outputs = [ "node_modules/" ] + @npm install { } +} + +target build { + outputs = [ "build/" "src/generated/" ] + @npm build { } +} + +target test { + outputs = [] + @npm run { target = "typecheck" } +} + +# A restored node_modules tree may not retain package launcher symlinks. +# Recreate it on the fresh CI runner before invoking the compiler. +target test_ci { + outputs = [] + @npm install { } + @npm run { target = "typecheck" } +} diff --git a/website/docs/compiler/compile-to-csharp.md b/website/docs/compiler/compile-to-csharp.md index 5d9aa082..1c640711 100644 --- a/website/docs/compiler/compile-to-csharp.md +++ b/website/docs/compiler/compile-to-csharp.md @@ -59,7 +59,7 @@ Manifest assembly paths resolve from the manifest directory. `--config ` c For the teapot sample, build the provider first and use its existing manifest: ```sh -dotnet build samples/ffi/FfiSample.fsproj -c Release +dotnet build samples/ffi/Provider/FfiSample.fsproj -c Release fscript analyze samples/ffi/main.fss fscript compile samples/ffi/main.fss --target csharp --output out/ffi-csharp ``` diff --git a/website/docs/compiler/compile-to-dotnet.md b/website/docs/compiler/compile-to-dotnet.md index 65439e27..a615f644 100644 --- a/website/docs/compiler/compile-to-dotnet.md +++ b/website/docs/compiler/compile-to-dotnet.md @@ -143,13 +143,13 @@ For the teapot example, `samples/ffi/fscript.toml` supplies the provider path: ```toml [runtime] -extern-assemblies = ["bin/Release/net10.0/FScript.Ffi.Sample.dll"] +extern-assemblies = ["Provider/bin/Release/net10.0/FScript.Ffi.Sample.dll"] ``` Build the provider first, then analyze or compile without extra assembly flags: ```sh -dotnet build samples/ffi/FfiSample.fsproj -c Release +dotnet build samples/ffi/Provider/FfiSample.fsproj -c Release dotnet run --project src/FScript -- analyze samples/ffi/main.fss dotnet run --project src/FScript -- compile samples/ffi/main.fss --target dotnet --no-aot --output out/graphics-dotnet dotnet out/graphics-dotnet/main.dll --headless diff --git a/website/docs/examples/ffi-graphics.md b/website/docs/examples/ffi-graphics.md index 15a2f9ad..0cec4ffa 100644 --- a/website/docs/examples/ffi-graphics.md +++ b/website/docs/examples/ffi-graphics.md @@ -25,7 +25,7 @@ Execution starts in `main.fss`, which loads the scene and starts rendering throu Check out the [FScript repository](https://github.com/MagnusOpera/FScript) and build the provider library with the .NET 10 SDK before the first run: ```sh -dotnet build samples/ffi/FfiSample.fsproj -c Release +dotnet build samples/ffi/Provider/FfiSample.fsproj -c Release ``` This compiles the FFI library used by `fscript`. Rebuild it when changing provider code; script edits only require running the `fscript` command again. Version 0.81.3 includes the macOS signing entitlement required to load GLFW. @@ -77,7 +77,7 @@ All sample code lives under [`samples/ffi`](https://github.com/MagnusOpera/FScri | --- | --- | | `scene.fss` | Chooses scene data, uploads the generated mesh, and animates it. | | `teapot.fss` | Holds the Bézier patch data and generates vertices, normals, and triangle indices. | -| `Graphics.fs` | Declares the script-visible types and typed FFI functions. | +| `Provider/Graphics.fs` | Declares the script-visible types and typed FFI functions. | | `main.fss` | Starts the window loop and returns a render closure that retains the mesh. | | `Renderer/` | Implements the native window, GPU buffers, shaders, and rendering. | | `fscript.toml` | Makes the provider available to CLI and editor analysis. | @@ -176,7 +176,7 @@ The sample supplies `Math.toFloat`, `Math.sqrt`, and `Math.sin` as typed extern ## 3. Expose data and resources as host types -`Graphics.fs` uses attributes on real F# types. Here are two representative definitions: +`Provider/Graphics.fs` uses attributes on real F# types. Here are two representative definitions: ```fsharp [] @@ -212,7 +212,7 @@ A `Window` descriptor instead has `HostManaged = true` and only a visible `Title ## 4. Turn typed handlers into function descriptors -The upload handler has the concrete F# type `Window -> Geometry -> Mesh`. It validates geometry, uploads it through `window.Backend`, and returns a mesh retaining that backend and its resource handle. The complete implementation is in `Graphics.fs`. +The upload handler has the concrete F# type `Window -> Geometry -> Mesh`. It validates geometry, uploads it through `window.Backend`, and returns a mesh retaining that backend and its resource handle. The complete implementation is in `Provider/Graphics.fs`. Register it with a registration that already contains the referenced type descriptors: @@ -249,7 +249,7 @@ Build the sample in Release, then run **FScript: Select Configuration** in VS Co ```toml [runtime] -extern-assemblies = ["bin/Release/net10.0/FScript.Ffi.Sample.dll"] +extern-assemblies = ["Provider/bin/Release/net10.0/FScript.Ffi.Sample.dll"] ``` The path is relative to the manifest. In a trusted workspace, the editor loads the provider's descriptors and schemes for completion, hover, and diagnostics. Provider discovery creates no window or GPU resources. If several manifests exist in the workspace, select this one explicitly.