Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 90 additions & 0 deletions .claude/skills/qa-create-test/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
---
name: qa-create-test
description: Scaffold and wire a new end-to-end test into the CodeZero QA framework at reticulum/qa (Ruby/RSpec/Capybara, GitLab-QA style). Use when asked to "add a QA test", "write a reticulum e2e/QA test", "add a spec for <flow/project/action/...>", or to extend the resource/page/spec layers. Produces a GraphQL resource factory and/or a sculptor page object plus an RSpec feature spec, correctly tagged and autoloaded.
---

# Add a test to the CodeZero QA framework

The framework lives in `reticulum/qa`. It drives a **running** deployment (nginx → sculptor
UI + sagittarius GraphQL + the nats/aquila/taurus/action runtime). Read `qa/README.md` first —
it is the authoritative contract (layers, env vars, `data-qa-selector` groups).

Three layers: **Resource** (`qa/qa/resource/`, GraphQL API factories — arrange state & assert
backend/runtime), **Page** (`qa/qa/page/`, Capybara page objects over `data-qa-selector`), and
**Specs** (`qa/specs/features/<category>/<name>_spec.rb`).

## Decision rule (which layer)

- **Arrange + assert via the API** (resource layer). It is fast and deterministic: create the
org/namespace/project/flow/runtime you need, and read back state (runtime status, execution
result, definitions) to assert.
- **Act via the UI** (page layer) only for the capability the spec is actually about — the thing
a user does in sculptor. Tag such specs `:browser`.
- A pure-API spec is tagged `:api`; a spec that mixes UI actions with API setup/assertions is
tagged `:orchestrated`. Add `:smoke` to the handful of critical-path specs.

## Step-by-step

1. **Pick the scenario + category.** Choose `<category>` (e.g. `project`, `flow`, `actions`) and
the `product_group:` metadata (e.g. `:project`, `:flow`, `:authentication`). Create
`qa/specs/features/<category>/` if missing.

2. **Resource (if you need to create/read backend state).** Copy `templates/resource.rb.template`
to `qa/qa/resource/<name>.rb`. Follow `qa/qa/resource/project.rb` exactly:
- attributes via `attr_accessor`;
- `#api_create` returns `[graphql_string, variables_hash]`;
- `#process_create_response(data)` populates `@id` and any read-back fields;
- `#api_delete` returns `[graphql_string, variables_hash]` for cleanup.
- **Find the mutation name**: it is the Sagittarius mutation class path with `Mutations::`
dropped, `::` removed, first letter lower-cased. Confirm by reading
`/Users/raph/Documents/Code0/sagittarius/app/graphql/mutations/**`. Examples:
`Mutations::Namespaces::Projects::Create` → `namespacesProjectsCreate`;
`Mutations::Organizations::Create` → `organizationsCreate`;
`Mutations::Namespaces::Projects::Flows::TriggerExecution` → `namespacesProjectsFlowsTriggerExecution`.
(Rule source: `base_mutation.rb` `graphql_name subclass.name.delete_prefix('Mutations::').gsub('::','')`.)
- Get the **arguments and input/ID types** from the same mutation file (e.g. `NamespaceID!`,
`NamespaceProjectID!`). Every mutation returns `errors { message }` — always select it; the
base class raises on a non-empty `errors` array.

3. **Page object (if the spec performs a UI action).** Copy `templates/page.rb.template` to
`qa/qa/page/<area>/<name>.rb`. Follow `qa/qa/page/auth/login.rb`:
- declare `path "/..."` and `view_selectors %w[...]`;
- expose intention-revealing methods built from `Page::Base` helpers
(`fill_element`, `click_element`, `has_element?`, `has_element_with_text?`,
`wait_for_element`, `wait_until`).
- **Discover real selectors** from the sculptor source (never guess):
`grep -rhoE 'data-qa-selector=\{?"[^"]+"' <sculptor>/src | sed -E 's/.*"([^"]+)"/\1/' | sort -u`
Known groups are listed in `qa/README.md` (auth / dashboard / flow-builder / flow-create).

4. **Register autoloads** in `qa/qa.rb`: add `autoload :<Const>, "qa/resource/<name>"` under
`module Resource`, and/or `autoload :<Const>, "qa/page/<area>/<name>"` under the matching
`Page::<Area>` module (create the nested `module <Area>` if new).

5. **Spec.** Copy `templates/spec.rb.template` to `qa/specs/features/<category>/<name>_spec.rb`.
Follow `qa/specs/features/login/login_spec.rb`:
- `RSpec.describe "<Area>", product_group: :<group> do` with a layer tag;
- fabricate prerequisites with `QA::Resource::X.fabricate_via_api!`;
- do the one thing under test (UI via a page object, or API);
- assert via UI (`have_css([data-qa-selector="..."])`) and/or API (`QA::Resource` read / client query);
- clean up in an `after` block with `remove_via_api!` (guard with `&.`).

6. **Verify.**
```sh
cd reticulum/qa
ruby -c qa/qa/resource/<name>.rb && ruby -c qa/qa/page/<area>/<name>.rb && ruby -c qa/specs/features/<category>/<name>_spec.rb
bundle exec rspec qa/specs --dry-run # new example(s) must appear in the list
```
A real run needs a live deployment: `QA_SEED=true QA_DEPLOYMENT=docker-compose QA_BASE_URL=... bin/qa qa/specs/features/<category>/<name>_spec.rb`.

## Rules

- Never invent GraphQL fields, mutation names, or `data-qa-selector` values — read them from
`sagittarius/app/graphql` and the sculptor source.
- Keep browser specs minimal and resilient (selectors only, generous waits via `wait_for_element`).
- Always select `errors {
errorCode
details {
...on ActiveModelError { attribute type }
...on MessageError { message }
}
}` in every mutation and always add cleanup.
30 changes: 30 additions & 0 deletions .claude/skills/qa-create-test/templates/page.rb.template
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# frozen_string_literal: true

module QA
module Page
module {{Area}}
# {{ONE_LINE_DESCRIPTION}} — a sculptor page object. Mirrors qa/qa/page/auth/login.rb.
class {{ClassName}} < Page::Base
# URL path relative to QA_BASE_URL.
path "{{/some/path}}"
# data-qa-selector names this page depends on (verify in the sculptor source).
view_selectors %w[{{selector-one selector-two}}]

# Intention-revealing action built from Page::Base helpers
# (fill_element / click_element / has_element? / has_element_with_text? /
# wait_for_element / wait_until).
def {{do_the_thing}}({{arg: nil}})
wait_for_element("{{selector-one}}")
{{fill_element("selector-one", arg)}}
{{click_element("selector-two")}}
self
end

# Optional: a predicate the spec can assert on.
def {{succeeded?}}
{{has_element?("some-success-selector")}}
end
end
end
end
end
72 changes: 72 additions & 0 deletions .claude/skills/qa-create-test/templates/resource.rb.template
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
# frozen_string_literal: true

module QA
module Resource
# {{ONE_LINE_DESCRIPTION}} — a GraphQL API factory. Mirrors qa/qa/resource/project.rb.
class {{ClassName}} < Base
# Settable attributes the spec assigns before fabrication.
attr_accessor {{:attr_one, :attr_two}}

def initialize
# Sensible defaults so a spec can fabricate with zero setup.
{{@attr_one = "qa-#{SecureRandom.hex(4)}"}}
end

# Accept either a parent resource (responds to #id) or a raw id string.
def {{parent}}_id
{{parent}}.respond_to?(:id) ? {{parent}}.id : {{parent}}
end

private

# Returns [graphql_string, variables_hash]. Mutation name = Sagittarius class path
# minus `Mutations::`, `::` removed, first letter lowercased (verify in
# sagittarius/app/graphql/mutations/**). Always select `errors { message }`.
def api_create
graphql = <<~GQL
mutation({{$parentId: ParentID!, $name: String!}}) {
{{graphqlCreateMutation}}(input: { {{parentId: $parentId, name: $name}} }) {
{{payloadField}} { id {{name}} }
errors {
errorCode
details {
...on ActiveModelError { attribute type }
...on MessageError { message }
}
}
}
}
GQL
variables = { {{parentId: parent_id, name: name}} }.compact
[graphql, variables]
end

# Populate @id (and any read-back fields) from the mutation `data` hash.
def process_create_response(data)
node = data.dig("{{graphqlCreateMutation}}", "{{payloadField}}")
raise Runtime::ApiClient::Error, "{{ClassName}} create returned no {{payloadField}}" if node.nil?

@id = node.fetch("id")
{{@name = node.fetch("name")}}
end

# Optional cleanup. Return [graphql, vars]; return nil to skip.
def api_delete
graphql = <<~GQL
mutation($id: {{EntityID}}!) {
{{graphqlDeleteMutation}}(input: { {{entityId: $id}} }) {
errors {
errorCode
details {
...on ActiveModelError { attribute type }
...on MessageError { message }
}
}
}
}
GQL
[graphql, { id: id }]
end
end
end
end
36 changes: 36 additions & 0 deletions .claude/skills/qa-create-test/templates/spec.rb.template
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# frozen_string_literal: true

# {{WHAT_THIS_PROVES}} — mirrors qa/specs/features/login/login_spec.rb.
# Layer tag: :api (pure GraphQL) | :browser (pure UI) | :orchestrated (UI + API).
# Add :smoke only for critical-path specs.
module QA
RSpec.describe "{{Area}}", product_group: :{{group}} do
describe "{{Capability}}", :{{orchestrated}} do
# Prerequisites fabricated via the API (fast, deterministic).
let(:{{parent}}) { Resource::{{Parent}}.fabricate_via_api! { |r| {{r.name = "qa-#{SecureRandom.hex(4)}"}} } }

after do
# Tear down everything created, children first. Guard with &. in case setup failed.
{{@thing}}&.remove_via_api!
{{parent}}&.remove_via_api!
end

it "{{does the thing and is observable}}", :smoke do
# Act — via a page object (UI) ...
{{Page::Area::Thing.new.visit!.do_the_thing(arg: value)}}

# ... or via a resource (API):
# @thing = Resource::Thing.fabricate_via_api! { |t| t.parent = parent }

# Assert — via the UI ...
{{expect(page).to have_css(%([data-qa-selector="some-selector"]), wait: 20)}}

# ... and/or via the API (read-back / client query):
# data = Runtime::ApiClient.default.query(<<~GQL, variables: { id: @thing.id })
# query($id: ThingID!) { thing(id: $id) { id status } }
# GQL
# expect(data.dig("thing", "status")).to eq("RUNNING")
end
end
end
end
58 changes: 58 additions & 0 deletions .github/workflows/ghcr-cleanup.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
name: GHCR cleanup

# Prunes the throwaway CI build images reticulum pushes to GHCR
# (ghcr.io/code0-tech/reticulum/ci-builds/*). Every pipeline leaves a
# pipeline-unique tagged manifest plus untagged per-platform digests behind,
# and nothing is ever overwritten, so the registry grows without bound
# (see issue #368).
#
# This does NOT touch anything pushed to GitLab (those are permanent) and does
# NOT touch reticulum/ci-cache (the buildx registry cache) — only ci-builds.

on:
schedule:
# Nightly, off the top of the hour to avoid scheduler congestion.
- cron: '17 3 * * *'
workflow_dispatch:
inputs:
dry-run:
description: 'List what would be deleted without actually deleting'
type: boolean
default: false

permissions:
packages: write

concurrency:
group: ghcr-cleanup
cancel-in-progress: false

jobs:
cleanup:
runs-on: ubuntu-latest
steps:
- name: Prune old ci-builds images
uses: dataaxiom/ghcr-cleanup-action@v1
with:
owner: code0-tech
# All ci-builds packages (aquila, taurus, sagittarius, sculptor,
# velorum, the base images, …). The glob excludes reticulum/ci-cache.
packages: 'reticulum/ci-builds/*'
# Always keep the 10 most recent tagged builds per image regardless of
# age, so a freshly pruned registry still has pullable images and we
# never end up trying to pull a version that no longer exists.
keep-n-tagged: 10
# Of everything beyond those 10, only delete what is older than 30 days.
older-than: 30 days
# Also sweep dangling untagged digests. The action is multi-arch aware:
# untagged platform children referenced by a retained manifest are kept,
# so pulls never break.
delete-untagged: true
# Re-check manifest/referrer integrity after deletion.
validate: true
dry-run: ${{ inputs.dry-run || false }}
# Org-owned packages are pushed with a PAT (C0_GH_TOKEN); deletion may
# likewise need a token with delete:packages. Provide GHCR_CLEANUP_TOKEN
# (classic PAT, read:packages + delete:packages) if the default
# GITHUB_TOKEN lacks permission on the org packages.
token: ${{ secrets.GHCR_CLEANUP_TOKEN || secrets.GITHUB_TOKEN }}
Loading
Loading