diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9cfd931..bf66afe 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,23 +21,6 @@ jobs: - run: uv run --with 'ruff==0.16.5' --no-project ruff check src/ tests/ - run: uv run --with 'ruff==0.16.5' --no-project ruff format --check src/ tests/ - website: - runs-on: ubuntu-latest - defaults: - run: - working-directory: website - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 - with: - node-version: 22 - cache: npm - cache-dependency-path: website/package-lock.json - - run: npm ci - - run: npm run lint - - run: npm test - - run: npm run build - smoke: runs-on: ubuntu-latest steps: diff --git a/.github/workflows/indexnow.yml b/.github/workflows/indexnow.yml deleted file mode 100644 index 14f1200..0000000 --- a/.github/workflows/indexnow.yml +++ /dev/null @@ -1,47 +0,0 @@ -name: IndexNow - -on: - pull_request: - paths: - - 'website/**' - - '.github/workflows/indexnow.yml' - push: - branches: [main] - paths: - - 'website/**' - - '.github/workflows/indexnow.yml' - workflow_dispatch: - -permissions: - contents: read - -concurrency: - group: indexnow-${{ github.ref }} - cancel-in-progress: true - -jobs: - test: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 - with: - node-version: '22' - - run: node --test website/scripts/indexnow.check.mjs - - submit: - if: github.ref == 'refs/heads/main' && github.event_name != 'pull_request' - needs: test - runs-on: ubuntu-latest - timeout-minutes: 20 - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 - with: - node-version: '22' - - name: Wait for this production deployment and submit public URLs - run: node website/scripts/indexnow.mjs submit diff --git a/README.md b/README.md index dc8cd3a..e2618d0 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ export PATH="$HOME/.local/share/9lives-runner/bin:$PATH" 9l run tests/ --timeout 5m --format json ``` -The installer uses a pinned, checksum-verified release and installs into a separate Go directory. `curl -fsSL https://9lives.run | sh` selects the same installer. Override the release or destination explicitly with `NINELIVES_VERSION` or `NINELIVES_INSTALL_DIR`; see the [migration guide](docs/MIGRATING_TO_GO.md). It does not install the SDK, project dependencies, or browsers. +The installer uses a pinned, checksum-verified release and installs into a separate Go directory. `curl -fsSL https://9lives.run | sh` selects the same installer. Override the release or destination explicitly with `NINELIVES_VERSION` or `NINELIVES_INSTALL_DIR`; see the [migration guide](docs/MIGRATING_TO_GO.md). It does not install the SDK, project dependencies, or browsers. The installer and the 9lives.run site live in the [runner repository](https://github.com/Quality-Max/9lives-runner/blob/main/docs/website.md). Alternatively, download a [prebuilt release](https://github.com/Quality-Max/9lives-runner/releases), or with Go 1.25.13 or newer: diff --git a/docs/INDEXNOW.md b/docs/INDEXNOW.md deleted file mode 100644 index fd7bcae..0000000 --- a/docs/INDEXNOW.md +++ /dev/null @@ -1,33 +0,0 @@ -# IndexNow - -Set a unique `INDEXNOW_KEY` in the production hosting environment for this -site. Generate 32 random hexadecimal characters with a password manager; do -not reuse another site's key or commit it. Preview/local builds can omit it. - -The build writes `/indexnow-key.txt` and `/indexnow-deployment.txt` into the -public output. Both generated files are gitignored. The first is the UTF-8 -ownership proof required by IndexNow; the second identifies the deployed -commit. Treat the key as a credential even though search engines must be able -to fetch its verification file. - -After changes to the site merge to `main`, the IndexNow GitHub workflow waits -for up to 15 minutes for the canonical production host to serve that exact -commit. It retrieves the ownership proof without logging it, then submits -the current flat sitemap's public URLs to `https://api.indexnow.org/indexnow`. -There is no browser key or GitHub secret, and preview deployments never submit. -The sitemap must contain only HTTPS URLs on this site's canonical host, without -query strings or fragments. Keep private/authenticated pages out of it. - -The workflow can also be run manually on `main` after a hosting configuration -change or redeployment. A missing key, failed deployment, or stale production -revision makes submission fail without sending URLs. HTTP 200 means the URLs -were received; 202 means ownership validation is pending. Neither guarantees -indexing. Check Bing Webmaster Tools > IndexNow for receipt evidence. - -This small static-site integration submits the current sitemap after a site -deploy. If removing a page, submit its deleted canonical URL separately before -removing it from the sitemap. Sitemap indexes are not supported. - -Run the focused checks with `node --test website/scripts/indexnow.check.mjs`. - -Protocol: https://www.indexnow.org/documentation diff --git a/docs/MIGRATING_TO_GO.md b/docs/MIGRATING_TO_GO.md index a3aa242..0fc0d7a 100644 --- a/docs/MIGRATING_TO_GO.md +++ b/docs/MIGRATING_TO_GO.md @@ -12,11 +12,11 @@ export PATH="$HOME/.local/share/9lives-runner/bin:$PATH" 9l --version ``` -The root endpoint (`curl -fsSL https://9lives.run | sh`) serves the same script to non-HTML clients. The default release is pinned to `v0.1.1`; choose another stable release or an absolute destination explicitly: +The root endpoint (`curl -fsSL https://9lives.run | sh`) serves the same script to non-HTML clients. The default release is pinned to `v0.4.0`; choose another stable release or an absolute destination explicitly. The installer and the 9lives.run site are maintained in the [runner repository](https://github.com/Quality-Max/9lives-runner/blob/main/docs/website.md): ```sh curl -fsSL https://9lives.run/install.sh | \ - NINELIVES_VERSION=v0.1.1 NINELIVES_INSTALL_DIR="$HOME/.local/share/9lives-runner/bin" sh + NINELIVES_VERSION=v0.4.0 NINELIVES_INSTALL_DIR="$HOME/.local/share/9lives-runner/bin" sh ``` The installer prints the PATH command and does not edit shell profiles. Its separate default directory leaves Python's entry points available. If a custom destination already contains a symlink or another CLI called `9l`, installation stops rather than overwriting it. Update any scripts that relied on the old Python installer to use `pip install 9lives` or `uv tool install 9lives` explicitly. @@ -24,7 +24,7 @@ The installer prints the PATH command and does not edit shell profiles. Its sepa Alternatively, download a pinned [release and verify its checksum](https://github.com/Quality-Max/9lives-runner/blob/main/docs/install.md), or with Go 1.25.13 or newer: ```sh -go install github.com/Quality-Max/9lives-runner/cmd/9l@v0.1.1 +go install github.com/Quality-Max/9lives-runner/cmd/9l@v0.4.0 "$(go env GOPATH)/bin/9l" --version ``` diff --git a/docs/marketing/meme-assets.md b/docs/marketing/meme-assets.md deleted file mode 100644 index 566e951..0000000 --- a/docs/marketing/meme-assets.md +++ /dev/null @@ -1,36 +0,0 @@ -# 9lives launch meme assets - -Created with the built-in image generation tool, using the approved Product Hunt thumbnail as the brand reference: fluffy black Qmax kitten, violet collar, cyan terminal tag, ivory background, and bold indigo typography. These are illustrated developer jokes, not product screenshots or benchmarks. - -Production assets are 1024 × 1024 WebP at quality 84. The gallery uses explicit dimensions, lazy loading, descriptive alt text, and links to the full images. - -Hosted on **9lives.run only**. QualityMax's homepage has no meme gallery. The GitHub README links to the gallery. - -| Asset | Production path | -| --- | --- | -| button-rename | `website/public/memes/button-rename-launch.webp` | -| harry-potter | `website/public/memes/harry-potter-launch.webp` | -| lotr | `website/public/memes/lotr-launch.webp` | -| batman-catwoman | `website/public/memes/batman-catwoman-launch.webp` | - -The older non-launch assets remain hosted to preserve existing social and email URLs. The email PNG is unchanged. - -## Prompt set - -Common style: match the supplied approved launch thumbnail's black Qmax kitten, violet collar, cyan terminal tag, ivory background, indigo typography, and generous margins. Square raster, expressive cats, text readable on mobile. Preserve the mascot identity. Laptop screens show only FAIL or PASS; no invented metrics or product UI. - -### button-rename - -Two-panel split. Headline: "ONE BUTTON RENAME." Left: black kitten dramatically slumped over a laptop showing red FAIL, caption "MY AFTERNOON". Right: same kitten calmly sitting beside laptop with green PASS, caption "AFTER THE RE-RUN". Bottom: "Repair the locator. Keep the assertion." Footer "9lives.run". - -### harry-potter - -Magical-school cat parody. Headline "YOU'RE A TESTER, HARRY." Two panels. Left: reference black kitten in round glasses with small wizard scarf, startled by a broken wooden wand and red FAIL laptop. Label "LOCATORUS BROKENUS". Right: same black kitten with whimsical violet wizard hat and wand, calm beside green PASS laptop. Label "RERUNUS VERIFIEDUS". Bottom "Even magic needs a re-run." Footer "9lives.run". No human likenesses. - -### lotr - -Epic fantasy cat parody. Headline "ONE DOES NOT SIMPLY". Central black kitten wearing a tiny grey wizard cloak, looking gravely at a red FAIL laptop, one paw raised in an incredulous gesture. Soft pale mountain silhouette behind. Second large caption beneath "RENAME A BUTTON." Small lower vignette same kitten calm at green PASS laptop. Bottom "Repair. Re-run. Review." Footer "9lives.run". Keep reference kitten face clearly visible. - -### batman-catwoman - -Original feline superhero parody. Headline "THE BUTTON MOVED." Two panels. Left: fluffy black kitten wearing homemade bat-eared dark cape, exhausted at red FAIL laptop, caption "ANOTHER LONG NIGHT". Right: original black kitten with violet collar and cyan tag, tiny violet eye mask and flowing violet cape, confident beside green PASS laptop, caption "NINE LIVES". Bottom "Keep the assertion. Verify the fix." Footer "9lives.run". Original cats only, no official superhero symbols or human likenesses. diff --git a/install/9l.run.sh b/install/9l.run.sh deleted file mode 100755 index 0481002..0000000 --- a/install/9l.run.sh +++ /dev/null @@ -1,115 +0,0 @@ -#!/bin/sh -# Install the Go 9lives runner from a checksum-verified GitHub release. -# Served at https://9lives.run/install.sh (and the non-HTML root endpoint). -# Python compatibility: pip install 9lives / uv tool install 9lives. -set -eu - -VERSION="${NINELIVES_VERSION:-v0.4.0}" -INSTALL_DIR="${NINELIVES_INSTALL_DIR:-$HOME/.local/share/9lives-runner/bin}" -REPOSITORY="https://github.com/Quality-Max/9lives-runner" - -fail() { printf '9lives: %s\n' "$1" >&2; exit 1; } -say() { printf '9lives: %s\n' "$1"; } - -case "$VERSION" in - *[!v0-9.]*) fail "NINELIVES_VERSION must be a stable tag such as v0.4.0" ;; -esac -printf '%s\n' "$VERSION" | grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+$' || - fail "NINELIVES_VERSION must be a stable tag such as v0.4.0" -case "$INSTALL_DIR" in - /*) ;; - *) fail "NINELIVES_INSTALL_DIR must be an absolute directory path" ;; -esac -case "$(uname -s)" in - Darwin) platform=darwin ;; - Linux) platform=linux ;; - *) fail "supported platforms: macOS and Linux; use Python for other existing workflows" ;; -esac -case "$(uname -m)" in - arm64|aarch64) architecture=arm64 ;; - x86_64|amd64) architecture=amd64 ;; - *) fail "supported architectures: amd64 and arm64" ;; -esac - -for dependency in curl tar awk; do - command -v "$dependency" >/dev/null 2>&1 || fail "required command missing: $dependency" -done -if command -v sha256sum >/dev/null 2>&1; then - checksum_tool=sha256sum -elif command -v shasum >/dev/null 2>&1; then - checksum_tool=shasum -else - fail "install sha256sum or shasum to verify the release" -fi - -# Keep Go separate from uv's Python entry points. Never follow or replace a -# foreign executable when a caller chooses a custom installation directory. -if [ -L "$INSTALL_DIR/9l" ]; then - fail "destination 9l is a symlink; choose a separate NINELIVES_INSTALL_DIR" -fi -if [ -e "$INSTALL_DIR/9l" ]; then - [ -f "$INSTALL_DIR/9l" ] && [ -x "$INSTALL_DIR/9l" ] || - fail "destination 9l is not an executable file" - case "$("$INSTALL_DIR/9l" --version 2>/dev/null)" in - "9l "*" (Go runner)") ;; - *) fail "destination 9l belongs to another CLI; choose a separate NINELIVES_INSTALL_DIR" ;; - esac -fi - -task_tmp="$(mktemp -d "${TMPDIR:-/tmp}/9lives-install.XXXXXXXX")" -staged_binary="" -cleanup() { - if [ -n "$staged_binary" ]; then rm -f "$staged_binary"; fi - rm -rf "$task_tmp" -} -trap cleanup EXIT -trap 'exit 1' HUP INT TERM - -bundle="9l-$platform-$architecture" -archive="$bundle.tar.gz" -release="$REPOSITORY/releases/download/$VERSION" -say "downloading Go runner $VERSION for $platform/$architecture" -curl --proto '=https' --proto-redir '=https' --tlsv1.2 -fsSL "$release/$archive" -o "$task_tmp/$archive" -curl --proto '=https' --proto-redir '=https' --tlsv1.2 -fsSL "$release/SHA256SUMS" -o "$task_tmp/SHA256SUMS" -awk -v archive="$archive" 'NF == 2 && $2 == archive { print }' "$task_tmp/SHA256SUMS" > "$task_tmp/selected.sha256" -[ "$(awk 'END { print NR }' "$task_tmp/selected.sha256")" -eq 1 ] || - fail "release must contain exactly one checksum for $archive" -( - cd "$task_tmp" - if [ "$checksum_tool" = sha256sum ]; then - sha256sum -c selected.sha256 - else - shasum -a 256 -c selected.sha256 - fi -) || fail "release checksum verification failed" - -tar -xzf "$task_tmp/$archive" -C "$task_tmp" -for artifact in 9l LICENSE NOTICE; do - [ -f "$task_tmp/$bundle/$artifact" ] && [ ! -L "$task_tmp/$bundle/$artifact" ] || - fail "release is missing a regular $artifact file" -done -release_identity="$("$task_tmp/$bundle/9l" --version)" || fail "release binary cannot run on this machine" -case "$release_identity" in - "9l ${VERSION#v} (Go runner)") ;; - *) fail "release binary version does not match $VERSION" ;; -esac -mkdir -p "$INSTALL_DIR" -staged_binary="$(mktemp "$INSTALL_DIR/.9l-install.XXXXXXXX")" -cp "$task_tmp/$bundle/9l" "$staged_binary" -chmod 755 "$staged_binary" -cp "$task_tmp/$bundle/LICENSE" "$INSTALL_DIR/LICENSE" -cp "$task_tmp/$bundle/NOTICE" "$INSTALL_DIR/NOTICE" -mv -f "$staged_binary" "$INSTALL_DIR/9l" -staged_binary="" - -say "installed: $release_identity" -say "binary: $INSTALL_DIR/9l" -say "add this directory to PATH, then check '9l --version' selects the Go runner:" -# Quote the path for a POSIX shell even when it contains spaces or apostrophes. -quoted_dir="$(printf '%s' "$INSTALL_DIR" | sed "s/'/'\\\\''/g")" -printf " export PATH='%s':\"\$PATH\"\n" "$quoted_dir" -say "from an existing Playwright project: 9l plan tests/ && 9l run tests/" -say "check the page you changed: 9l scan https://your-app.dev --format markdown" -say "install project dependencies and Playwright browsers separately" -say "guide: https://quality-max.github.io/9lives-runner/" -say "Python compatibility remains available via '9lives' or 'python -m ninelives.cli'" diff --git a/tests/test_installer.py b/tests/test_installer.py deleted file mode 100644 index d5d222d..0000000 --- a/tests/test_installer.py +++ /dev/null @@ -1,240 +0,0 @@ -"""Exercise the public Go installer without network access or global installs.""" - -import hashlib -import os -import shlex -import subprocess -import sys -import tarfile -from pathlib import Path - -import pytest - -ROOT = Path(__file__).parents[1] -SCRIPT = ROOT / "install" / "9l.run.sh" - - -def executable(path, content): - path.write_text(content) - path.chmod(0o755) - - -@pytest.fixture -def installer(tmp_path): - assets = tmp_path / "assets" - assets.mkdir() - commands = tmp_path / "commands" - commands.mkdir() - temporary = tmp_path / "temporary" - temporary.mkdir() - destination = tmp_path / "Go tools's bin" - requests = tmp_path / "requests" - executable( - commands / "uname", - '#!/bin/sh\ncase "$1" in -s) echo "$INSTALLER_TEST_OS";; -m) echo "$INSTALLER_TEST_ARCH";; esac\n', - ) - executable( - commands / "curl", - f"#!{sys.executable}\n" - "import os, pathlib, shutil, sys\n" - "args = sys.argv[1:]\n" - "url = args[args.index('-o') - 1]\n" - "assert url.startswith('https://github.com/Quality-Max/9lives-runner/releases/download/v0.1.1/')\n" - "with open(os.environ['INSTALLER_TEST_REQUESTS'], 'a') as log: log.write(url + '\\n')\n" - "shutil.copyfile(pathlib.Path(os.environ['INSTALLER_TEST_ASSETS']) / url.rsplit('/', 1)[1], " - "args[args.index('-o') + 1])\n", - ) - environment = { - **os.environ, - "PATH": f"{commands}{os.pathsep}{os.environ['PATH']}", - "TMPDIR": str(temporary), - "NINELIVES_VERSION": "v0.1.1", - "NINELIVES_INSTALL_DIR": str(destination), - "INSTALLER_TEST_OS": "Darwin", - "INSTALLER_TEST_ARCH": "arm64", - "INSTALLER_TEST_ASSETS": str(assets), - "INSTALLER_TEST_REQUESTS": str(requests), - } - - def package(bundle="9l-darwin-arm64", missing=None, version="0.1.1"): - source = tmp_path / bundle - source.mkdir() - executable(source / "9l", f"#!/bin/sh\nprintf '9l {version} (Go runner)\\n'\n") - (source / "LICENSE").write_text("Apache-2.0 fixture\n") - (source / "NOTICE").write_text("Release notice fixture\n") - archive = assets / f"{bundle}.tar.gz" - with tarfile.open(archive, "w:gz") as tar: - for artifact in source.iterdir(): - if artifact.name != missing: - tar.add(artifact, arcname=f"{bundle}/{artifact.name}") - digest = hashlib.sha256(archive.read_bytes()).hexdigest() - (assets / "SHA256SUMS").write_text(f"{digest} {archive.name}\n") - return source - - def run(): - return subprocess.run(["sh", str(SCRIPT)], env=environment, capture_output=True, text=True, timeout=30, check=False) - - return package, run, environment, destination, assets, requests, temporary - - -@pytest.mark.parametrize( - ("system", "architecture", "bundle"), - [ - ("Darwin", "arm64", "9l-darwin-arm64"), - ("Darwin", "x86_64", "9l-darwin-amd64"), - ("Linux", "aarch64", "9l-linux-arm64"), - ("Linux", "x86_64", "9l-linux-amd64"), - ], -) -def test_installs_verified_platform_and_prints_usable_path(installer, system, architecture, bundle): - package, run, environment, destination, _, requests, temporary = installer - environment.update(INSTALLER_TEST_OS=system, INSTALLER_TEST_ARCH=architecture) - source = package(bundle) - result = run() - assert result.returncode == 0, result.stderr - assert requests.read_text().splitlines() == [ - f"https://github.com/Quality-Max/9lives-runner/releases/download/v0.1.1/{bundle}.tar.gz", - "https://github.com/Quality-Max/9lives-runner/releases/download/v0.1.1/SHA256SUMS", - ] - for artifact in ("9l", "LICENSE", "NOTICE"): - assert (destination / artifact).read_bytes() == (source / artifact).read_bytes() - assert os.access(destination / "9l", os.X_OK) - path_line = next(line.strip() for line in result.stdout.splitlines() if line.strip().startswith("export PATH=")) - selected = subprocess.run( - ["sh", "-c", path_line + "; command -v 9l; 9l --version"], - env=environment, - capture_output=True, - text=True, - timeout=10, - check=False, - ) - assert selected.returncode == 0, selected.stderr - assert selected.stdout.splitlines() == [str(destination / "9l"), "9l 0.1.1 (Go runner)"] - assert list(temporary.iterdir()) == [] - assert not list(destination.glob(".9l-install.*")) - - -@pytest.mark.parametrize("checksum", ["mismatch", "missing", "duplicate"]) -def test_bad_checksums_never_install_or_replace_a_binary(installer, checksum): - package, run, _, destination, assets, _, temporary = installer - package() - destination.mkdir() - original = "#!/bin/sh\necho '9l 0.0.0 (Go runner)'\n" - executable(destination / "9l", original) - manifest = assets / "SHA256SUMS" - if checksum == "mismatch": - manifest.write_text("0" * 64 + " 9l-darwin-arm64.tar.gz\n") - elif checksum == "missing": - manifest.write_text("0" * 64 + " unrelated.tar.gz\n") - else: - manifest.write_text(manifest.read_text() * 2) - result = run() - assert result.returncode != 0 - assert "checksum" in result.stderr - assert (destination / "9l").read_text() == original - assert list(destination.iterdir()) == [destination / "9l"] - assert list(temporary.iterdir()) == [] - - -def test_missing_release_artifact_never_installs(installer): - package, run, _, destination, _, _, temporary = installer - package(missing="NOTICE") - result = run() - assert result.returncode != 0 - assert "missing a regular NOTICE" in result.stderr - assert not destination.exists() - assert list(temporary.iterdir()) == [] - - -def test_wrong_release_identity_preserves_existing_go_binary(installer): - package, run, _, destination, _, _, temporary = installer - package(version="0.1.0") - destination.mkdir() - original = "#!/bin/sh\necho '9l 0.0.0 (Go runner)'\n" - executable(destination / "9l", original) - result = run() - assert result.returncode != 0 - assert "version does not match" in result.stderr - assert (destination / "9l").read_text() == original - assert list(temporary.iterdir()) == [] - - -@pytest.mark.parametrize("symlink", [False, True]) -def test_preserves_python_executable_and_symlinks(installer, symlink): - _, run, _, destination, assets, requests, _ = installer - destination.mkdir() - python_cli = assets / "python-cli" - original = "#!/bin/sh\necho '9lives 0.2.1'\n" - executable(python_cli, original) - if symlink: - (destination / "9l").symlink_to(python_cli) - else: - executable(destination / "9l", original) - result = run() - assert result.returncode != 0 - assert "choose a separate NINELIVES_INSTALL_DIR" in result.stderr - assert python_cli.read_text() == original - assert (destination / "9l").read_text() == original - assert (destination / "9l").is_symlink() == symlink - assert not requests.exists() - - -def test_can_update_its_existing_go_binary(installer): - package, run, _, destination, _, _, _ = installer - source = package() - destination.mkdir() - executable(destination / "9l", "#!/bin/sh\necho '9l 0.0.0 (Go runner)'\n") - assert run().returncode == 0 - assert (destination / "9l").read_bytes() == (source / "9l").read_bytes() - - -@pytest.mark.parametrize( - ("key", "value"), - [ - ("INSTALLER_TEST_OS", "Windows_NT"), - ("INSTALLER_TEST_ARCH", "riscv64"), - ("NINELIVES_VERSION", "v0x1x1"), - ("NINELIVES_VERSION", "v0.1.1\nextra-line"), - ("NINELIVES_VERSION", "../../main"), - ("NINELIVES_INSTALL_DIR", "relative/bin"), - ], -) -def test_rejects_unsupported_targets_before_download(installer, key, value): - _, run, environment, _, _, requests, _ = installer - environment[key] = value - assert run().returncode != 0 - assert not requests.exists() - - -def test_python_integration_commands_ignore_foreign_9l(tmp_path): - """A Go executable earlier on PATH must not change Python integration identity.""" - executable(tmp_path / "9l", "#!/bin/sh\necho 'wrong engine' >&2\nexit 97\n") - environment = { - **os.environ, - "PATH": f"{tmp_path}{os.pathsep}{os.environ['PATH']}", - "PYTHONPATH": str(ROOT / "src"), - } - hook_entries = [ - line.removeprefix(" entry: ") - for line in (ROOT / ".pre-commit-hooks.yaml").read_text().splitlines() - if line.startswith(" entry: ") - ] - action_entries = [ - line.strip().split(' "${specs[@]}"')[0] - for line in (ROOT / "action" / "action.yml").read_text().splitlines() - if "ninelives.cli" in line - ] - assert len(hook_entries) == len(action_entries) == 2 - for entry in hook_entries + action_entries: - command = shlex.split(entry) - assert command[1:3] == ["-m", "ninelives.cli"] - result = subprocess.run( - [sys.executable, *command[1:3], "--version"], - env=environment, - capture_output=True, - text=True, - timeout=10, - check=False, - ) - assert result.returncode == 0 - assert result.stdout.strip() == "9lives 0.2.1" diff --git a/tests/test_integration_identity.py b/tests/test_integration_identity.py new file mode 100644 index 0000000..be5b803 --- /dev/null +++ b/tests/test_integration_identity.py @@ -0,0 +1,48 @@ +"""Python integrations select the Python CLI even when a Go 9l is on PATH.""" + +import os +import shlex +import subprocess +import sys +from pathlib import Path + +ROOT = Path(__file__).parents[1] + + +def executable(path, content): + path.write_text(content) + path.chmod(0o755) + + +def test_python_integration_commands_ignore_foreign_9l(tmp_path): + """A Go executable earlier on PATH must not change Python integration identity.""" + executable(tmp_path / "9l", "#!/bin/sh\necho 'wrong engine' >&2\nexit 97\n") + environment = { + **os.environ, + "PATH": f"{tmp_path}{os.pathsep}{os.environ['PATH']}", + "PYTHONPATH": str(ROOT / "src"), + } + hook_entries = [ + line.removeprefix(" entry: ") + for line in (ROOT / ".pre-commit-hooks.yaml").read_text().splitlines() + if line.startswith(" entry: ") + ] + action_entries = [ + line.strip().split(' "${specs[@]}"')[0] + for line in (ROOT / "action" / "action.yml").read_text().splitlines() + if "ninelives.cli" in line + ] + assert len(hook_entries) == len(action_entries) == 2 + for entry in hook_entries + action_entries: + command = shlex.split(entry) + assert command[1:3] == ["-m", "ninelives.cli"] + result = subprocess.run( + [sys.executable, *command[1:3], "--version"], + env=environment, + capture_output=True, + text=True, + timeout=10, + check=False, + ) + assert result.returncode == 0 + assert result.stdout.strip() == "9lives 0.2.1" diff --git a/tests/test_website.py b/tests/test_website.py deleted file mode 100644 index 1aa255c..0000000 --- a/tests/test_website.py +++ /dev/null @@ -1,56 +0,0 @@ -from pathlib import Path - -ROOT = Path(__file__).parents[1] -WEBSITE = ROOT / "website" - - -def test_public_installer_matches_canonical_script(): - assert (WEBSITE / "public" / "install.sh").read_bytes() == (ROOT / "install" / "9l.run.sh").read_bytes() - - -def test_landing_page_keeps_install_and_ecosystem_links(): - page = (WEBSITE / "app" / "page.tsx").read_text() - - assert "curl -fsSL https://9lives.run/install.sh | sh" in page - assert "https://quality-max.github.io/9lives-runner/" in page - assert "https://github.com/Quality-Max/9lives-runner" in page - assert page.count('href="https://qualitymax.io"') == 2 - for destination in ( - "https://aiinqa.com/ai-in-qa-issue-18/", - "https://github.com/Quality-Max/qmax-code", - "https://github.com/Quality-Max/qualitymax-grader", - "https://github.com/Quality-Max/free-qa-skills", - ): - assert destination in page - - -def test_landing_page_presents_the_page_checks_built_into_the_runner(): - page = (WEBSITE / "app" / "page.tsx").read_text() - - assert "https://qmax.run" not in page - assert "9l scan https://your-app.dev --format markdown" in page - assert "claude mcp add 9lives -- 9l mcp" in page - for tool in ("scan_url", "inspect_page", "generate_playwright_repro", "confirm_finding"): - assert tool in page - - -def test_installer_defaults_to_the_release_with_page_checks(): - installer = (ROOT / "install" / "9l.run.sh").read_text() - - assert 'VERSION="${NINELIVES_VERSION:-v0.4.0}"' in installer - assert "9l scan" in installer - - -def test_site_uses_the_original_qualitymax_favicon(): - layout = (WEBSITE / "app" / "layout.tsx").read_text() - - assert "https://qualitymax.io/static/img/favicon-color-round.png" in layout - - -def test_root_uses_accept_header_for_installer_content_negotiation(): - proxy = (WEBSITE / "proxy.ts").read_text() - - assert "text/html" in proxy - assert "'/install.sh'" in proxy - assert "NextResponse.redirect" in proxy - assert "matcher: ['/']" in proxy diff --git a/website/.gitignore b/website/.gitignore deleted file mode 100644 index b3f7655..0000000 --- a/website/.gitignore +++ /dev/null @@ -1,42 +0,0 @@ -# dependencies -/node_modules -/.pnp -.pnp.* -.yarn/* -!.yarn/patches -!.yarn/plugins -!.yarn/releases -!.yarn/versions - -# testing -/coverage - -# next.js -/.next/ -/.vinext/ -/out/ - -# misc -.DS_Store -*.pem - -# debug -npm-debug.log* -yarn-debug.log* -yarn-error.log* -.pnpm-debug.log* - -# local environment -.env* - -# deployment and generated files -.vercel -next-env.d.ts -/dist/ -/.wrangler/ -/outputs/ -/work/ - -# Generated IndexNow ownership files (never commit credentials) -/public/indexnow-key.txt -/public/indexnow-deployment.txt diff --git a/website/app/copy-button.test.tsx b/website/app/copy-button.test.tsx deleted file mode 100644 index 3454a9a..0000000 --- a/website/app/copy-button.test.tsx +++ /dev/null @@ -1,146 +0,0 @@ -// @vitest-environment jsdom - -import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; - -import { CopyButton } from './copy-button'; - -const installCommand = 'curl -sL 9lives.run | sh'; - -describe('CopyButton', () => { - beforeEach(() => { - vi.useFakeTimers({ shouldAdvanceTime: true }); - }); - - afterEach(() => { - cleanup(); - vi.useRealTimers(); - }); - - it('copies the command and resets its success message', async () => { - const writeText = vi.fn().mockResolvedValue(undefined); - Object.defineProperty(navigator, 'clipboard', { - configurable: true, - value: { writeText }, - }); - - render(); - fireEvent.click(screen.getByRole('button')); - - await waitFor(() => expect(screen.getByText('Copied')).toBeTruthy()); - expect(writeText).toHaveBeenCalledWith(installCommand); - - act(() => vi.advanceTimersByTime(1800)); - expect(screen.getByText('Copy')).toBeTruthy(); - }); - - it('shows a recoverable message when clipboard access fails', async () => { - const writeText = vi.fn().mockRejectedValue(new Error('denied')); - Object.defineProperty(navigator, 'clipboard', { - configurable: true, - value: { writeText }, - }); - - render(); - fireEvent.click(screen.getByRole('button')); - - await waitFor(() => expect(screen.getByText('Copy failed')).toBeTruthy()); - - await act(async () => fireEvent.click(screen.getByRole('button'))); - expect(writeText).toHaveBeenCalledTimes(2); - }); - - it('handles browsers without the Clipboard API', async () => { - Object.defineProperty(navigator, 'clipboard', { - configurable: true, - value: undefined, - }); - - render(); - fireEvent.click(screen.getByRole('button')); - - await waitFor(() => expect(screen.getByText('Copy failed')).toBeTruthy()); - }); - - it('clears its reset timer when unmounted', async () => { - Object.defineProperty(navigator, 'clipboard', { - configurable: true, - value: { writeText: vi.fn().mockResolvedValue(undefined) }, - }); - const clearTimeoutSpy = vi.spyOn(window, 'clearTimeout'); - - const { unmount } = render(); - fireEvent.click(screen.getByRole('button')); - await waitFor(() => expect(screen.getByText('Copied')).toBeTruthy()); - unmount(); - - expect(clearTimeoutSpy).toHaveBeenCalled(); - }); - - it('replaces the pending reset timer on a subsequent copy', async () => { - Object.defineProperty(navigator, 'clipboard', { - configurable: true, - value: { writeText: vi.fn().mockResolvedValue(undefined) }, - }); - const clearTimeoutSpy = vi.spyOn(window, 'clearTimeout'); - const setTimeoutSpy = vi.spyOn(window, 'setTimeout'); - - render(); - fireEvent.click(screen.getByRole('button')); - await waitFor(() => expect(screen.getByText('Copied')).toBeTruthy()); - - clearTimeoutSpy.mockClear(); - setTimeoutSpy.mockClear(); - await act(async () => fireEvent.click(screen.getByRole('button'))); - - expect(clearTimeoutSpy).toHaveBeenCalledTimes(1); - expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), 1800); - expect(screen.getByText('Copied')).toBeTruthy(); - }); - - it('ignores concurrent copy attempts until the active write finishes', async () => { - let finishCopy: (() => void) | undefined; - const pendingCopy = new Promise((resolve) => { - finishCopy = resolve; - }); - const writeText = vi - .fn() - .mockReturnValueOnce(pendingCopy) - .mockResolvedValueOnce(undefined); - Object.defineProperty(navigator, 'clipboard', { - configurable: true, - value: { writeText }, - }); - - render(); - fireEvent.click(screen.getByRole('button')); - fireEvent.click(screen.getByRole('button')); - - expect(writeText).toHaveBeenCalledTimes(1); - - await act(async () => finishCopy?.()); - expect(screen.getByText('Copied')).toBeTruthy(); - - await act(async () => fireEvent.click(screen.getByRole('button'))); - expect(writeText).toHaveBeenCalledTimes(2); - }); - - it('does not update state or create a timer when copying finishes after unmount', async () => { - let finishCopy: (() => void) | undefined; - const pendingCopy = new Promise((resolve) => { - finishCopy = resolve; - }); - Object.defineProperty(navigator, 'clipboard', { - configurable: true, - value: { writeText: vi.fn().mockReturnValue(pendingCopy) }, - }); - const setTimeoutSpy = vi.spyOn(window, 'setTimeout'); - - const { unmount } = render(); - fireEvent.click(screen.getByRole('button')); - unmount(); - await act(async () => finishCopy?.()); - - expect(setTimeoutSpy).not.toHaveBeenCalledWith(expect.any(Function), 1800); - }); -}); diff --git a/website/app/copy-button.tsx b/website/app/copy-button.tsx deleted file mode 100644 index c752829..0000000 --- a/website/app/copy-button.tsx +++ /dev/null @@ -1,68 +0,0 @@ -'use client'; - -import { useEffect, useRef, useState } from 'react'; - -export function CopyButton({ value }: { value: string }) { - const [status, setStatus] = useState<'idle' | 'copied' | 'failed'>('idle'); - const mounted = useRef(true); - const copying = useRef(false); - const resetTimer = useRef(null); - - useEffect(() => { - mounted.current = true; - - return () => { - mounted.current = false; - if (resetTimer.current !== null) { - window.clearTimeout(resetTimer.current); - } - }; - }, []); - - async function copy() { - if (copying.current) { - return; - } - - copying.current = true; - - if (resetTimer.current !== null) { - window.clearTimeout(resetTimer.current); - resetTimer.current = null; - } - - let nextStatus: 'copied' | 'failed'; - - try { - if (!navigator.clipboard?.writeText) { - throw new Error('Clipboard API unavailable'); - } - - await navigator.clipboard.writeText(value); - nextStatus = 'copied'; - } catch { - nextStatus = 'failed'; - } finally { - copying.current = false; - } - - if (!mounted.current) { - return; - } - - setStatus(nextStatus); - resetTimer.current = window.setTimeout(() => { - if (mounted.current) { - setStatus('idle'); - } - }, 1800); - } - - return ( - - ); -} diff --git a/website/app/globals.css b/website/app/globals.css deleted file mode 100644 index 616ca2e..0000000 --- a/website/app/globals.css +++ /dev/null @@ -1,188 +0,0 @@ -@import 'tailwindcss'; - -:root { - --ink: #f7f6ff; - --muted: #aaa5c4; - --surface: #100e1a; - --surface-2: #171426; - --line: rgba(255, 255, 255, 0.1); - --violet: #9d7cff; - --cyan: #55e6ff; - --pink: #ff68b5; -} - -* { box-sizing: border-box; } -html { scroll-behavior: smooth; } -body { - margin: 0; - background: #090812; - color: var(--ink); - font-family: var(--font-geist-sans), Arial, sans-serif; -} -a { color: inherit; text-decoration: none; } -code { font-family: var(--font-geist-mono), monospace; } - -.site-header { - position: relative; - z-index: 10; - display: flex; - min-height: 74px; - align-items: center; - justify-content: space-between; - max-width: 1180px; - margin: 0 auto; - padding: 0 28px; - border-bottom: 1px solid var(--line); -} -.qualitymax-brand img { display: block; width: 142px; height: auto; } -.site-header nav { display: flex; align-items: center; gap: 28px; color: var(--muted); font-size: 14px; } -.site-header nav a:hover, .site-header nav a:focus-visible { color: #fff; } -.nav-github { color: #fff !important; padding: 9px 14px; border: 1px solid var(--line); border-radius: 10px; } - -.hero { - position: relative; - isolation: isolate; - max-width: 1000px; - margin: 0 auto; - padding: 110px 28px 104px; - text-align: center; - overflow: hidden; -} -.hero-glow { - position: absolute; - z-index: -1; - width: 720px; - height: 520px; - top: -130px; - left: 50%; - translate: -50% 0; - border-radius: 50%; - background: radial-gradient(circle, rgba(119, 74, 255, .23), rgba(35, 214, 255, .07) 43%, transparent 70%); - filter: blur(12px); -} -.product-mark { margin: 0 0 24px; font-weight: 900; letter-spacing: .14em; color: #fff; } -.product-mark span { font-size: 26px; vertical-align: -3px; } -.eyebrow { display: flex; align-items: center; justify-content: center; gap: 12px; margin: 0 0 22px; color: var(--cyan); font-size: 12px; letter-spacing: .15em; text-transform: uppercase; } -.eyebrow span { width: 28px; height: 1px; background: currentColor; opacity: .55; } -.hero h1 { margin: 0; font-size: clamp(58px, 9vw, 104px); line-height: .9; letter-spacing: -.075em; font-weight: 820; } -.hero h1 em { font-style: normal; color: transparent; background: linear-gradient(90deg, var(--violet), var(--pink) 52%, var(--cyan)); background-clip: text; -webkit-background-clip: text; } -.hero-copy { max-width: 720px; margin: 32px auto 38px; color: var(--muted); font-size: 18px; line-height: 1.7; } -.command-stack { display: grid; grid-template-columns: 1.1fr .9fr; gap: 12px; max-width: 850px; margin: 0 auto; text-align: left; } -.command-card { position: relative; min-width: 0; padding: 15px 82px 15px 18px; border: 1px solid var(--line); border-radius: 14px; background: rgba(18, 15, 31, .88); box-shadow: 0 18px 60px rgba(0,0,0,.25); } -.primary-command { border-color: rgba(157, 124, 255, .48); } -.command-label { display: block; margin-bottom: 7px; color: var(--muted); font-size: 11px; text-transform: uppercase; letter-spacing: .12em; } -.command-card code { display: block; color: #fff; overflow-x: auto; white-space: nowrap; font-size: 14px; scrollbar-width: none; } -.command-card button, .inline-command button { position: absolute; right: 10px; top: 50%; translate: 0 -50%; border: 1px solid var(--line); border-radius: 8px; padding: 8px 10px; color: #ddd8ef; background: #211c35; cursor: pointer; } -.command-card button:hover, .command-card button:focus-visible, .inline-command button:hover, .inline-command button:focus-visible { outline: 2px solid var(--cyan); outline-offset: 2px; } -.trust-line { margin: 20px 0 0; color: #78738f; font-size: 12px; } -.trust-line span { margin: 0 8px; color: #4b465e; } -.hero-note { max-width: 720px; margin: 20px auto 0; color: var(--muted); font-size: 14px; line-height: 1.7; } -.hero-note a, .compatibility-copy a { color: var(--cyan); text-decoration: underline; text-underline-offset: 3px; } -.compatibility-copy { max-width: 820px; color: var(--muted); line-height: 1.7; } -#python .inline-command { margin: 24px 0; } -.command-stack .command-card:last-child:nth-child(odd) { grid-column: 1 / -1; } -.featured-links { display: flex; flex-wrap: wrap; justify-content: center; gap: 10px; margin-top: 20px; } -.featured-link { display: inline-flex; align-items: center; gap: 5px; padding: 8px 12px; border: 1px solid rgba(85, 230, 255, .18); border-radius: 999px; color: #9f9ab5; background: rgba(85, 230, 255, .04); font-size: 12px; transition: border-color .2s, color .2s, background .2s; } -.featured-link strong { color: #dbd7ea; } -.featured-link > span { margin-left: 2px; color: var(--cyan); } -.featured-link:hover, .featured-link:focus-visible { border-color: rgba(85, 230, 255, .5); color: #fff; background: rgba(85, 230, 255, .08); outline: none; } - -.section { max-width: 1180px; margin: 0 auto; padding: 104px 28px; } -.section-heading { max-width: 690px; margin-bottom: 46px; } -.section-heading > p { color: var(--cyan); font-size: 11px; letter-spacing: .18em; font-weight: 750; } -.section-heading h2 { margin: 10px 0 15px; font-size: clamp(36px, 5vw, 58px); line-height: 1.02; letter-spacing: -.05em; } -.section-heading > span { color: var(--muted); font-size: 17px; line-height: 1.6; } -.meme-section { padding-top: 24px; } -.meme-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 24px; } -.meme-card { margin: 0; overflow: hidden; border: 1px solid var(--line); border-radius: 18px; background: var(--surface); } -.meme-card > a { display: block; } -.meme-card > a:focus-visible { outline: 3px solid var(--cyan); outline-offset: -4px; } -.meme-card img { display: block; width: 100%; height: auto; aspect-ratio: 1; } -.meme-card figcaption { padding: 22px; } -.meme-card strong { font-size: 20px; } -.meme-card p, .meme-note { color: var(--muted); font-size: 14px; line-height: 1.7; } -.meme-card p { margin: 8px 0 0; } -.meme-note { margin: 22px 0 0; } -.meme-note a { color: var(--cyan); text-decoration: underline; text-underline-offset: 3px; } -.step-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 14px; } -.step-card { min-height: 248px; padding: 26px; border: 1px solid var(--line); border-radius: 18px; background: linear-gradient(150deg, rgba(29, 24, 46, .93), rgba(14, 12, 24, .96)); } -.step-number { display: block; color: var(--violet); font: 700 12px var(--font-geist-mono), monospace; } -.step-card h3 { margin: 62px 0 12px; font-size: 23px; } -.step-card p { margin: 0; color: var(--muted); line-height: 1.6; } -.safety-banner { display: flex; gap: 18px; align-items: center; margin-top: 16px; padding: 22px 24px; border: 1px solid rgba(255, 104, 181, .28); border-radius: 16px; background: rgba(255, 104, 181, .06); } -.safety-icon { display: grid; flex: 0 0 38px; height: 38px; place-items: center; border: 1px solid rgba(255, 104, 181, .4); border-radius: 50%; color: var(--pink); font-weight: 800; } -.safety-banner strong { font-size: 16px; } -.safety-banner p { margin: 5px 0 0; color: var(--muted); line-height: 1.5; } -.safety-banner code { color: #fff; } - -.framework-strip { display: flex; justify-content: center; align-items: center; gap: clamp(20px, 5vw, 64px); padding: 30px 20px; border-block: 1px solid var(--line); color: #d8d3e9; font-size: clamp(13px, 2vw, 17px); letter-spacing: .16em; font-weight: 760; } -.framework-strip i { color: #4f4961; font-style: normal; } - -.ecosystem-section { padding-bottom: 120px; } -.ecosystem-grid { display: grid; grid-template-columns: repeat(6, 1fr); gap: 14px; } -.ecosystem-card { position: relative; grid-column: span 3; min-height: 230px; padding: 24px; border: 1px solid var(--line); border-radius: 18px; background: var(--surface); transition: border-color .2s, translate .2s, background .2s; } -.ecosystem-card:hover, .ecosystem-card:focus-visible { translate: 0 -4px; border-color: rgba(157, 124, 255, .55); background: var(--surface-2); outline: none; } -.ecosystem-card > span { color: var(--violet); font-size: 11px; letter-spacing: .12em; text-transform: uppercase; } -.ecosystem-card h3 { margin: 28px 0 10px; font-size: 22px; } -.ecosystem-card p { max-width: 320px; margin: 0; color: var(--muted); line-height: 1.55; } -.ecosystem-card b { position: absolute; top: 21px; right: 22px; font-size: 18px; color: #69627e; } - -.final-cta { padding: 112px 28px; text-align: center; background: radial-gradient(circle at 50% 10%, rgba(138, 96, 255, .18), transparent 55%); border-top: 1px solid var(--line); } -.final-cta > p { margin: 0 0 16px; font-size: 38px; } -.final-cta h2 { margin: 0 0 32px; font-size: clamp(38px, 6vw, 68px); letter-spacing: -.055em; } -.inline-command { position: relative; display: flex; align-items: center; max-width: 460px; min-height: 54px; margin: 0 auto 24px; padding: 0 82px 0 18px; border: 1px solid rgba(157, 124, 255, .5); border-radius: 13px; background: #151123; text-align: left; } -.inline-command code { white-space: nowrap; font-size: 14px; overflow: auto; scrollbar-width: none; } -.final-cta > a { color: var(--cyan); font-size: 14px; } - -footer { display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; gap: 24px; max-width: 1180px; margin: 0 auto; padding: 34px 28px; border-top: 1px solid var(--line); color: #77728b; font-size: 12px; } -footer .qualitymax-brand img { width: 122px; } -footer p { margin: 0; text-align: center; } -footer > span { text-align: right; } -footer .legal-links { display: flex; flex-wrap: wrap; justify-content: center; gap: 8px 24px; grid-column: 1 / -1; font-size: 14px; } -footer .legal-links a { display: inline-flex; align-items: center; min-height: 44px; color: #d8d3e9; text-decoration: underline; text-underline-offset: 4px; } -footer .legal-links a:hover { color: var(--cyan); } -footer .legal-links a:focus-visible { outline: 2px solid var(--cyan); outline-offset: 4px; border-radius: 2px; } -footer .service-legal { grid-column: 1 / -1; } -footer .service-legal p, footer .legal-note { color: var(--muted); } -footer .legal-note { grid-column: 1 / -1; } - -@media (max-width: 760px) { - .site-header { padding: 0 18px; } - .site-header nav a:not(.nav-github) { display: none; } - .hero { padding: 78px 18px 80px; } - .hero h1 { font-size: clamp(54px, 18vw, 78px); } - .hero-copy { font-size: 16px; } - .command-stack, .step-grid { grid-template-columns: 1fr; } - .section { padding: 78px 18px; } - .meme-section { padding-top: 0; } - .meme-grid { grid-template-columns: 1fr; gap: 18px; } - .step-card { min-height: 210px; } - .step-card h3 { margin-top: 42px; } - .ecosystem-grid { grid-template-columns: 1fr; } - .ecosystem-card { grid-column: auto; } - .framework-strip { gap: 12px; font-size: 10px; letter-spacing: .08em; } - footer { grid-template-columns: 1fr; justify-items: center; } - footer p, footer > span { text-align: center; } -} - -@media (prefers-reduced-motion: reduce) { - html { scroll-behavior: auto; } - .ecosystem-card { transition: none; } -} - -.feature-tour { padding-top: 24px; } -.interactive-demo { position: relative; box-sizing: content-box; max-height: 80vh; max-height: 80svh; aspect-ratio: 1.67; padding: 40px 0; border: 1px solid var(--line); border-radius: 16px; overflow: hidden; margin-bottom: 24px; } -.interactive-demo iframe { position: absolute; top: 0; left: 0; width: 100%; height: 100%; border: 0; } -.interactive-demo iframe:focus-visible { outline: 3px solid var(--cyan); outline-offset: -4px; } -.feature-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 24px; } -.feature-grid h3 { font-size: 20px; margin: 0 0 10px; } -.feature-grid p { color: var(--muted); line-height: 1.7; margin: 0; } -@media (max-width: 760px) { .feature-grid { grid-template-columns: 1fr; } } -#page-checks .step-grid { grid-template-columns: repeat(4, minmax(0, 1fr)); } -#page-checks .step-card h3 { font-size: 20px; overflow-wrap: anywhere; } -#page-checks .feature-grid { margin: 48px 0 40px; } -#page-checks .feature-grid h3 { font-size: 17px; } -#page-checks .inline-command { margin-bottom: 24px; } -@media (max-width: 1080px) { #page-checks .step-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } } -@media (max-width: 760px) { #page-checks .step-grid { grid-template-columns: 1fr; } } - diff --git a/website/app/layout.tsx b/website/app/layout.tsx deleted file mode 100644 index ac8f0b8..0000000 --- a/website/app/layout.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import type { Metadata } from 'next'; -import { Geist, Geist_Mono } from 'next/font/google'; -import './globals.css'; - -const geistSans = Geist({ variable: '--font-geist-sans', subsets: ['latin'] }); -const geistMono = Geist_Mono({ variable: '--font-geist-mono', subsets: ['latin'] }); - -export const metadata: Metadata = { - metadataBase: new URL('https://9lives.run'), - title: '9Lives — Your tests have nine lives', - description: 'The Go runner for local Playwright tests: bounded execution, validated receipts, and optional AI help. Python compatibility remains available in maintenance mode.', - alternates: { canonical: '/' }, - openGraph: { - title: '9Lives — Your tests have nine lives', - description: 'Ordinary Playwright tests. Bounded AI help. Evidence for every attempt. Install the Go runner for macOS or Linux.', - url: 'https://9lives.run', - siteName: '9Lives', - type: 'website', - images: [{ url: '/og.png', width: 1200, height: 630, alt: '9Lives by QualityMax — Your tests have nine lives' }], - }, - twitter: { - card: 'summary_large_image', - title: '9Lives — Your tests have nine lives', - description: 'Run Playwright locally with Go, keep your assertions, and inspect attributable evidence.', - images: ['/og.png'], - }, - icons: { icon: 'https://qualitymax.io/static/img/favicon-color-round.png' }, -}; - -export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) { - return ( - - {children} - - ); -} diff --git a/website/app/page.test.tsx b/website/app/page.test.tsx deleted file mode 100644 index 0fe9edc..0000000 --- a/website/app/page.test.tsx +++ /dev/null @@ -1,79 +0,0 @@ -// @vitest-environment jsdom - -import { cleanup, render, screen } from '@testing-library/react'; -import { afterEach, describe, expect, it } from 'vitest'; - -import Home from './page'; - -afterEach(cleanup); - -describe('QualityMax branding', () => { - it('uses the canonical logo asset and links both wordmarks to QualityMax', () => { - render(); - - const brandLinks = screen.getAllByRole('link', { name: 'QualityMax home' }); - const logos = screen.getAllByRole('img', { name: 'QualityMax' }); - - expect(brandLinks).toHaveLength(2); - expect(brandLinks.every((link) => link.getAttribute('href') === 'https://qualitymax.io')).toBe(true); - expect(logos).toHaveLength(2); - expect( - logos.every( - (logo) => logo.getAttribute('src') === 'https://qualitymax.io/static/img/qualitymax-logo-white.png', - ), - ).toBe(true); - }); - - it('links to the AI in QA feature', () => { - render(); - - const featureLink = screen.getByRole('link', { name: /as featured in ai in qa — issue #18/i }); - - expect(featureLink.getAttribute('href')).toBe('https://aiinqa.com/ai-in-qa-issue-18/'); - expect(featureLink.getAttribute('target')).toBe('_blank'); - expect(featureLink.getAttribute('rel')).toBe('noreferrer'); - }); - - it('links to the awesome-python-testing listing', () => { - render(); - - const listingLink = screen.getByRole('link', { name: /python package listed on awesome-python-testing/i }); - - expect(listingLink.getAttribute('href')).toBe('https://github.com/cleder/awesome-python-testing'); - expect(listingLink.getAttribute('target')).toBe('_blank'); - expect(listingLink.getAttribute('rel')).toBe('noreferrer'); - }); -}); - -describe('Go runner onboarding', () => { - it('leads with Go installation and execution while retaining an explicit Python path', () => { - render(); - - expect(screen.getAllByText('curl -fsSL https://9lives.run/install.sh | sh')).toHaveLength(2); - expect(screen.getByText('export PATH="$HOME/.local/share/9lives-runner/bin:$PATH"')).toBeTruthy(); - expect(screen.getByText('9l run tests/ --timeout 5m --format json')).toBeTruthy(); - expect(screen.getByText('9l scan https://your-app.dev --format markdown')).toBeTruthy(); - expect(screen.getByRole('link', { name: 'Documentation' }).getAttribute('href')) - .toBe('https://quality-max.github.io/9lives-runner/'); - expect(screen.getByRole('link', { name: /github/i }).getAttribute('href')) - .toBe('https://github.com/Quality-Max/9lives-runner'); - expect(screen.getByRole('heading', { name: 'Python is in maintenance mode.' })).toBeTruthy(); - expect(screen.getByText('uv tool install 9lives')).toBeTruthy(); - expect(screen.getByRole('link', { name: 'Read the migration guide' }).getAttribute('href')) - .toBe('https://github.com/Quality-Max/9lives/blob/main/docs/MIGRATING_TO_GO.md'); - }); -}); - -describe('Page checks', () => { - it('presents the verification loop and the nine checks built into the runner', () => { - render(); - - expect(screen.getByRole('heading', { name: 'From “looks done” to evidence it works.' })).toBeTruthy(); - for (const check of ['console', 'links', 'accessibility', 'performance', 'seo', 'security_headers', 'cookies', 'mixed_content', 'weight']) { - expect(screen.getByRole('heading', { name: check })).toBeTruthy(); - } - expect(screen.getByText('claude mcp add 9lives -- 9l mcp')).toBeTruthy(); - expect(screen.getByRole('link', { name: 'Page checks' }).getAttribute('href')).toBe('#page-checks'); - expect(screen.queryAllByRole('link').some((link) => link.getAttribute('href')?.startsWith('https://qmax.run'))).toBe(false); - }); -}); diff --git a/website/app/page.tsx b/website/app/page.tsx deleted file mode 100644 index bd6ea0d..0000000 --- a/website/app/page.tsx +++ /dev/null @@ -1,387 +0,0 @@ -import Image from 'next/image'; -import { CopyButton } from './copy-button'; - -const installCommand = 'curl -fsSL https://9lives.run/install.sh | sh'; -const pathCommand = 'export PATH="$HOME/.local/share/9lives-runner/bin:$PATH"'; -const runCommand = '9l run tests/ --timeout 5m --format json'; -const scanCommand = '9l scan https://your-app.dev --format markdown'; -const mcpCommand = 'claude mcp add 9lives -- 9l mcp'; -const pythonCommand = 'uv tool install 9lives'; -const runnerSite = 'https://quality-max.github.io/9lives-runner/'; -const migrationGuide = 'https://github.com/Quality-Max/9lives/blob/main/docs/MIGRATING_TO_GO.md'; -const interactiveDemo = 'https://app.supademo.com/demo/cmv43czgb0031wi0jzgno1y8r?utm_source=link'; - -const memes = [ - { - file: 'button-rename-launch', - title: 'One button. A whole afternoon.', - alt: 'One button rename. The black Qmax kitten slumps over a failing laptop labeled My afternoon, then sits calmly beside a passing laptop labeled After the re-run. Repair the locator. Keep the assertion.', - copy: 'A locator repair still has to pass the original assertions.', - }, - { - file: 'harry-potter-launch', - title: 'Less panic. More proof.', - alt: 'Magical-school cat parody: You\'re a tester, Harry. The black Qmax kitten in wizard glasses faces a failing laptop, then a passing one. Locatorus Brokenus becomes Rerunus Verifiedus. Even magic needs a re-run.', - copy: 'Even a little UI magic deserves a real test result.', - }, - { - file: 'lotr-launch', - title: 'A tiny change. An epic detour.', - alt: 'Epic fantasy cat parody: One does not simply rename a button. The black Qmax kitten wears a grey wizard cloak beside a failing laptop, then reviews a passing result. Repair. Re-run. Review.', - copy: 'The journey ends with evidence, not a hopeful green badge.', - }, - { - file: 'batman-catwoman-launch', - title: 'Your tests deserve nine lives.', - alt: 'Original feline superhero parody: The button moved. A black kitten in a bat-eared cape sees FAIL; the Qmax kitten in a violet mask sees PASS. Another long night. Nine lives. Keep the assertion. Verify the fix.', - copy: 'Keep the assertion. Make the fix earn its green.', - }, -]; - -const pageChecks = [ - ['console', 'JavaScript errors, warnings, and failed requests'], - ['links', 'Broken and redirecting links'], - ['accessibility', 'Missing alt text, unlabelled controls, heading structure'], - ['performance', 'Core Web Vitals: LCP, CLS, TTFB, FCP'], - ['seo', 'Title and meta description'], - ['security_headers', 'CSP, HSTS, X-Content-Type-Options, Referrer-Policy'], - ['cookies', 'Cookie flags, third-party cookies, trackers before consent'], - ['mixed_content', 'HTTP resources and form actions on HTTPS pages'], - ['weight', 'Transfer size, requests, render-blocking and oversized assets'], -]; - -const verificationSteps = [ - { - number: '01', - title: 'scan_url — find what broke', - copy: 'Nine checks from one Chromium page load, with graded findings, reproduction steps, and suggested fixes.', - }, - { - number: '02', - title: 'inspect_page — see what a test can use', - copy: 'Page structure, accessible names, and ranked Playwright locators with a stability verdict for each control.', - }, - { - number: '03', - title: 'generate_playwright_repro — keep the failure', - copy: 'A deterministic, reviewable spec for a finding, written inside the project. Nothing runs until you choose.', - }, - { - number: '04', - title: 'run_test or confirm_finding — prove it', - copy: 'Execute the repro with validated receipts, or check that it fails before the fix and passes after it.', - }, -]; - -const healingSteps = [ - { - number: '01', - title: 'Plan your tests', - copy: 'Inspect discovered specs before execution. Keep your existing Playwright configuration and assertions.', - }, - { - number: '02', - title: 'Run with bounds', - copy: 'Go owns concurrency, deadlines, cancellation, and a terminal outcome for every required test.', - }, - { - number: '03', - title: 'Inspect the evidence', - copy: 'Validated receipts distinguish completed tests from missing evidence, skips, and interrupted runs.', - }, -]; - -const ecosystem = [ - { - eyebrow: 'Ship in the cloud', - title: 'QualityMax Cloud', - copy: 'Hosted test management, execution, and quality intelligence for growing teams.', - href: 'https://qualitymax.io', - }, - { - eyebrow: 'Code with evidence', - title: 'qmax-code', - copy: 'Agentic delivery loops that keep implementation, QA, and release evidence together.', - href: 'https://github.com/Quality-Max/qmax-code', - }, - { - eyebrow: 'Grade your specs', - title: 'QualityMax Grader', - copy: 'An offline A–F quality grade for Playwright specs before they reach the repository.', - href: 'https://github.com/Quality-Max/qualitymax-grader', - }, - { - eyebrow: 'Equip your agent', - title: 'Free QA Skills', - copy: 'Open QA skills for test quality, selectors, dependencies, accessibility, security, and more.', - href: 'https://github.com/Quality-Max/free-qa-skills', - }, -]; - -export default function Home() { - return ( -
-
- - QualityMax - - -
- -
-
- -
-
-

THE 9LIVES FEATURE LOOP

-

Create. Assess. Run. Heal. Challenge.

- Explore the 9lives workflow in an interactive demo, then try the runner on your own Playwright tests. Native healing is experimental; a demo is not a comparative benchmark. -
-
-