From a7adc4e33d85e4f93ac3cb2941ab9f049d4acfa9 Mon Sep 17 00:00:00 2001 From: Ryan Melton Date: Sun, 13 Sep 2026 11:29:32 -0600 Subject: [PATCH 1/7] Add plugin unit tests, and playwright workflows. Add options rubygems push to release workflow --- .../workflows/plugin-playwright-reusable.yml | 253 ++++++++++++++++++ .../workflows/plugin-unit-tests-reusable.yml | 210 +++++++++++++++ .../release-cosmos-plugin-reusable.yml | 34 +++ playwright-plugin/.gitignore | 4 + playwright-plugin/package-lock.json | 74 +++++ playwright-plugin/package.json | 12 + playwright-plugin/playwright.config.ts | 49 ++++ .../tests/01-install-plugin.spec.ts | 113 ++++++++ .../tests/02-tool-console.spec.ts | 145 ++++++++++ playwright-plugin/tests/auth.setup.ts | 84 ++++++ .../plugin-playwright.properties.json | 11 + workflow-templates/plugin-playwright.yml | 60 +++++ .../plugin-unit-tests.properties.json | 11 + workflow-templates/plugin-unit-tests.yml | 32 +++ workflow-templates/release-cosmos-plugin.yml | 9 + workflow-templates/release-cosmos-tool.yml | 9 + 16 files changed, 1110 insertions(+) create mode 100644 .github/workflows/plugin-playwright-reusable.yml create mode 100644 .github/workflows/plugin-unit-tests-reusable.yml create mode 100644 playwright-plugin/.gitignore create mode 100644 playwright-plugin/package-lock.json create mode 100644 playwright-plugin/package.json create mode 100644 playwright-plugin/playwright.config.ts create mode 100644 playwright-plugin/tests/01-install-plugin.spec.ts create mode 100644 playwright-plugin/tests/02-tool-console.spec.ts create mode 100644 playwright-plugin/tests/auth.setup.ts create mode 100644 workflow-templates/plugin-playwright.properties.json create mode 100644 workflow-templates/plugin-playwright.yml create mode 100644 workflow-templates/plugin-unit-tests.properties.json create mode 100644 workflow-templates/plugin-unit-tests.yml diff --git a/.github/workflows/plugin-playwright-reusable.yml b/.github/workflows/plugin-playwright-reusable.yml new file mode 100644 index 0000000..a258556 --- /dev/null +++ b/.github/workflows/plugin-playwright-reusable.yml @@ -0,0 +1,253 @@ +# Builds a COSMOS plugin and installs it into a real COSMOS with Playwright. +# +# Starts the most recent COSMOS Core release in Docker using the cosmos-project +# compose stack, builds the plugin gem from the calling repo, then drives the +# Admin tool the way an operator would: Install From File, submit the variables +# dialog, and wait for the install process to report Complete. This catches the +# failures unit tests can't - a plugin.txt that doesn't parse, a target that +# doesn't build, a microservice that won't start. +# +# Plugins that register a COSMOS tool additionally get each tool page opened and +# checked for browser console errors. That is discovered from the COSMOS API +# after install, so plugins without a tool skip it with nothing to configure. +# +# The Playwright harness lives in this repo under playwright-plugin/, so the +# calling plugin needs no test files of its own. +# +# Third party actions are pinned to a full commit SHA, because a tag can be moved +# to point at different code. The comment after each pin records the tag it was. + +name: Plugin Playwright (Reusable) + +on: + workflow_call: + inputs: + plugin_directory: + description: 'Directory containing the plugin (e.g., openc3-cosmos-demo-plugin)' + required: false + type: string + default: '.' + version: + description: 'Version to build the plugin gem with' + required: false + type: string + default: '1.0.0' + cosmos_version: + description: 'COSMOS version to run, or latest to resolve the newest release' + required: false + type: string + default: 'latest' + expected_targets: + description: 'Space separated target names the plugin should define, checked after install' + required: false + type: string + default: '' + install_demo: + description: 'Install the COSMOS demo, which plugins that reference INST targets need' + required: false + type: boolean + default: false + is_tool: + description: 'Whether this is a tool (requires frontend build)' + required: false + type: boolean + default: false + console_ignore: + description: 'Newline separated regexes for browser console errors to allow on the tool pages' + required: false + type: string + default: '' + ruby_version: + description: 'Ruby version used to build the gem' + required: false + type: string + default: '3.4' + workflows_ref: + description: 'Ref of OpenC3/.github to take the Playwright harness from' + required: false + type: string + default: 'main' + +permissions: + contents: read + +defaults: + run: + shell: bash + +jobs: + playwright: + name: Install plugin in COSMOS + runs-on: ubuntu-latest + timeout-minutes: 45 + steps: + - name: Checkout plugin + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + # The harness lives alongside this workflow rather than in every plugin + - name: Checkout Playwright harness + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: OpenC3/.github + ref: ${{ inputs.workflows_ref }} + path: .openc3-shared + + - name: Resolve COSMOS version + id: cosmos + env: + GH_TOKEN: ${{ github.token }} + REQUESTED: ${{ inputs.cosmos_version }} + run: | + set -euo pipefail + if [ "$REQUESTED" = "latest" ]; then + # Release tags are v7.4.0, the image tag is 7.4.0 + tag=$(gh api repos/OpenC3/cosmos/releases/latest --jq .tag_name) + version="${tag#v}" + else + version="${REQUESTED#v}" + fi + if [ -z "$version" ]; then + echo "::error::Could not resolve a COSMOS version" + exit 1 + fi + echo "version=$version" >> "$GITHUB_OUTPUT" + echo "Running COSMOS $version" + echo "COSMOS version: \`$version\`" >> "$GITHUB_STEP_SUMMARY" + + - name: Checkout cosmos-project + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: OpenC3/cosmos-project + path: .cosmos + + - name: Configure the COSMOS stack + working-directory: .cosmos + env: + COSMOS_VERSION: ${{ steps.cosmos.outputs.version }} + INSTALL_DEMO: ${{ inputs.install_demo }} + run: | + set -euo pipefail + # .env ships the defaults; .env.local is loaded after it and wins, so + # the pinned upstream file stays untouched + demo=0 + if [ "$INSTALL_DEMO" = "true" ]; then demo=1; fi + cat > .env.local <> "$GITHUB_OUTPUT" + echo "Built \`$(basename "$gem_path")\`" >> "$GITHUB_STEP_SUMMARY" + + - name: Start COSMOS + working-directory: .cosmos + run: ./openc3.sh run + + - name: Wait for COSMOS to come up + run: | + set -euo pipefail + # The Playwright setup project waits for the tools to register; this + # only waits for the front door, and fails fast on a crash loop + for attempt in $(seq 1 60); do + if curl -fsS -o /dev/null http://localhost:2900; then + echo "COSMOS is answering after ${attempt} attempts" + exit 0 + fi + if docker ps --format '{{.Names}} {{.Status}}' | grep -q Restarting; then + echo "::error::A COSMOS container is restarting" + docker ps + exit 1 + fi + sleep 10 + done + echo "::error::COSMOS did not answer on http://localhost:2900" + docker ps + exit 1 + + - name: Install Playwright + working-directory: .openc3-shared/playwright-plugin + run: | + set -euo pipefail + npm install --no-audit --no-fund + npx playwright install --with-deps chromium + + - name: Install the plugin with Playwright + working-directory: .openc3-shared/playwright-plugin + env: + CI: true + PLUGIN_GEM: ${{ steps.gem.outputs.path }} + EXPECTED_TARGETS: ${{ inputs.expected_targets }} + # Tool pages are only checked when the plugin registers a tool, which + # the spec discovers from the COSMOS API after install + IS_TOOL: ${{ inputs.is_tool }} + CONSOLE_IGNORE: ${{ inputs.console_ignore }} + run: npx playwright test --project=chromium + + - name: COSMOS logs + if: ${{ failure() }} + working-directory: .cosmos + run: | + docker ps -a + for service in openc3-cosmos-init openc3-operator openc3-cosmos-cmd-tlm-api openc3-cosmos-script-runner-api; do + echo "::group::$service" + docker compose logs --tail 200 "$service" || true + echo "::endgroup::" + done + + - name: Upload Playwright artifacts + if: ${{ !cancelled() }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: plugin-playwright + path: .openc3-shared/playwright-plugin/test-results + retention-days: 30 + if-no-files-found: ignore + + - name: Stop COSMOS + if: ${{ always() }} + working-directory: .cosmos + run: ./openc3.sh stop || true diff --git a/.github/workflows/plugin-unit-tests-reusable.yml b/.github/workflows/plugin-unit-tests-reusable.yml new file mode 100644 index 0000000..8cf4182 --- /dev/null +++ b/.github/workflows/plugin-unit-tests-reusable.yml @@ -0,0 +1,210 @@ +# Generic unit tests for COSMOS plugins. +# +# Runs Python tests found in tests/ or test/ and Ruby specs found in spec/ or +# specs/. A plugin with only one kind of test skips the other job, and a plugin +# with neither passes with a warning unless require-tests is set. +# +# Dependencies are picked up automatically: requirements.txt for the plugin's own +# imports, /requirements.txt for test only ones, and a Gemfile for Ruby +# when there is one, otherwise the openc3 and rspec gems. There is nothing to +# configure per plugin unless it lives in a subdirectory of a monorepo, in which +# case set plugin_directory. +# +# Third party actions are pinned to a full commit SHA, because a tag can be moved +# to point at different code. The comment after each pin records the tag it was. + +name: Plugin Unit Tests (Reusable) + +on: + workflow_call: + inputs: + plugin_directory: + description: 'Directory containing the plugin (e.g., openc3-cosmos-demo-plugin)' + required: false + type: string + default: '.' + python_version: + description: 'Python version used for the Python tests' + required: false + type: string + default: '3.12' + ruby_version: + description: 'Ruby version used for the Ruby specs' + required: false + type: string + default: '3.4' + require_tests: + description: 'Fail when the plugin has no tests at all, rather than passing quietly' + required: false + type: boolean + default: false + +permissions: + contents: read + +defaults: + run: + shell: bash + +jobs: + # Work out which kinds of tests this plugin actually has, so the language jobs + # below skip rather than fail on a plugin which has none of that kind. + detect: + name: Detect tests + runs-on: ubuntu-latest + outputs: + python-dir: ${{ steps.detect.outputs.python-dir }} + ruby-dir: ${{ steps.detect.outputs.ruby-dir }} + steps: + - name: Checkout code + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Validate plugin directory exists + run: | + if [ ! -d "${{ inputs.plugin_directory }}" ]; then + echo "Error: Plugin directory '${{ inputs.plugin_directory }}' does not exist" + exit 1 + fi + + - name: Detect test directories + id: detect + working-directory: ${{ inputs.plugin_directory }} + env: + REQUIRE_TESTS: ${{ inputs.require_tests }} + run: | + set -euo pipefail + + # A directory only counts when it actually holds test files, so an empty + # or unrelated tests/ directory doesn't make the job fail with no tests + first_dir_containing() { + local pattern=$1 + shift + for candidate in "$@"; do + if [ -d "$candidate" ] && [ -n "$(find "$candidate" -name "$pattern" -print -quit)" ]; then + echo "$candidate" + return + fi + done + } + + python_dir=$(first_dir_containing 'test_*.py' tests test) + if [ -z "$python_dir" ]; then + python_dir=$(first_dir_containing '*_test.py' tests test) + fi + ruby_dir=$(first_dir_containing '*_spec.rb' spec specs) + + echo "python-dir=$python_dir" >> "$GITHUB_OUTPUT" + echo "ruby-dir=$ruby_dir" >> "$GITHUB_OUTPUT" + + { + echo "## Detected tests" + echo "- Python: ${python_dir:-none}" + echo "- Ruby: ${ruby_dir:-none}" + } >> "$GITHUB_STEP_SUMMARY" + + if [ -z "$python_dir" ] && [ -z "$ruby_dir" ]; then + if [ "${REQUIRE_TESTS:-false}" = "true" ]; then + echo "::error::No tests found in tests/, test/, spec/ or specs/" + exit 1 + fi + echo "::warning::No tests found in tests/, test/, spec/ or specs/" + fi + + python: + name: Python tests + needs: detect + if: needs.detect.outputs.python-dir != '' + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Install uv + uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0 + with: + enable-cache: true + python-version: ${{ inputs.python_version }} + + - name: Create virtual environment + working-directory: ${{ inputs.plugin_directory }} + run: uv venv .venv + + - name: Install dependencies + working-directory: ${{ inputs.plugin_directory }} + env: + TEST_DIR: ${{ needs.detect.outputs.python-dir }} + run: | + set -euo pipefail + uv pip install --python .venv/bin/python pytest coverage openc3 + # The plugin's own runtime dependencies, which the code under test imports + if [ -f requirements.txt ]; then + uv pip install --python .venv/bin/python -r requirements.txt + fi + # Dependencies needed only by the tests + if [ -f "$TEST_DIR/requirements.txt" ]; then + uv pip install --python .venv/bin/python -r "$TEST_DIR/requirements.txt" + fi + + - name: Run tests + working-directory: ${{ inputs.plugin_directory }} + env: + TEST_DIR: ${{ needs.detect.outputs.python-dir }} + run: .venv/bin/python -m coverage run -m pytest "$TEST_DIR" -v + + - name: Coverage + # Report coverage even when a test failed, but don't add a second, more + # confusing failure when the run never got far enough to record any + if: ${{ !cancelled() }} + working-directory: ${{ inputs.plugin_directory }} + run: | + set -euo pipefail + if [ ! -f .coverage ]; then + echo "No coverage data recorded" >> "$GITHUB_STEP_SUMMARY" + exit 0 + fi + { + echo "## Python coverage" + echo '```' + .venv/bin/python -m coverage report + echo '```' + } >> "$GITHUB_STEP_SUMMARY" + + ruby: + name: Ruby specs + needs: detect + if: needs.detect.outputs.ruby-dir != '' + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Set up Ruby + uses: ruby/setup-ruby@95ef2b042f9d7a56d8268cba8559e2842e2ad01b # v1.321.0 + with: + ruby-version: ${{ inputs.ruby_version }} + working-directory: ${{ inputs.plugin_directory }} + # Caching bundler needs a lockfile to key on, and most plugins have no + # Gemfile at all, in which case the gems are installed directly below + bundler-cache: ${{ hashFiles(format('{0}/Gemfile.lock', inputs.plugin_directory)) != '' }} + + - name: Install dependencies + working-directory: ${{ inputs.plugin_directory }} + run: | + set -euo pipefail + if [ -f Gemfile ]; then + bundle install --jobs 4 --retry 3 + else + gem install --no-document rspec openc3 + fi + + - name: Run specs + working-directory: ${{ inputs.plugin_directory }} + env: + SPEC_DIR: ${{ needs.detect.outputs.ruby-dir }} + run: | + set -euo pipefail + if [ -f Gemfile ]; then + bundle exec rspec "$SPEC_DIR" --format documentation + else + rspec "$SPEC_DIR" --format documentation + fi diff --git a/.github/workflows/release-cosmos-plugin-reusable.yml b/.github/workflows/release-cosmos-plugin-reusable.yml index c45a330..37989a7 100644 --- a/.github/workflows/release-cosmos-plugin-reusable.yml +++ b/.github/workflows/release-cosmos-plugin-reusable.yml @@ -12,6 +12,11 @@ on: required: false type: boolean default: true + deploy_to_rubygems: + description: 'Publish the gem to RubyGems (requires the RUBYGEMS_API_KEY secret)' + required: false + type: boolean + default: false plugin_directory: description: 'Directory containing the plugin (e.g., openc3-cosmos-demo-plugin)' required: false @@ -30,6 +35,9 @@ on: secrets: OPENC3_API_TOKEN: required: true + RUBYGEMS_API_KEY: + description: 'RubyGems API key, only needed when deploy_to_rubygems is true' + required: false jobs: release: @@ -53,6 +61,21 @@ jobs: exit 1 fi + # Checked up front rather than at the push step, which runs after the tag + # has been pushed and the App Store upload has already happened + - name: Validate RubyGems credentials + if: ${{ inputs.deploy_to_rubygems }} + env: + GEM_HOST_API_KEY: ${{ secrets.RUBYGEMS_API_KEY }} + run: | + if [ -z "$GEM_HOST_API_KEY" ]; then + echo "Error: deploy_to_rubygems is true but the RUBYGEMS_API_KEY secret is empty" + echo "Add the key to the repo secrets and pass it through, e.g." + echo " secrets:" + echo " RUBYGEMS_API_KEY: \${{ secrets.RUBYGEMS_API_KEY }}" + exit 1 + fi + - name: Validate package.json exists if: ${{ inputs.is_tool }} run: | @@ -169,6 +192,17 @@ jobs: echo "Plugin version ${{ inputs.version }} successfully uploaded to OpenC3 App Store!" + # gem push reads the credential from GEM_HOST_API_KEY, so nothing is + # written to ~/.gem/credentials on the runner + - name: Publish to RubyGems + if: ${{ inputs.deploy_to_rubygems }} + working-directory: ${{ inputs.plugin_directory }} + env: + GEM_HOST_API_KEY: ${{ secrets.RUBYGEMS_API_KEY }} + run: | + gem push "${{ steps.gem_file.outputs.gem_filename }}" + echo "Plugin version ${{ inputs.version }} successfully pushed to RubyGems!" + - name: Create GitHub Release id: create_release uses: actions/create-release@v1 diff --git a/playwright-plugin/.gitignore b/playwright-plugin/.gitignore new file mode 100644 index 0000000..682cece --- /dev/null +++ b/playwright-plugin/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +test-results/ +playwright-report/ +storageState.json diff --git a/playwright-plugin/package-lock.json b/playwright-plugin/package-lock.json new file mode 100644 index 0000000..95d8f3b --- /dev/null +++ b/playwright-plugin/package-lock.json @@ -0,0 +1,74 @@ +{ + "name": "openc3-plugin-playwright", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "openc3-plugin-playwright", + "version": "1.0.0", + "dependencies": { + "@playwright/test": "1.62.1" + } + }, + "node_modules/@playwright/test": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.1.tgz", + "integrity": "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==", + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.62.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/playwright": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz", + "integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==", + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.62.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz", + "integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==", + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + } + } +} diff --git a/playwright-plugin/package.json b/playwright-plugin/package.json new file mode 100644 index 0000000..17e44d9 --- /dev/null +++ b/playwright-plugin/package.json @@ -0,0 +1,12 @@ +{ + "name": "openc3-plugin-playwright", + "version": "1.0.0", + "private": true, + "description": "Installs a COSMOS plugin into a running COSMOS and verifies it came up", + "scripts": { + "test": "playwright test --project=chromium" + }, + "dependencies": { + "@playwright/test": "1.62.1" + } +} diff --git a/playwright-plugin/playwright.config.ts b/playwright-plugin/playwright.config.ts new file mode 100644 index 0000000..c8e03f9 --- /dev/null +++ b/playwright-plugin/playwright.config.ts @@ -0,0 +1,49 @@ +import { defineConfig, devices } from '@playwright/test' +import path from 'path' + +export const STORAGE_STATE = path.join(__dirname, 'storageState.json') + +export default defineConfig({ + testDir: './tests', + // Installing a plugin builds and deploys microservices, which on a cold CI + // runner is minutes rather than seconds + timeout: 10 * 60 * 1000, + expect: { + timeout: 30 * 1000, + }, + globalTimeout: 30 * 60 * 1000, + forbidOnly: !!process.env.CI, + // COSMOS and the browser share one runner, so a starved event loop can freeze + // a page long enough to time out an action. One retry rides that out. + retries: process.env.CI ? 1 : 0, + // The install spec mutates global COSMOS state, so never run specs in parallel. + // Specs are numbered because the later ones need the plugin the first one + // installed, and Playwright runs files in discovery order. + workers: 1, + fullyParallel: false, + reporter: process.env.CI ? [['github'], ['list']] : [['list']], + use: { + actionTimeout: 60 * 1000, + baseURL: process.env.COSMOS_URL || 'http://localhost:2900', + trace: 'retain-on-failure', + screenshot: 'only-on-failure', + video: 'retain-on-failure', + viewport: { width: 1600, height: 1200 }, + }, + projects: [ + { + name: 'setup', + testMatch: /auth\.setup\.ts/, + }, + { + name: 'chromium', + testIgnore: /auth\.setup\.ts/, + dependencies: ['setup'], + use: { + ...devices['Desktop Chrome'], + storageState: STORAGE_STATE, + viewport: { width: 1600, height: 1200 }, + }, + }, + ], +}) diff --git a/playwright-plugin/tests/01-install-plugin.spec.ts b/playwright-plugin/tests/01-install-plugin.spec.ts new file mode 100644 index 0000000..29669b8 --- /dev/null +++ b/playwright-plugin/tests/01-install-plugin.spec.ts @@ -0,0 +1,113 @@ +/* +# Copyright 2026 OpenC3, Inc. +# All Rights Reserved. +# +# This file is licensed under the MIT license. +# See LICENSE.md file in the project root for details. +*/ + +// Installs the plugin gem built by the workflow through the COSMOS Admin tool, +// the same way an operator would, and verifies the install ran to completion. +// +// PLUGIN_GEM path to the .gem file to install (required) +// EXPECTED_TARGETS space separated target names the plugin should define (optional) + +import { test, expect } from '@playwright/test' +import * as fs from 'fs' +import * as path from 'path' + +const gemPath = process.env.PLUGIN_GEM || '' +const gem = path.basename(gemPath) +// openc3-cosmos-kayhan-1.0.0.gem -> openc3-cosmos-kayhan +const pluginName = gem.replace(/-\d[^-]*\.gem$/, '') +const expectedTargets = (process.env.EXPECTED_TARGETS || '') + .split(/\s+/) + .filter(Boolean) + +// The gem filename goes into a RegExp below, and plugin names are full of dots +// and dashes +function escapeRegExp(value: string) { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +} + +const escapedGem = escapeRegExp(gem) + +// Installing builds and deploys the plugin's microservices, which on a cold +// runner pulling images is minutes rather than seconds +const INSTALL_TIMEOUT = 10 * 60 * 1000 + +test.beforeAll(() => { + expect(gemPath, 'PLUGIN_GEM must be set').toBeTruthy() + expect(fs.existsSync(gemPath), `${gemPath} does not exist`).toBe(true) +}) + +test('installs the plugin', async ({ page }) => { + await page.goto('/tools/admin/plugins') + await expect(page.locator('.v-app-bar')).toContainText('Administrator') + + // waitForEvent must be set up before the click that opens the chooser + const [fileChooser] = await Promise.all([ + page.waitForEvent('filechooser'), + page.getByRole('button', { name: 'Install From File' }).click(), + ]) + await fileChooser.setFiles(gemPath) + + // Every plugin gets the variables dialog, even with no VARIABLEs to set. + // Submitting takes the defaults from plugin.txt. + await expect(page.locator('.v-dialog:has-text("Variables")')).toBeVisible() + await page.locator('data-test=edit-submit').click() + + await expect(page.locator('[data-test=plugin-alert]')).toContainText( + 'Started installing', + ) + + // Wait for the install process to report Complete. Deliberately a positive + // assertion: the process list is only rendered once there are processes, so + // asserting the absence of a Running row can pass before the install has even + // been queued. A first install reports the bare gem name, a re-install + // appends __. + const complete = new RegExp( + `Processing plugin_install: ${escapedGem}(__\\S+)? - Complete`, + ) + await expect(page.locator('[data-test=process-list]')).toContainText( + complete, + { timeout: INSTALL_TIMEOUT }, + ) + + // A failed install still leaves a process row, so confirm the plugin is + // actually listed rather than trusting the process output alone + await expect( + page.locator('[data-test=plugin-list-item]').filter({ hasText: pluginName }), + ).toHaveCount(1) + + for (const target of expectedTargets) { + await expect( + page + .locator('[data-test=plugin-list-item]') + .filter({ hasText: pluginName }), + ).toContainText(target) + } +}) + +test('loaded the plugin', async ({ page }) => { + await page.goto('/tools/admin/plugins') + await expect(page.locator('.v-app-bar')).toContainText('Administrator') + + // The show-output button only renders once the process is no longer Running, + // which the previous test already waited for + await page + .locator('[data-test=process-list]') + .locator('.v-list-item') + .filter({ hasText: pluginName }) + .locator('[data-test=show-output]') + .first() + .click() + + await expect(page.getByRole('dialog')).toContainText('Process Output') + // openc3cli logs "Loading new plugin: ", and the path is not always + // just the filename, so don't anchor the gem name to the colon + await expect(page.getByRole('dialog')).toContainText( + new RegExp(`Loading new plugin: .*${escapedGem}`), + ) + await page.getByRole('button', { name: 'Ok' }).click() +}) diff --git a/playwright-plugin/tests/02-tool-console.spec.ts b/playwright-plugin/tests/02-tool-console.spec.ts new file mode 100644 index 0000000..f776028 --- /dev/null +++ b/playwright-plugin/tests/02-tool-console.spec.ts @@ -0,0 +1,145 @@ +/* +# Copyright 2026 OpenC3, Inc. +# All Rights Reserved. +# +# This file is licensed under the MIT license. +# See LICENSE.md file in the project root for details. +*/ + +// For plugins that ship a COSMOS tool, opens each tool page the plugin added and +// fails if the browser logged any error. A tool that installs cleanly can still +// be broken on load - a bad import, a missing asset, a Vue render error - and +// none of that shows up in the install process output. +// +// Plugins with no tools skip this automatically: the tool list comes from the +// COSMOS API after install, so nothing has to be declared twice. +// +// PLUGIN_GEM path to the installed .gem (required, set by the workflow) +// IS_TOOL 'true' when the plugin is expected to register a tool +// CONSOLE_IGNORE newline separated regexes for console errors to allow + +import { test, expect, Page } from '@playwright/test' +import * as path from 'path' + +const gem = path.basename(process.env.PLUGIN_GEM || '') +const pluginName = gem.replace(/-\d[^-]*\.gem$/, '') +const isTool = process.env.IS_TOOL === 'true' + +// Errors that say nothing about the plugin. Deliberately short: every entry +// here is a class of real breakage this test can no longer see. +const DEFAULT_IGNORES = [ + // Fired by the browser when a ResizeObserver callback is still running at the + // next paint. Chromium reports it as an error, it is not one, and any Vuetify + // layout can trip it. + /ResizeObserver loop/, + // The tool pages do not ship their own favicon + /favicon\.ico/, +] + +const ignores = [ + ...DEFAULT_IGNORES, + ...(process.env.CONSOLE_IGNORE || '') + .split('\n') + .map((line) => line.trim()) + .filter(Boolean) + .map((pattern) => new RegExp(pattern)), +] + +function ignored(message: string) { + return ignores.some((pattern) => pattern.test(message)) +} + +interface Tool { + name: string + folder_name: string + url: string | null + window: string + plugin: string | null + shown: boolean +} + +// COSMOS authenticates with a localStorage token rather than a cookie, so the +// request has to be made from inside the page +async function fetchTools(page: Page): Promise { + const result = await page.evaluate(async () => { + const response = await fetch('/openc3-api/tools?scope=DEFAULT', { + headers: { Authorization: localStorage.openc3Token }, + }) + if (!response.ok) return { error: `${response.status}` } + return { data: await response.json() } + }) + expect(result.error, 'could not read /openc3-api/tools').toBeUndefined() + // The API returns a hash of folder_name => tool + const data = result.data as Record | Tool[] + return Array.isArray(data) ? data : Object.values(data) +} + +// Collect everything the browser complained about while the page was open +function watchForErrors(page: Page, problems: string[]) { + page.on('console', (message) => { + if (message.type() !== 'error') return + const text = message.text() + if (!ignored(text)) problems.push(`console.error: ${text}`) + }) + page.on('pageerror', (error) => { + const text = error.message + if (!ignored(text)) problems.push(`uncaught: ${text}`) + }) +} + +test('tool pages load without console errors', async ({ page, context }) => { + // Any COSMOS page, just to get an authenticated origin to query from + await page.goto('/tools/admin/plugins') + await expect(page.locator('.v-app-bar')).toContainText('Administrator') + + const all = await fetchTools(page) + const mine = all.filter((tool) => (tool.plugin || '').includes(pluginName)) + + // IFRAME and NEW tools point somewhere else, so their console says nothing + // about this plugin. INLINE tools are the ones COSMOS itself renders. + const checkable = mine.filter( + (tool) => tool.window === 'INLINE' && tool.url && tool.url.startsWith('/'), + ) + + if (mine.length === 0) { + // A plugin that declared a tool but registered none is broken, not skippable + expect( + isTool, + `is_tool is set but ${pluginName} registered no tools`, + ).toBe(false) + test.skip(true, `${pluginName} ships no COSMOS tools`) + return + } + + test.skip( + checkable.length === 0, + `${pluginName} ships only IFRAME/NEW tools, which render outside COSMOS`, + ) + + const failures: string[] = [] + for (const tool of checkable) { + await test.step(`${tool.name} (${tool.url})`, async () => { + const problems: string[] = [] + const toolPage = await context.newPage() + watchForErrors(toolPage, problems) + + await toolPage.goto(tool.url!, { waitUntil: 'networkidle' }) + + // tool-base renders a catch all 404 for a tool that never registered with + // single-spa, which is quiet in the console. Confirm the tool actually + // rendered before believing a clean console means anything. + await expect(toolPage.locator('.v-app-bar')).toContainText(tool.name) + + // Let deferred work (chunk loads, first data fetch) report itself + await toolPage.waitForTimeout(5000) + await toolPage.close() + + if (problems.length) { + failures.push(`${tool.name} (${tool.url}):\n ${problems.join('\n ')}`) + } + }) + } + + // Report every tool's problems at once rather than only the first + expect(failures.join('\n'), 'browser errors on tool pages').toBe('') +}) diff --git a/playwright-plugin/tests/auth.setup.ts b/playwright-plugin/tests/auth.setup.ts new file mode 100644 index 0000000..4fbbe7c --- /dev/null +++ b/playwright-plugin/tests/auth.setup.ts @@ -0,0 +1,84 @@ +/* +# Copyright 2026 OpenC3, Inc. +# All Rights Reserved. +# +# This file is licensed under the MIT license. +# See LICENSE.md file in the project root for details. +*/ + +// Signs in to a freshly started COSMOS Core and saves the session for the specs. +// +// Login.vue asks /openc3-api/auth/token-exists on load. A container that has +// never been used has no password, so it renders the "set a password" form +// (New Password + Confirm Password + Set), which is the CI case. A container +// that already has one renders a single Password field and a Login button. The +// password field carries data-test=new-password either way, so key off whether +// the confirm field is present rather than off the labels, which change text +// between the two states. + +import { test as setup, expect, Page } from '@playwright/test' +import { STORAGE_STATE } from '../playwright.config' + +const PASSWORD = process.env.COSMOS_PASSWORD || 'password' + +// init.sh installs the tools one at a time, so the admin tool being present in +// the import map is the signal that COSMOS is far enough along to drive. A tool +// that isn't in the map is never registered with single-spa and tool-base +// renders its 404 instead, which a spec only sees as a missing app bar. +async function waitForAdminTool(page: Page) { + await expect + .poll( + async () => { + try { + const response = await page.request.get('/openc3-api/map.json') + if (!response.ok()) return false + const imports = (await response.json()).imports || {} + return '@openc3/tool-admin' in imports + } catch { + return false + } + }, + { + message: 'waiting for @openc3/tool-admin in the import map', + timeout: 12 * 60 * 1000, + intervals: [5000], + }, + ) + .toBe(true) +} + +setup('sign in', async ({ page }) => { + setup.setTimeout(20 * 60 * 1000) + + await waitForAdminTool(page) + + await page.goto('/tools/cmdtlmserver') + + // The password form is rendered by tool-base once it decides we're not + // authenticated, which can lag the initial load + await expect(page.locator('[data-test=new-password]')).toBeVisible({ + timeout: 60 * 1000, + }) + await page.locator('[data-test=new-password]').fill(PASSWORD) + + if (await page.locator('[data-test=confirm-password]').isVisible()) { + // First run, COSMOS wants the password created + await page.locator('[data-test=confirm-password]').fill(PASSWORD) + await page.locator('[data-test=set-password]').click() + } else { + // A password already exists, e.g. re-running against a stack still up + await page.locator('button:has-text("Login")').click() + } + + // Confirm the session actually works rather than trusting the form submit + await expect(page.locator('.v-app-bar')).toContainText('CmdTlmServer', { + timeout: 2 * 60 * 1000, + }) + + if (await page.getByText('Clock out of sync').isVisible()) { + await page.locator("text=Don't show this again").click() + await page.locator('button:has-text("Dismiss")').click() + } + + await page.context().storageState({ path: STORAGE_STATE }) +}) diff --git a/workflow-templates/plugin-playwright.properties.json b/workflow-templates/plugin-playwright.properties.json new file mode 100644 index 0000000..414eb23 --- /dev/null +++ b/workflow-templates/plugin-playwright.properties.json @@ -0,0 +1,11 @@ +{ + "name": "Plugin Playwright", + "description": "Runs the latest COSMOS Core release in Docker, builds the plugin, and installs it through the Admin tool with Playwright.", + "iconName": "octicon container", + "categories": [ + "Testing" + ], + "filePatterns": [ + ".*\\.gemspec$" + ] +} diff --git a/workflow-templates/plugin-playwright.yml b/workflow-templates/plugin-playwright.yml new file mode 100644 index 0000000..248bdfe --- /dev/null +++ b/workflow-templates/plugin-playwright.yml @@ -0,0 +1,60 @@ +# Builds the plugin and installs it into a real COSMOS with Playwright. +# Copy this file to .github/workflows/ in the plugin repo and: +# 1. Update the workflow name if necessary (if you're in cosmos-enterprise-plugins and have multiple plugins) +# 2. Update the workflow filename if necessary +# 3. Update plugin_directory argument. This should be "." for single repos, and e.g. "openc3-cosmos-orbitview" for cosmos-enterprise-plugins. +# 4. Set expected_targets to the target names your plugin defines +# 5. Set install_demo: true if your plugin's defaults reference the demo targets (INST, INST2) +# +# Runs the most recent COSMOS Core release in Docker, so it catches what unit +# tests can't: a plugin.txt that doesn't parse, a target that doesn't build, a +# microservice that won't start. Plugins that register a COSMOS tool also get +# each tool page opened and checked for browser console errors. Runs on every +# commit and once a week, the weekly run being what catches a new COSMOS +# release breaking the plugin. +# +# GitHub disables scheduled workflows in a repo with no activity for 60 days. + +name: Plugin Playwright + +on: + push: + branches: [main] + pull_request: + branches: ["**"] + # Weekly against whatever COSMOS release is current, so a new COSMOS that + # breaks the plugin surfaces even when nothing here has changed. Off the hour + # because scheduled runs at :00 get queued behind everyone else's. + schedule: + - cron: '27 7 * * 1' + workflow_dispatch: + inputs: + cosmos_version: + description: 'COSMOS version to run, or latest' + required: false + type: string + default: 'latest' + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + # Superseding a pull request run is fine, but a scheduled run and a push to + # main share a ref, so cancelling on those would have them kill each other + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + playwright: + uses: OpenC3/.github/.github/workflows/plugin-playwright-reusable.yml@main + with: + plugin_directory: . # Update this + expected_targets: MY_TARGET # Update this + cosmos_version: ${{ inputs.cosmos_version || 'latest' }} + # install_demo: true # Uncomment if the plugin's defaults reference INST/INST2 + # is_tool: true # Uncomment for tool plugins that need a frontend build + # Tool pages are opened and checked for browser console errors automatically + # when the plugin registers a tool. Add regexes here for errors to allow: + # console_ignore: | + # some third party gripe + # another known message diff --git a/workflow-templates/plugin-unit-tests.properties.json b/workflow-templates/plugin-unit-tests.properties.json new file mode 100644 index 0000000..148b5ce --- /dev/null +++ b/workflow-templates/plugin-unit-tests.properties.json @@ -0,0 +1,11 @@ +{ + "name": "Plugin Unit Tests", + "description": "Runs Python tests in tests/ or test/ and Ruby specs in spec/ or specs/ on every push and pull request.", + "iconName": "octicon beaker", + "categories": [ + "Testing" + ], + "filePatterns": [ + ".*\\.gemspec$" + ] +} diff --git a/workflow-templates/plugin-unit-tests.yml b/workflow-templates/plugin-unit-tests.yml new file mode 100644 index 0000000..b764d50 --- /dev/null +++ b/workflow-templates/plugin-unit-tests.yml @@ -0,0 +1,32 @@ +# Runs a plugin's unit tests on every push and pull request. +# Copy this file to .github/workflows/ in the plugin repo and: +# 1. Update the workflow name if necessary (if you're in cosmos-enterprise-plugins and have multiple plugins) +# 2. Update the workflow filename if necessary +# 3. Update plugin_directory argument. This should be "." for single repos, and e.g. "openc3-cosmos-orbitview" for cosmos-enterprise-plugins. +# +# Python tests are discovered in tests/ or test/, Ruby specs in spec/ or specs/. +# Whichever the plugin doesn't have is skipped, so there is nothing else to set. + +name: Plugin Unit Tests + +on: + push: + branches: [main] + pull_request: + branches: ["**"] + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + unit-tests: + uses: OpenC3/.github/.github/workflows/plugin-unit-tests-reusable.yml@main + with: + plugin_directory: . # Update this + # python_version: '3.12' # Uncomment and update if needed + # ruby_version: '3.4' # Uncomment and update if needed + # require_tests: true # Uncomment to fail the build when the plugin has no tests diff --git a/workflow-templates/release-cosmos-plugin.yml b/workflow-templates/release-cosmos-plugin.yml index c672bff..7f3d70b 100644 --- a/workflow-templates/release-cosmos-plugin.yml +++ b/workflow-templates/release-cosmos-plugin.yml @@ -4,6 +4,8 @@ # 2. Update the workflow filename if necessary # 3. Update plugin_directory argument. This should be "." for single repos, and e.g. "openc3-cosmos-orbitview" for cosmos-enterprise-plugins. # 4. Add the OpenC3 admin accont API token to the repo secrets as OPENC3_API_TOKEN +# 5. To publish to RubyGems, add your RubyGems API key to the repo secrets as +# RUBYGEMS_API_KEY and check 'Publish the gem to RubyGems' when you run this name: Release COSMOS Plugin @@ -19,6 +21,11 @@ on: required: false type: boolean default: true + deploy_to_rubygems: + description: 'Publish the gem to RubyGems' + required: false + type: boolean + default: false jobs: release: @@ -26,6 +33,8 @@ jobs: with: version: ${{ inputs.version }} deploy_to_store: ${{ inputs.deploy_to_store }} + deploy_to_rubygems: ${{ inputs.deploy_to_rubygems }} plugin_directory: openc3-cosmos-foo-plugin # Update this secrets: OPENC3_API_TOKEN: ${{ secrets.OPENC3_API_TOKEN }} + RUBYGEMS_API_KEY: ${{ secrets.RUBYGEMS_API_KEY }} diff --git a/workflow-templates/release-cosmos-tool.yml b/workflow-templates/release-cosmos-tool.yml index 6e04233..d3aaefd 100644 --- a/workflow-templates/release-cosmos-tool.yml +++ b/workflow-templates/release-cosmos-tool.yml @@ -4,6 +4,8 @@ # 2. Update the workflow filename if necessary # 3. Update plugin_directory argument. This should be "." for single repos, and e.g. "openc3-cosmos-orbitview" for cosmos-enterprise-plugins. # 4. Add the OpenC3 admin accont API token to the repo secrets as OPENC3_API_TOKEN +# 5. To publish to RubyGems, add your RubyGems API key to the repo secrets as +# RUBYGEMS_API_KEY and check 'Publish the gem to RubyGems' when you run this # 5. Make sure pnpm-workspace.yaml has the `packages` field defined name: Release COSMOS Tool @@ -20,6 +22,11 @@ on: required: false type: boolean default: true + deploy_to_rubygems: + description: 'Publish the gem to RubyGems' + required: false + type: boolean + default: false jobs: release: @@ -27,7 +34,9 @@ jobs: with: version: ${{ inputs.version }} deploy_to_store: ${{ inputs.deploy_to_store }} + deploy_to_rubygems: ${{ inputs.deploy_to_rubygems }} plugin_directory: openc3-cosmos-tool-foo # Update this is_tool: true secrets: OPENC3_API_TOKEN: ${{ secrets.OPENC3_API_TOKEN }} + RUBYGEMS_API_KEY: ${{ secrets.RUBYGEMS_API_KEY }} From 80637f5a5442beb53c3998d51b7fca66825e854d Mon Sep 17 00:00:00 2001 From: Ryan Melton Date: Sun, 13 Sep 2026 11:38:25 -0600 Subject: [PATCH 2/7] Handle building plugins with tools --- .../workflows/plugin-playwright-reusable.yml | 9 ++++++++- .../release-cosmos-plugin-reusable.yml | 4 +++- playwright-plugin/playwright.config.ts | 17 +++++++++++++---- playwright-plugin/tests/02-tool-console.spec.ts | 4 ++-- 4 files changed, 26 insertions(+), 8 deletions(-) diff --git a/.github/workflows/plugin-playwright-reusable.yml b/.github/workflows/plugin-playwright-reusable.yml index a258556..a8a7ff1 100644 --- a/.github/workflows/plugin-playwright-reusable.yml +++ b/.github/workflows/plugin-playwright-reusable.yml @@ -150,12 +150,19 @@ jobs: - name: Install pnpm if: ${{ inputs.is_tool }} uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0 + with: + package_json_file: ${{ inputs.plugin_directory }}/package.json - name: Install frontend dependencies if: ${{ inputs.is_tool }} working-directory: ${{ inputs.plugin_directory }} run: pnpm install --frozen-lockfile + - name: Build frontend + if: ${{ inputs.is_tool }} + working-directory: ${{ inputs.plugin_directory }} + run: pnpm run build + # openc3cli is what `rake build` shells out to for validation - name: Install openc3 gem run: gem install --no-document openc3 @@ -212,7 +219,7 @@ jobs: working-directory: .openc3-shared/playwright-plugin run: | set -euo pipefail - npm install --no-audit --no-fund + npm ci --no-audit --no-fund npx playwright install --with-deps chromium - name: Install the plugin with Playwright diff --git a/.github/workflows/release-cosmos-plugin-reusable.yml b/.github/workflows/release-cosmos-plugin-reusable.yml index 37989a7..ae816c4 100644 --- a/.github/workflows/release-cosmos-plugin-reusable.yml +++ b/.github/workflows/release-cosmos-plugin-reusable.yml @@ -72,7 +72,7 @@ jobs: echo "Error: deploy_to_rubygems is true but the RUBYGEMS_API_KEY secret is empty" echo "Add the key to the repo secrets and pass it through, e.g." echo " secrets:" - echo " RUBYGEMS_API_KEY: \${{ secrets.RUBYGEMS_API_KEY }}" + echo ' RUBYGEMS_API_KEY: $''{{ secrets.RUBYGEMS_API_KEY }}' exit 1 fi @@ -118,6 +118,8 @@ jobs: - name: Install pnpm if: ${{ inputs.is_tool }} uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 #v5.0.0 + with: + package_json_file: ${{ inputs.plugin_directory }}/package.json - name: Install frontend dependencies if: ${{ inputs.is_tool }} diff --git a/playwright-plugin/playwright.config.ts b/playwright-plugin/playwright.config.ts index c8e03f9..2ad5c78 100644 --- a/playwright-plugin/playwright.config.ts +++ b/playwright-plugin/playwright.config.ts @@ -17,8 +17,7 @@ export default defineConfig({ // a page long enough to time out an action. One retry rides that out. retries: process.env.CI ? 1 : 0, // The install spec mutates global COSMOS state, so never run specs in parallel. - // Specs are numbered because the later ones need the plugin the first one - // installed, and Playwright runs files in discovery order. + // Project dependencies ensure tool checks only run after a successful install. workers: 1, fullyParallel: false, reporter: process.env.CI ? [['github'], ['list']] : [['list']], @@ -36,8 +35,8 @@ export default defineConfig({ testMatch: /auth\.setup\.ts/, }, { - name: 'chromium', - testIgnore: /auth\.setup\.ts/, + name: 'install', + testMatch: /01-install-plugin\.spec\.ts/, dependencies: ['setup'], use: { ...devices['Desktop Chrome'], @@ -45,5 +44,15 @@ export default defineConfig({ viewport: { width: 1600, height: 1200 }, }, }, + { + name: 'chromium', + testMatch: /02-tool-console\.spec\.ts/, + dependencies: ['install'], + use: { + ...devices['Desktop Chrome'], + storageState: STORAGE_STATE, + viewport: { width: 1600, height: 1200 }, + }, + }, ], }) diff --git a/playwright-plugin/tests/02-tool-console.spec.ts b/playwright-plugin/tests/02-tool-console.spec.ts index f776028..edaa4ad 100644 --- a/playwright-plugin/tests/02-tool-console.spec.ts +++ b/playwright-plugin/tests/02-tool-console.spec.ts @@ -62,13 +62,13 @@ interface Tool { // request has to be made from inside the page async function fetchTools(page: Page): Promise { const result = await page.evaluate(async () => { - const response = await fetch('/openc3-api/tools?scope=DEFAULT', { + const response = await fetch('/openc3-api/tools/all?scope=DEFAULT', { headers: { Authorization: localStorage.openc3Token }, }) if (!response.ok) return { error: `${response.status}` } return { data: await response.json() } }) - expect(result.error, 'could not read /openc3-api/tools').toBeUndefined() + expect(result.error, 'could not read /openc3-api/tools/all').toBeUndefined() // The API returns a hash of folder_name => tool const data = result.data as Record | Tool[] return Array.isArray(data) ? data : Object.values(data) From f36dbb0449dcaf9a2b512facbdf806ff456da37c Mon Sep 17 00:00:00 2001 From: Ryan Melton Date: Sun, 13 Sep 2026 11:40:36 -0600 Subject: [PATCH 3/7] Support plugins with just widgets but not a tool --- .github/workflows/plugin-playwright-reusable.yml | 11 ++++++++--- workflow-templates/plugin-playwright.yml | 1 + 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/.github/workflows/plugin-playwright-reusable.yml b/.github/workflows/plugin-playwright-reusable.yml index a8a7ff1..0a57be0 100644 --- a/.github/workflows/plugin-playwright-reusable.yml +++ b/.github/workflows/plugin-playwright-reusable.yml @@ -52,6 +52,11 @@ on: required: false type: boolean default: false + build_frontend: + description: 'Build frontend assets for plugins such as widgets; tool plugins build automatically' + required: false + type: boolean + default: false console_ignore: description: 'Newline separated regexes for browser console errors to allow on the tool pages' required: false @@ -148,18 +153,18 @@ jobs: node-version: 24 - name: Install pnpm - if: ${{ inputs.is_tool }} + if: ${{ inputs.is_tool || inputs.build_frontend }} uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0 with: package_json_file: ${{ inputs.plugin_directory }}/package.json - name: Install frontend dependencies - if: ${{ inputs.is_tool }} + if: ${{ inputs.is_tool || inputs.build_frontend }} working-directory: ${{ inputs.plugin_directory }} run: pnpm install --frozen-lockfile - name: Build frontend - if: ${{ inputs.is_tool }} + if: ${{ inputs.is_tool || inputs.build_frontend }} working-directory: ${{ inputs.plugin_directory }} run: pnpm run build diff --git a/workflow-templates/plugin-playwright.yml b/workflow-templates/plugin-playwright.yml index 248bdfe..f0a3cc0 100644 --- a/workflow-templates/plugin-playwright.yml +++ b/workflow-templates/plugin-playwright.yml @@ -53,6 +53,7 @@ jobs: cosmos_version: ${{ inputs.cosmos_version || 'latest' }} # install_demo: true # Uncomment if the plugin's defaults reference INST/INST2 # is_tool: true # Uncomment for tool plugins that need a frontend build + # build_frontend: true # Uncomment for widget-only plugins that need a frontend build # Tool pages are opened and checked for browser console errors automatically # when the plugin registers a tool. Add regexes here for errors to allow: # console_ignore: | From 313d1faa50c226780977ec7036c41380058ccc98 Mon Sep 17 00:00:00 2001 From: Ryan Melton Date: Sun, 13 Sep 2026 12:11:16 -0600 Subject: [PATCH 4/7] More AI review feedback --- .../workflows/plugin-playwright-reusable.yml | 46 +++++++++++++------ .../workflows/plugin-unit-tests-reusable.yml | 5 +- .../release-cosmos-plugin-reusable.yml | 18 +++++++- playwright-plugin/playwright.config.ts | 4 +- .../tests/01-install-plugin.spec.ts | 34 ++++++++++---- .../tests/02-tool-console.spec.ts | 31 ++++++++++--- playwright-plugin/tests/auth.setup.ts | 25 ++++++---- workflow-templates/plugin-playwright.yml | 13 ++++-- workflow-templates/plugin-unit-tests.yml | 6 ++- workflow-templates/release-cosmos-tool.yml | 2 +- 10 files changed, 139 insertions(+), 45 deletions(-) diff --git a/.github/workflows/plugin-playwright-reusable.yml b/.github/workflows/plugin-playwright-reusable.yml index 0a57be0..85eb499 100644 --- a/.github/workflows/plugin-playwright-reusable.yml +++ b/.github/workflows/plugin-playwright-reusable.yml @@ -14,6 +14,9 @@ # The Playwright harness lives in this repo under playwright-plugin/, so the # calling plugin needs no test files of its own. # +# A frontend needs nothing declared either: a plugin with a package.json gets +# pnpm and `pnpm install`, and `rake build` is what runs `pnpm run build`. +# # Third party actions are pinned to a full commit SHA, because a tag can be moved # to point at different code. The comment after each pin records the tag it was. @@ -48,12 +51,7 @@ on: type: boolean default: false is_tool: - description: 'Whether this is a tool (requires frontend build)' - required: false - type: boolean - default: false - build_frontend: - description: 'Build frontend assets for plugins such as widgets; tool plugins build automatically' + description: 'Whether the plugin is expected to register a COSMOS tool, which makes registering none a failure rather than a skip' required: false type: boolean default: false @@ -134,7 +132,11 @@ jobs: set -euo pipefail # .env ships the defaults; .env.local is loaded after it and wins, so # the pinned upstream file stays untouched - demo=0 + # The empty string is the one value that disables the demo on every + # COSMOS. Current init.sh reads OPENC3_DEMO through flag_enabled, where + # empty, 0 and false are all off, but older releases tested it with + # `[ ! -z $OPENC3_DEMO ]`, where the non-empty string 0 counts as ON. + demo= if [ "$INSTALL_DEMO" = "true" ]; then demo=1; fi cat > .env.local <> "$GITHUB_OUTPUT" + echo "Frontend: \`package.json\` found, building with pnpm" >> "$GITHUB_STEP_SUMMARY" + else + echo "present=false" >> "$GITHUB_OUTPUT" + echo "Frontend: no \`package.json\`, nothing to build" >> "$GITHUB_STEP_SUMMARY" + fi + - name: Install pnpm - if: ${{ inputs.is_tool || inputs.build_frontend }} + if: ${{ steps.frontend.outputs.present == 'true' }} uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0 with: package_json_file: ${{ inputs.plugin_directory }}/package.json - name: Install frontend dependencies - if: ${{ inputs.is_tool || inputs.build_frontend }} + if: ${{ steps.frontend.outputs.present == 'true' }} working-directory: ${{ inputs.plugin_directory }} run: pnpm install --frozen-lockfile - - name: Build frontend - if: ${{ inputs.is_tool || inputs.build_frontend }} - working-directory: ${{ inputs.plugin_directory }} - run: pnpm run build - # openc3cli is what `rake build` shells out to for validation - name: Install openc3 gem run: gem install --no-document openc3 + # There is no separate `pnpm run build` step because this one covers it: + # the plugin's Rakefile runs the frontend build itself when the plugin has + # a package.json - name: Build plugin gem id: gem working-directory: ${{ inputs.plugin_directory }} diff --git a/.github/workflows/plugin-unit-tests-reusable.yml b/.github/workflows/plugin-unit-tests-reusable.yml index 8cf4182..72d83c9 100644 --- a/.github/workflows/plugin-unit-tests-reusable.yml +++ b/.github/workflows/plugin-unit-tests-reusable.yml @@ -162,10 +162,13 @@ jobs: echo "No coverage data recorded" >> "$GITHUB_STEP_SUMMARY" exit 0 fi + # coverage report exits non-zero when there is nothing measured and + # when a plugin configures fail_under, neither of which should turn + # into a second failure on top of whatever the test run reported { echo "## Python coverage" echo '```' - .venv/bin/python -m coverage report + .venv/bin/python -m coverage report || true echo '```' } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/release-cosmos-plugin-reusable.yml b/.github/workflows/release-cosmos-plugin-reusable.yml index ae816c4..12d880a 100644 --- a/.github/workflows/release-cosmos-plugin-reusable.yml +++ b/.github/workflows/release-cosmos-plugin-reusable.yml @@ -138,13 +138,27 @@ jobs: - name: Build gem working-directory: ${{ inputs.plugin_directory }} - run: rake build VERSION=${{ inputs.version }} + run: | + # Some plugins have a built gem committed at the plugin root. Clear any + # out first so the glob below can only match what this build produced; + # otherwise a stale gem sorts first and gets published in place of the + # version being released. Only the plugin root is touched, so gems + # vendored in subdirectories are left alone. + rm -f ./*.gem + rake build VERSION=${{ inputs.version }} - name: Find and set gem filename id: gem_file working-directory: ${{ inputs.plugin_directory }} run: | - GEM_FILE=$(ls ${{ steps.plugin_info.outputs.plugin_name }}-*.gem) + shopt -s nullglob + GEM_FILES=(./${{ steps.plugin_info.outputs.plugin_name }}-*.gem) + if [ ${#GEM_FILES[@]} -ne 1 ]; then + echo "Error: Expected exactly one gem after the build, found ${#GEM_FILES[@]}" + ls -la ./*.gem || true + exit 1 + fi + GEM_FILE=$(basename "${GEM_FILES[0]}") echo "gem_filename=$GEM_FILE" >> $GITHUB_OUTPUT echo "Found gem file: $GEM_FILE" diff --git a/playwright-plugin/playwright.config.ts b/playwright-plugin/playwright.config.ts index 2ad5c78..5962093 100644 --- a/playwright-plugin/playwright.config.ts +++ b/playwright-plugin/playwright.config.ts @@ -11,7 +11,9 @@ export default defineConfig({ expect: { timeout: 30 * 1000, }, - globalTimeout: 30 * 60 * 1000, + // Has to leave room for the setup budget (20 min) plus the install test and + // its one retry, and still finish inside the workflow's 45 minute job timeout + globalTimeout: 40 * 60 * 1000, forbidOnly: !!process.env.CI, // COSMOS and the browser share one runner, so a starved event loop can freeze // a page long enough to time out an action. One retry rides that out. diff --git a/playwright-plugin/tests/01-install-plugin.spec.ts b/playwright-plugin/tests/01-install-plugin.spec.ts index 29669b8..601bf20 100644 --- a/playwright-plugin/tests/01-install-plugin.spec.ts +++ b/playwright-plugin/tests/01-install-plugin.spec.ts @@ -42,6 +42,10 @@ test.beforeAll(() => { }) test('installs the plugin', async ({ page }) => { + // The Complete assertion alone is allowed to burn INSTALL_TIMEOUT, so the + // test needs headroom on top of it for the navigation, upload and submit + test.setTimeout(INSTALL_TIMEOUT + 3 * 60 * 1000) + await page.goto('/tools/admin/plugins') await expect(page.locator('.v-app-bar')).toContainText('Administrator') @@ -65,7 +69,7 @@ test('installs the plugin', async ({ page }) => { // assertion: the process list is only rendered once there are processes, so // asserting the absence of a Running row can pass before the install has even // been queued. A first install reports the bare gem name, a re-install - // appends __. + // appends __. const complete = new RegExp( `Processing plugin_install: ${escapedGem}(__\\S+)? - Complete`, ) @@ -75,17 +79,28 @@ test('installs the plugin', async ({ page }) => { ) // A failed install still leaves a process row, so confirm the plugin is - // actually listed rather than trusting the process output alone + // actually listed rather than trusting the process output alone. + // Deliberately "at least one" and not exactly one: COSMOS names every fresh + // install __, so a Playwright retry after an install that got + // far enough leaves two rows, and an exact count could then never pass. await expect( - page.locator('[data-test=plugin-list-item]').filter({ hasText: pluginName }), - ).toHaveCount(1) + page + .locator('[data-test=plugin-list-item]') + .filter({ hasText: pluginName }) + .first(), + ).toBeVisible() for (const target of expectedTargets) { + // Same reason as above: match "some row for this plugin lists the target" + // rather than pinning it to a single row await expect( page .locator('[data-test=plugin-list-item]') - .filter({ hasText: pluginName }), - ).toContainText(target) + .filter({ hasText: pluginName }) + .filter({ hasText: target }) + .first(), + `${target} is not listed under ${pluginName}`, + ).toBeVisible() } }) @@ -105,9 +120,12 @@ test('loaded the plugin', async ({ page }) => { await expect(page.getByRole('dialog')).toContainText('Process Output') // openc3cli logs "Loading new plugin: ", and the path is not always - // just the filename, so don't anchor the gem name to the colon + // just the filename, so don't anchor the gem name to the colon. On a + // Playwright retry (or against a stack that already has the plugin) local + // mode routes the same gem down the upgrade path, which logs + // "Updating existing plugin: with " instead. await expect(page.getByRole('dialog')).toContainText( - new RegExp(`Loading new plugin: .*${escapedGem}`), + new RegExp(`(Loading new plugin|Updating existing plugin): .*${escapedGem}`), ) await page.getByRole('button', { name: 'Ok' }).click() }) diff --git a/playwright-plugin/tests/02-tool-console.spec.ts b/playwright-plugin/tests/02-tool-console.spec.ts index edaa4ad..bb8fecb 100644 --- a/playwright-plugin/tests/02-tool-console.spec.ts +++ b/playwright-plugin/tests/02-tool-console.spec.ts @@ -25,6 +25,13 @@ const gem = path.basename(process.env.PLUGIN_GEM || '') const pluginName = gem.replace(/-\d[^-]*\.gem$/, '') const isTool = process.env.IS_TOOL === 'true' +// Without this an unset PLUGIN_GEM leaves pluginName as '', and the tool filter +// below ("does tool.plugin contain pluginName") then matches every tool COSMOS +// has installed, silently checking the whole product instead of this plugin +test.beforeAll(() => { + expect(pluginName, 'PLUGIN_GEM must be set').toBeTruthy() +}) + // Errors that say nothing about the plugin. Deliberately short: every entry // here is a class of real breakage this test can no longer see. const DEFAULT_IGNORES = [ @@ -123,15 +130,27 @@ test('tool pages load without console errors', async ({ page, context }) => { const toolPage = await context.newPage() watchForErrors(toolPage, problems) - await toolPage.goto(tool.url!, { waitUntil: 'networkidle' }) - - // tool-base renders a catch all 404 for a tool that never registered with - // single-spa, which is quiet in the console. Confirm the tool actually - // rendered before believing a clean console means anything. - await expect(toolPage.locator('.v-app-bar')).toContainText(tool.name) + // Not networkidle: COSMOS tools poll the API and hold a cable connection + // open, so a tool page is never reliably idle and the wait can burn the + // whole test timeout. The settle and the 404 check below are what decide + // whether the tool actually came up. + await toolPage.goto(tool.url!, { waitUntil: 'domcontentloaded' }) // Let deferred work (chunk loads, first data fetch) report itself await toolPage.waitForTimeout(5000) + + // tool-base renders a catch all "404 Not Found" card for a tool that + // never registered with single-spa, and that is quiet in the console, so + // confirm it is absent before believing a clean console means anything. + // Deliberately not "the app bar says tool.name": a tool's title is + // whatever its own code hands and does not have to equal the + // name in plugin.txt - COSMOS's own tool is TOOL admin Admin and titles + // itself Administrator. + await expect( + toolPage.getByText('404 Not Found'), + `${tool.url} rendered tool-base's 404 instead of the tool`, + ).toHaveCount(0) + await toolPage.close() if (problems.length) { diff --git a/playwright-plugin/tests/auth.setup.ts b/playwright-plugin/tests/auth.setup.ts index 4fbbe7c..aa90809 100644 --- a/playwright-plugin/tests/auth.setup.ts +++ b/playwright-plugin/tests/auth.setup.ts @@ -21,11 +21,20 @@ import { STORAGE_STATE } from '../playwright.config' const PASSWORD = process.env.COSMOS_PASSWORD || 'password' -// init.sh installs the tools one at a time, so the admin tool being present in -// the import map is the signal that COSMOS is far enough along to drive. A tool -// that isn't in the map is never registered with single-spa and tool-base -// renders its 404 instead, which a spec only sees as a missing app bar. -async function waitForAdminTool(page: Page) { +// init.sh installs the plugins one at a time, so a tool appearing in the import +// map only means init.sh got that far. A tool that isn't in the map is never +// registered with single-spa and tool-base renders its 404 instead, which a +// spec only sees as a missing app bar. +// +// The marker has to be the LAST inline tool init.sh loads, not the first one we +// happen to need: tool-admin is loaded well before the demo plugin and before +// cmdtlmserver, so waiting on admin would let the specs start while INST/INST2 +// don't exist yet and /tools/cmdtlmserver still 404s. bucketexplorer is the +// last tool init.sh loads that gets an import map entry (docs is iframe based), +// which is the same marker the COSMOS repo's own suite waits on. +const READY_MARKER = '@openc3/tool-bucketexplorer' + +async function waitForTools(page: Page) { await expect .poll( async () => { @@ -33,13 +42,13 @@ async function waitForAdminTool(page: Page) { const response = await page.request.get('/openc3-api/map.json') if (!response.ok()) return false const imports = (await response.json()).imports || {} - return '@openc3/tool-admin' in imports + return READY_MARKER in imports } catch { return false } }, { - message: 'waiting for @openc3/tool-admin in the import map', + message: `waiting for ${READY_MARKER} in the import map`, timeout: 12 * 60 * 1000, intervals: [5000], }, @@ -50,7 +59,7 @@ async function waitForAdminTool(page: Page) { setup('sign in', async ({ page }) => { setup.setTimeout(20 * 60 * 1000) - await waitForAdminTool(page) + await waitForTools(page) await page.goto('/tools/cmdtlmserver') diff --git a/workflow-templates/plugin-playwright.yml b/workflow-templates/plugin-playwright.yml index f0a3cc0..a9c6ba7 100644 --- a/workflow-templates/plugin-playwright.yml +++ b/workflow-templates/plugin-playwright.yml @@ -5,6 +5,10 @@ # 3. Update plugin_directory argument. This should be "." for single repos, and e.g. "openc3-cosmos-orbitview" for cosmos-enterprise-plugins. # 4. Set expected_targets to the target names your plugin defines # 5. Set install_demo: true if your plugin's defaults reference the demo targets (INST, INST2) +# 6. Replace $default-branch under `push:` with your default branch name. +# GitHub substitutes it only when you start the workflow from the Actions +# tab; a hand copied file keeps the literal, which matches no branch and +# silently never runs on push. # # Runs the most recent COSMOS Core release in Docker, so it catches what unit # tests can't: a plugin.txt that doesn't parse, a target that doesn't build, a @@ -19,7 +23,7 @@ name: Plugin Playwright on: push: - branches: [main] + branches: [$default-branch] pull_request: branches: ["**"] # Weekly against whatever COSMOS release is current, so a new COSMOS that @@ -52,8 +56,11 @@ jobs: expected_targets: MY_TARGET # Update this cosmos_version: ${{ inputs.cosmos_version || 'latest' }} # install_demo: true # Uncomment if the plugin's defaults reference INST/INST2 - # is_tool: true # Uncomment for tool plugins that need a frontend build - # build_frontend: true # Uncomment for widget-only plugins that need a frontend build + # A plugin with a package.json is built with pnpm automatically, so there + # is nothing to set for a tool or widget frontend. Uncomment is_tool only + # to assert the plugin really does register a COSMOS tool, which turns + # registering none from a skip into a failure. + # is_tool: true # Tool pages are opened and checked for browser console errors automatically # when the plugin registers a tool. Add regexes here for errors to allow: # console_ignore: | diff --git a/workflow-templates/plugin-unit-tests.yml b/workflow-templates/plugin-unit-tests.yml index b764d50..a395e22 100644 --- a/workflow-templates/plugin-unit-tests.yml +++ b/workflow-templates/plugin-unit-tests.yml @@ -3,6 +3,10 @@ # 1. Update the workflow name if necessary (if you're in cosmos-enterprise-plugins and have multiple plugins) # 2. Update the workflow filename if necessary # 3. Update plugin_directory argument. This should be "." for single repos, and e.g. "openc3-cosmos-orbitview" for cosmos-enterprise-plugins. +# 4. Replace $default-branch under `push:` with your default branch name. +# GitHub substitutes it only when you start the workflow from the Actions +# tab; a hand copied file keeps the literal, which matches no branch and +# silently never runs on push. # # Python tests are discovered in tests/ or test/, Ruby specs in spec/ or specs/. # Whichever the plugin doesn't have is skipped, so there is nothing else to set. @@ -11,7 +15,7 @@ name: Plugin Unit Tests on: push: - branches: [main] + branches: [$default-branch] pull_request: branches: ["**"] diff --git a/workflow-templates/release-cosmos-tool.yml b/workflow-templates/release-cosmos-tool.yml index d3aaefd..3a058d2 100644 --- a/workflow-templates/release-cosmos-tool.yml +++ b/workflow-templates/release-cosmos-tool.yml @@ -6,7 +6,7 @@ # 4. Add the OpenC3 admin accont API token to the repo secrets as OPENC3_API_TOKEN # 5. To publish to RubyGems, add your RubyGems API key to the repo secrets as # RUBYGEMS_API_KEY and check 'Publish the gem to RubyGems' when you run this -# 5. Make sure pnpm-workspace.yaml has the `packages` field defined +# 6. Make sure pnpm-workspace.yaml has the `packages` field defined name: Release COSMOS Tool From 1e7b128dc54b6ea1324c10693819b6cf676be481 Mon Sep 17 00:00:00 2001 From: Ryan Melton Date: Sun, 13 Sep 2026 12:20:17 -0600 Subject: [PATCH 5/7] Add package.json check --- .../release-cosmos-plugin-reusable.yml | 31 ++++++++++++++----- workflow-templates/release-cosmos-tool.yml | 3 ++ 2 files changed, 27 insertions(+), 7 deletions(-) diff --git a/.github/workflows/release-cosmos-plugin-reusable.yml b/.github/workflows/release-cosmos-plugin-reusable.yml index 12d880a..4dd404c 100644 --- a/.github/workflows/release-cosmos-plugin-reusable.yml +++ b/.github/workflows/release-cosmos-plugin-reusable.yml @@ -23,12 +23,12 @@ on: type: string default: '.' is_tool: - description: 'Whether this is a tool (requires frontend build)' + description: 'Assert the plugin has a frontend, so a missing package.json fails here instead of publishing a gem with no tool in it. The frontend is built whenever a package.json is present, with or without this.' required: false type: boolean default: false frozen_lockfile: - description: 'Use pnpm --frozen-lockfile (ignored when is_tool is false)' + description: 'Use pnpm --frozen-lockfile (ignored when the plugin has no package.json)' required: false type: boolean default: true @@ -85,8 +85,25 @@ jobs: exit 1 fi + # The presence of a package.json - not an input - is what decides whether + # the frontend gets built. Gating that on is_tool meant a plugin that grew + # a tool or a widget and whose caller never added the flag published a gem + # with the frontend silently missing. is_tool is now only the assertion + # above: say it when you want a missing package.json to fail the release. + - name: Detect frontend + id: frontend + working-directory: ${{ inputs.plugin_directory }} + run: | + if [ -f package.json ]; then + echo "present=true" >> "$GITHUB_OUTPUT" + echo "Frontend detected, building with pnpm" + else + echo "present=false" >> "$GITHUB_OUTPUT" + echo "No package.json, nothing to build" + fi + - name: Validate packageManager field - if: ${{ inputs.is_tool }} + if: ${{ steps.frontend.outputs.present == 'true' }} run: | cat "${{ inputs.plugin_directory }}/package.json" if ! grep -q '"packageManager"' "${{ inputs.plugin_directory }}/package.json"; then @@ -110,24 +127,24 @@ jobs: echo "Plugin name: $PLUGIN_NAME" - name: Set up Node.js - if: ${{ inputs.is_tool }} + if: ${{ steps.frontend.outputs.present == 'true' }} uses: actions/setup-node@v6 with: node-version: '24' - name: Install pnpm - if: ${{ inputs.is_tool }} + if: ${{ steps.frontend.outputs.present == 'true' }} uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 #v5.0.0 with: package_json_file: ${{ inputs.plugin_directory }}/package.json - name: Install frontend dependencies - if: ${{ inputs.is_tool }} + if: ${{ steps.frontend.outputs.present == 'true' }} working-directory: ${{ inputs.plugin_directory }} run: pnpm install ${{ inputs.frozen_lockfile && '--frozen-lockfile' || '' }} - name: Build frontend - if: ${{ inputs.is_tool }} + if: ${{ steps.frontend.outputs.present == 'true' }} working-directory: ${{ inputs.plugin_directory }} run: pnpm run build diff --git a/workflow-templates/release-cosmos-tool.yml b/workflow-templates/release-cosmos-tool.yml index 3a058d2..467e246 100644 --- a/workflow-templates/release-cosmos-tool.yml +++ b/workflow-templates/release-cosmos-tool.yml @@ -36,6 +36,9 @@ jobs: deploy_to_store: ${{ inputs.deploy_to_store }} deploy_to_rubygems: ${{ inputs.deploy_to_rubygems }} plugin_directory: openc3-cosmos-tool-foo # Update this + # The frontend is built whenever the plugin has a package.json. is_tool + # only asserts that it has one, so a missing package.json fails the + # release instead of publishing a gem with no tool in it. is_tool: true secrets: OPENC3_API_TOKEN: ${{ secrets.OPENC3_API_TOKEN }} From 4f9820d1f511e6a6e93efd202f52437b8b3c7d68 Mon Sep 17 00:00:00 2001 From: Ryan Melton Date: Tue, 22 Sep 2026 16:14:47 -0600 Subject: [PATCH 6/7] Address PR review: fail fast on install errors, longer job timeout, CI The install spec now waits for any final process state and fails on Error, Crashed or Expired rather than waiting out the timeout for a Complete that won't come. Warning passes. Rows left from an earlier attempt are ignored so a retry is judged on its own result. The Playwright job timeout goes to 75 minutes since checkout, build and startup can take 20 minutes before Playwright's own 40 minute budget starts. Adds CI for this repo: actionlint on workflows and templates, template metadata checks, and a typecheck and test listing of the Playwright harness. Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/ci.yml | 85 ++++ .../workflows/plugin-playwright-reusable.yml | 4 +- playwright-plugin/package-lock.json | 396 ++++++++++++++++++ playwright-plugin/package.json | 8 +- playwright-plugin/playwright.config.ts | 6 +- .../tests/01-install-plugin.spec.ts | 68 ++- .../tests/02-tool-console.spec.ts | 8 +- playwright-plugin/tsconfig.json | 13 + 8 files changed, 567 insertions(+), 21 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 playwright-plugin/tsconfig.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..3d00be6 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,85 @@ +# Checks this repo's own files: the reusable workflows and templates it hands to +# plugin repos, and the Playwright harness those workflows check out. Nothing +# here needs COSMOS running - the harness is exercised for real by each plugin's +# Playwright run - so this catches the mistakes that are cheap to catch early: +# a workflow that doesn't parse, a template whose JSON is broken, a spec that +# doesn't compile. +# +# Third party actions are pinned to a full commit SHA, because a tag can be moved +# to point at different code. The comment after each pin records the tag it was. + +name: CI + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +defaults: + run: + shell: bash + +jobs: + workflows: + name: Lint workflows + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Run actionlint + env: + ACTIONLINT_VERSION: 1.7.12 + run: | + set -euo pipefail + bash <(curl -fsSL "https://raw.githubusercontent.com/rhysd/actionlint/v${ACTIONLINT_VERSION}/scripts/download-actionlint.bash") "$ACTIONLINT_VERSION" + # The templates are workflows too, just ones GitHub copies into plugin + # repos rather than runs here + ./actionlint -color .github/workflows/*.yml workflow-templates/*.yml + + - name: Validate template metadata + run: | + set -euo pipefail + for file in workflow-templates/*.properties.json; do + jq empty "$file" + done + # GitHub only offers a template that has a properties file beside it + for file in workflow-templates/*.yml; do + if [ ! -f "${file%.yml}.properties.json" ]; then + echo "::error file=$file::Missing ${file%.yml}.properties.json" + exit 1 + fi + done + + playwright-harness: + name: Check Playwright harness + runs-on: ubuntu-latest + timeout-minutes: 10 + defaults: + run: + working-directory: playwright-plugin + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Set up Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 24 + + - name: Install dependencies + run: npm ci --no-audit --no-fund + + - name: Typecheck + run: npm run typecheck + + # Loads the config and every spec the way a real run would, so a broken + # import or project wiring fails here. No browser is needed to list. + - name: List tests + env: + PLUGIN_GEM: /nonexistent/openc3-cosmos-example-1.0.0.gem + run: npm run list diff --git a/.github/workflows/plugin-playwright-reusable.yml b/.github/workflows/plugin-playwright-reusable.yml index 85eb499..6cd901e 100644 --- a/.github/workflows/plugin-playwright-reusable.yml +++ b/.github/workflows/plugin-playwright-reusable.yml @@ -82,7 +82,9 @@ jobs: playwright: name: Install plugin in COSMOS runs-on: ubuntu-latest - timeout-minutes: 45 + # Checkout, the gem build and starting COSMOS can take 20 minutes before + # Playwright starts, and Playwright's own globalTimeout is 40 + timeout-minutes: 75 steps: - name: Checkout plugin uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 diff --git a/playwright-plugin/package-lock.json b/playwright-plugin/package-lock.json index 95d8f3b..c380d74 100644 --- a/playwright-plugin/package-lock.json +++ b/playwright-plugin/package-lock.json @@ -9,6 +9,10 @@ "version": "1.0.0", "dependencies": { "@playwright/test": "1.62.1" + }, + "devDependencies": { + "@types/node": "24.13.6", + "typescript": "7.0.2" } }, "node_modules/@playwright/test": { @@ -26,6 +30,356 @@ "node": ">=20" } }, + "node_modules/@types/node": { + "version": "24.13.6", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.6.tgz", + "integrity": "sha512-SGrw/h3KPFshy3OE6ZL53LMBG5vGQQ8/gIpiqz/kRZhPJ7HgwCEs8LBuNtWLa8dvGZVpSF7+Bf+c11HUrCb/yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@typescript/typescript-aix-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", + "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz", + "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz", + "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz", + "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz", + "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz", + "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz", + "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-loong64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz", + "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-mips64el": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz", + "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz", + "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-riscv64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz", + "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-s390x": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz", + "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz", + "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz", + "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz", + "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz", + "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz", + "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-sunos-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz", + "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz", + "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz", + "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, "node_modules/fsevents": { "version": "2.3.2", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", @@ -69,6 +423,48 @@ "engines": { "node": ">=20" } + }, + "node_modules/typescript": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", + "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc" + }, + "engines": { + "node": ">=16.20.0" + }, + "optionalDependencies": { + "@typescript/typescript-aix-ppc64": "7.0.2", + "@typescript/typescript-darwin-arm64": "7.0.2", + "@typescript/typescript-darwin-x64": "7.0.2", + "@typescript/typescript-freebsd-arm64": "7.0.2", + "@typescript/typescript-freebsd-x64": "7.0.2", + "@typescript/typescript-linux-arm": "7.0.2", + "@typescript/typescript-linux-arm64": "7.0.2", + "@typescript/typescript-linux-loong64": "7.0.2", + "@typescript/typescript-linux-mips64el": "7.0.2", + "@typescript/typescript-linux-ppc64": "7.0.2", + "@typescript/typescript-linux-riscv64": "7.0.2", + "@typescript/typescript-linux-s390x": "7.0.2", + "@typescript/typescript-linux-x64": "7.0.2", + "@typescript/typescript-netbsd-arm64": "7.0.2", + "@typescript/typescript-netbsd-x64": "7.0.2", + "@typescript/typescript-openbsd-arm64": "7.0.2", + "@typescript/typescript-openbsd-x64": "7.0.2", + "@typescript/typescript-sunos-x64": "7.0.2", + "@typescript/typescript-win32-arm64": "7.0.2", + "@typescript/typescript-win32-x64": "7.0.2" + } + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true, + "license": "MIT" } } } diff --git a/playwright-plugin/package.json b/playwright-plugin/package.json index 17e44d9..05d117e 100644 --- a/playwright-plugin/package.json +++ b/playwright-plugin/package.json @@ -4,9 +4,15 @@ "private": true, "description": "Installs a COSMOS plugin into a running COSMOS and verifies it came up", "scripts": { - "test": "playwright test --project=chromium" + "test": "playwright test --project=chromium", + "typecheck": "tsc", + "list": "playwright test --list" }, "dependencies": { "@playwright/test": "1.62.1" + }, + "devDependencies": { + "@types/node": "24.13.6", + "typescript": "7.0.2" } } diff --git a/playwright-plugin/playwright.config.ts b/playwright-plugin/playwright.config.ts index 5962093..733c93f 100644 --- a/playwright-plugin/playwright.config.ts +++ b/playwright-plugin/playwright.config.ts @@ -11,8 +11,10 @@ export default defineConfig({ expect: { timeout: 30 * 1000, }, - // Has to leave room for the setup budget (20 min) plus the install test and - // its one retry, and still finish inside the workflow's 45 minute job timeout + // Covers the Playwright step only: the setup budget (20 min) plus the install + // test and its one retry. The workflow's 75 minute job timeout adds room on + // top for checkout, the gem build and starting COSMOS, which together can + // take 20 minutes on their own. globalTimeout: 40 * 60 * 1000, forbidOnly: !!process.env.CI, // COSMOS and the browser share one runner, so a starved event loop can freeze diff --git a/playwright-plugin/tests/01-install-plugin.spec.ts b/playwright-plugin/tests/01-install-plugin.spec.ts index 601bf20..5ae8371 100644 --- a/playwright-plugin/tests/01-install-plugin.spec.ts +++ b/playwright-plugin/tests/01-install-plugin.spec.ts @@ -12,7 +12,7 @@ // PLUGIN_GEM path to the .gem file to install (required) // EXPECTED_TARGETS space separated target names the plugin should define (optional) -import { test, expect } from '@playwright/test' +import { test, expect, type Page } from '@playwright/test' import * as fs from 'fs' import * as path from 'path' @@ -36,13 +36,26 @@ const escapedGem = escapeRegExp(gem) // runner pulling images is minutes rather than seconds const INSTALL_TIMEOUT = 10 * 60 * 1000 +// Every final state a COSMOS process can report. A first install reports the +// bare gem name, a re-install appends __. +const FINISHED = new RegExp( + `^Processing plugin_install: ${escapedGem}(__\\S+)? - (Complete|Warning|Error|Crashed|Expired)$`, +) + +async function readFinishedInstalls(page: Page) { + const rows = await page + .locator('[data-test=process-list] .v-list-item-title') + .allInnerTexts() + return rows.map((row) => row.trim()).filter((row) => FINISHED.test(row)) +} + test.beforeAll(() => { expect(gemPath, 'PLUGIN_GEM must be set').toBeTruthy() expect(fs.existsSync(gemPath), `${gemPath} does not exist`).toBe(true) }) test('installs the plugin', async ({ page }) => { - // The Complete assertion alone is allowed to burn INSTALL_TIMEOUT, so the + // Waiting for the install to finish alone is allowed to burn INSTALL_TIMEOUT, so the // test needs headroom on top of it for the navigation, upload and submit test.setTimeout(INSTALL_TIMEOUT + 3 * 60 * 1000) @@ -59,24 +72,51 @@ test('installs the plugin', async ({ page }) => { // Every plugin gets the variables dialog, even with no VARIABLEs to set. // Submitting takes the defaults from plugin.txt. await expect(page.locator('.v-dialog:has-text("Variables")')).toBeVisible() + + // The process list keeps the last 10 minutes of activity in no particular + // order, so on a Playwright retry it still holds the first attempt's rows. + // Snapshot the finished ones now so only this attempt's result is judged. + const finishedRows = await readFinishedInstalls(page) + await page.locator('data-test=edit-submit').click() await expect(page.locator('[data-test=plugin-alert]')).toContainText( 'Started installing', ) - // Wait for the install process to report Complete. Deliberately a positive - // assertion: the process list is only rendered once there are processes, so - // asserting the absence of a Running row can pass before the install has even - // been queued. A first install reports the bare gem name, a re-install - // appends __. - const complete = new RegExp( - `Processing plugin_install: ${escapedGem}(__\\S+)? - Complete`, - ) - await expect(page.locator('[data-test=process-list]')).toContainText( - complete, - { timeout: INSTALL_TIMEOUT }, - ) + // Wait for the install process to reach any final state, so an Error or + // Crashed install fails immediately instead of burning INSTALL_TIMEOUT waiting + // for a Complete that will never come. Deliberately a positive check: the + // process list is only rendered once there are processes, so asserting the + // absence of a Running row can pass before the install has even been queued. + let state: string | undefined + await expect + .poll( + async () => { + const fresh = [...(await readFinishedInstalls(page))] + for (const row of finishedRows) { + const index = fresh.indexOf(row) + if (index !== -1) fresh.splice(index, 1) + } + state = fresh[0]?.match(FINISHED)?.[2] + return state + }, + { + message: `plugin_install of ${gem} never finished`, + timeout: INSTALL_TIMEOUT, + intervals: [2000], + }, + ) + .toBeTruthy() + + // Warning means the plugin installed but something along the way deserves a + // look, so it passes. Error, Crashed and Expired are failures. + expect( + state, + `plugin_install finished with ${state}:\n${await page + .locator('[data-test=process-list]') + .innerText()}`, + ).toMatch(/^(Complete|Warning)$/) // A failed install still leaves a process row, so confirm the plugin is // actually listed rather than trusting the process output alone. diff --git a/playwright-plugin/tests/02-tool-console.spec.ts b/playwright-plugin/tests/02-tool-console.spec.ts index bb8fecb..8fa3f9a 100644 --- a/playwright-plugin/tests/02-tool-console.spec.ts +++ b/playwright-plugin/tests/02-tool-console.spec.ts @@ -102,8 +102,10 @@ test('tool pages load without console errors', async ({ page, context }) => { const all = await fetchTools(page) const mine = all.filter((tool) => (tool.plugin || '').includes(pluginName)) - // IFRAME and NEW tools point somewhere else, so their console says nothing - // about this plugin. INLINE tools are the ones COSMOS itself renders. + // WINDOW is INLINE, IFRAME, SAME or NEW (see ToolModel). Only INLINE tools are + // mounted by COSMOS itself as a single-spa app, so only their console says + // anything about this plugin. IFRAME embeds, and SAME (a plain link in the + // current tab) and NEW (a new tab) navigate to, a page COSMOS doesn't render. const checkable = mine.filter( (tool) => tool.window === 'INLINE' && tool.url && tool.url.startsWith('/'), ) @@ -120,7 +122,7 @@ test('tool pages load without console errors', async ({ page, context }) => { test.skip( checkable.length === 0, - `${pluginName} ships only IFRAME/NEW tools, which render outside COSMOS`, + `${pluginName} ships only IFRAME/SAME/NEW tools, which render outside COSMOS`, ) const failures: string[] = [] diff --git a/playwright-plugin/tsconfig.json b/playwright-plugin/tsconfig.json new file mode 100644 index 0000000..207fc60 --- /dev/null +++ b/playwright-plugin/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "nodenext", + "moduleResolution": "nodenext", + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "skipLibCheck": true, + "types": ["node"] + }, + "include": ["playwright.config.ts", "tests/**/*.ts"] +} From b9db25cc797caf53924ef1d0d686546264fa8fde Mon Sep 17 00:00:00 2001 From: Ryan Melton Date: Tue, 22 Sep 2026 16:16:12 -0600 Subject: [PATCH 7/7] Fix shellcheck findings that the new CI lint reports Co-Authored-By: Claude Opus 5.5 (1M context) --- .../copy-release-to-forgejo-reusable.yml | 2 +- .../workflows/release-cosmos-plugin-reusable.yml | 16 +++++++++------- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/.github/workflows/copy-release-to-forgejo-reusable.yml b/.github/workflows/copy-release-to-forgejo-reusable.yml index bcca093..5adbafd 100644 --- a/.github/workflows/copy-release-to-forgejo-reusable.yml +++ b/.github/workflows/copy-release-to-forgejo-reusable.yml @@ -37,7 +37,7 @@ jobs: FORGEJO_REPO="openc3/${{ github.event.repository.name }}" echo "No forgejo_repo provided, defaulting to: $FORGEJO_REPO" fi - echo "forgejo_repo=$FORGEJO_REPO" >> $GITHUB_OUTPUT + echo "forgejo_repo=$FORGEJO_REPO" >> "$GITHUB_OUTPUT" - name: Fetch and Mirror Release env: diff --git a/.github/workflows/release-cosmos-plugin-reusable.yml b/.github/workflows/release-cosmos-plugin-reusable.yml index 4dd404c..29d508d 100644 --- a/.github/workflows/release-cosmos-plugin-reusable.yml +++ b/.github/workflows/release-cosmos-plugin-reusable.yml @@ -117,13 +117,15 @@ jobs: id: plugin_info working-directory: ${{ inputs.plugin_directory }} run: | - GEMSPEC_FILE=$(ls *.gemspec | head -n1) - if [ -z "$GEMSPEC_FILE" ]; then + shopt -s nullglob + GEMSPEC_FILES=(./*.gemspec) + GEMSPEC_FILE=$(basename "${GEMSPEC_FILES[0]:-}") + if [ ${#GEMSPEC_FILES[@]} -eq 0 ]; then echo "Error: No gemspec file found in ${{ inputs.plugin_directory }}" exit 1 fi PLUGIN_NAME="${GEMSPEC_FILE%.gemspec}" - echo "plugin_name=$PLUGIN_NAME" >> $GITHUB_OUTPUT + echo "plugin_name=$PLUGIN_NAME" >> "$GITHUB_OUTPUT" echo "Plugin name: $PLUGIN_NAME" - name: Set up Node.js @@ -176,15 +178,15 @@ jobs: exit 1 fi GEM_FILE=$(basename "${GEM_FILES[0]}") - echo "gem_filename=$GEM_FILE" >> $GITHUB_OUTPUT + echo "gem_filename=$GEM_FILE" >> "$GITHUB_OUTPUT" echo "Found gem file: $GEM_FILE" - name: Calculate checksum id: checksum working-directory: ${{ inputs.plugin_directory }} run: | - CHECKSUM=$(shasum -a 256 ${{ steps.gem_file.outputs.gem_filename }} | cut -d' ' -f1) - echo "checksum=$CHECKSUM" >> $GITHUB_OUTPUT + CHECKSUM=$(shasum -a 256 "${{ steps.gem_file.outputs.gem_filename }}" | cut -d' ' -f1) + echo "checksum=$CHECKSUM" >> "$GITHUB_OUTPUT" echo "Checksum: $CHECKSUM" - name: Create and push git tag @@ -198,7 +200,7 @@ jobs: fi git tag -a "$TAG_NAME" -m "Release ${{ steps.plugin_info.outputs.plugin_name }} version ${{ inputs.version }}" git push origin "$TAG_NAME" - echo "tag_name=$TAG_NAME" >> $GITHUB_OUTPUT + echo "tag_name=$TAG_NAME" >> "$GITHUB_OUTPUT" id: tag_info - name: Upload to OpenC3 App Store