From 4a173ce1a14b9aab9bfdc15b0d7c100f51b83915 Mon Sep 17 00:00:00 2001 From: bcastets-robotiq Date: Mon, 21 Sep 2026 12:20:32 -0400 Subject: [PATCH 1/4] feat: auto-generate README repository table and software tools section Adds scripts/update-readme.mjs and a daily GitHub Actions workflow that regenerate the Repositories table (from the GitHub API) and the Software tools tables (imported from robotiq.github.io's docs/intro.mdx) between marker comments, opening and auto-merging a PR when content changes. Co-Authored-By: Claude Sonnet 5 --- .github/workflows/update-readme.yml | 88 +++++++++++++++ profile/README.md | 92 ++++++++++++--- scripts/update-readme.mjs | 168 ++++++++++++++++++++++++++++ 3 files changed, 335 insertions(+), 13 deletions(-) create mode 100644 .github/workflows/update-readme.yml create mode 100644 scripts/update-readme.mjs diff --git a/.github/workflows/update-readme.yml b/.github/workflows/update-readme.yml new file mode 100644 index 0000000..0ea3b35 --- /dev/null +++ b/.github/workflows/update-readme.yml @@ -0,0 +1,88 @@ +name: Update README + +# Daily refresh of profile/README.md's auto-generated sections (see +# scripts/update-readme.mjs): the repository table (from the GitHub API) and +# the software tools tables (imported from robotiq/robotiq.github.io's +# docs/intro.mdx). Opens a PR only when the regenerated content actually +# differs, then auto-merges it — mirroring +# robotiq.github.io's .github/workflows/dependabot-auto-merge.yml. +# +# Why this needs a PAT instead of just GITHUB_TOKEN: the prToMain ruleset's +# required-review rule blocks a PR's *author* from approving its own PR. In +# dependabot-auto-merge.yml, Dependabot (a distinct actor) opens the PR and +# GITHUB_TOKEN (github-actions[bot]) approves it — two different actors, so +# it's not self-approval. Here there's no Dependabot; this workflow itself +# generates the content. So the "open PR" step authenticates as +# README_BOT_PAT (a fine-grained PAT scoped to just this repo, Contents + +# Pull requests: write) instead of GITHUB_TOKEN, making its author a +# distinct actor from the github-actions[bot] identity that then approves +# and merges it in the next step. +# +# Why this is safe to merge without human review: the generated content is +# either the GitHub API's own repo descriptions or robotiq.github.io's own +# published docs/intro.mdx — both already public and already reviewed +# upstream. There's no required status check on this repo's ruleset, so +# nothing else gates the merge. + +on: + schedule: + - cron: '17 6 * * *' + workflow_dispatch: {} + +permissions: + contents: write + pull-requests: write + +jobs: + update-readme: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + token: ${{ secrets.README_BOT_PAT }} + + - uses: actions/setup-node@v4 + with: + node-version: 20 + + - name: Regenerate README + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: node scripts/update-readme.mjs + + - name: Open PR if content changed + id: pr + env: + GH_TOKEN: ${{ secrets.README_BOT_PAT }} + run: | + if git diff --quiet -- profile/README.md; then + echo "changed=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + + BRANCH="auto/update-readme-$(date -u +%Y%m%d-%H%M%S)" + git config user.name "robotiq-readme-bot" + git config user.email "readme-bot@robotiq.users.noreply.github.com" + git checkout -b "$BRANCH" + git add profile/README.md + git commit -m "chore: update README repository table and software tools section" + git push origin "$BRANCH" + + PR_URL=$(gh pr create \ + --base main \ + --head "$BRANCH" \ + --title "chore: update README repository table" \ + --body "Automated daily refresh of the repository table (GitHub API) and software tools section (robotiq.github.io docs/intro.mdx) — see \`scripts/update-readme.mjs\`.") + + echo "changed=true" >> "$GITHUB_OUTPUT" + echo "url=$PR_URL" >> "$GITHUB_OUTPUT" + + - name: Approve and auto-merge + if: steps.pr.outputs.changed == 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PR_URL: ${{ steps.pr.outputs.url }} + run: | + gh pr review --approve "$PR_URL" + gh pr merge --auto --squash "$PR_URL" diff --git a/profile/README.md b/profile/README.md index a31b7dd..a319a4f 100644 --- a/profile/README.md +++ b/profile/README.md @@ -1,22 +1,88 @@ # Robotiq -Open-source tools and drivers for Robotiq products. +Open-source software tools for Robotiq developers. +

+ + Robotiq documentation website + +

+ +

+ Full documentation, guides, and API references for `Robotiq` software tools.
+ Start there to find your product, then jump into the matching repository below. +

+ +## Repositories + + + | Repository | Description | |---|---| -| [grippers](https://github.com/Robotiq/grippers) | Standalone C++ SDK for the 2F adaptive grippers (2F-85 / 2F-140 / Hand-E) over Modbus RTU | -| [tactile_sensors](https://github.com/Robotiq/tactile_sensors) | SDK, sensor I/O, and quickstart tools for the TSF-85 tactile sensor | -| [ros](https://github.com/Robotiq/ROS_Packages) | Robotiq ROS packages (grippers, tactile sensor) | +| [grippers](https://github.com/robotiq/grippers) | Standalone C++ SDK for Robotiq 2F adaptive grippers (2F-85 / 2F-140 / Hand-E) over Modbus RTU | +| [isaacsim_assets](https://github.com/robotiq/isaacsim_assets) | Our Isaac Sim assets available to the public | +| [ros](https://github.com/robotiq/ros) | Collection of ROS packages for controlling Robotiq hardware. | +| [tactile_sensors](https://github.com/robotiq/tactile_sensors) | Get started with Robotiqs TSF-85 tactile sensors | + -## Community & Third-Party +## Software tools -A curated list of community-maintained drivers and useful third-party projects related to Robotiq products and robotic grasping. + + +#### SDKs/languages -| Repository | Description | +| Product | C | C++ | Python | +|---|---|---|---| +| [2F / Hand-E](https://robotiq.github.io/docs/drivers/2F%20hande) | - | [![Robotiq](https://img.shields.io/badge/Robotiq-blue)](https://robotiq.github.io/docs/drivers/2F%20hande/SDK/C++) | [![Third party](https://img.shields.io/badge/Third_party-lightgrey)](https://robotiq.github.io/docs/drivers/2F%20hande/SDK/Python) | +| [FT300-S](https://robotiq.github.io/docs/drivers/FT300) | [![Robotiq](https://img.shields.io/badge/Robotiq-blue)](https://robotiq.github.io/docs/drivers/FT300/SDK/C) | - | [![Third party](https://img.shields.io/badge/Third_party-lightgrey)](https://robotiq.github.io/docs/drivers/FT300/SDK/Python) | +| [TSF-85](https://robotiq.github.io/docs/drivers/TSF-85) | - | [![Robotiq](https://img.shields.io/badge/Robotiq-blue)](https://robotiq.github.io/docs/drivers/TSF-85/SDK/C++) | [![Robotiq](https://img.shields.io/badge/Robotiq-blue)](https://robotiq.github.io/docs/drivers/TSF-85/SDK/Python) | + +- **C** — Low-level C driver talking directly to the hardware's communication protocol (e.g. Modbus RTU, serial). +- **C++** — Low-level C++ driver/SDK for direct hardware integration. +- **Python** — Python driver/SDK for scripting and rapid prototyping. + +#### ROS2 + +| Product | Rolling | Jazzy | Iron | Humble | +|---|---|---|---|---| +| [2F / Hand-E](https://robotiq.github.io/docs/drivers/2F%20hande) | [![Third party](https://img.shields.io/badge/Third_party-lightgrey)](https://robotiq.github.io/docs/drivers/2F%20hande/ROS/ROS2-Rolling) | - | [![Third party](https://img.shields.io/badge/Third_party-lightgrey)](https://robotiq.github.io/docs/drivers/2F%20hande/ROS/ROS2-Iron) | [![Third party](https://img.shields.io/badge/Third_party-lightgrey)](https://robotiq.github.io/docs/drivers/2F%20hande/ROS/ROS2-Humble) | +| [FT300-S](https://robotiq.github.io/docs/drivers/FT300) | - | - | - | [![Third party](https://img.shields.io/badge/Third_party-lightgrey)](https://robotiq.github.io/docs/drivers/FT300/ROS/ROS2-Humble) | +| [TSF-85](https://robotiq.github.io/docs/drivers/TSF-85) | - | [![Robotiq](https://img.shields.io/badge/Robotiq-blue)](https://robotiq.github.io/docs/drivers/TSF-85/ROS/ROS2-Jazzy) | - | - | + +- **Rolling** — ROS 2 rolling development distro, always tracking the latest sources. +- **Jazzy** — ROS 2 LTS release (2024), supported until 2029. +- **Iron** — ROS 2 release (2023), end of life. +- **Humble** — ROS 2 LTS release (2022), supported until 2027. + +#### ROS1 + +| Product | Melodic | Kinetic | Jade | Indigo | +|---|---|---|---|---| +| [2F / Hand-E](https://robotiq.github.io/docs/drivers/2F%20hande) | [![Third party](https://img.shields.io/badge/Third_party-lightgrey)](https://robotiq.github.io/docs/drivers/2F%20hande/ROS/ROS1-Melodic) | [![Third party](https://img.shields.io/badge/Third_party-lightgrey)](https://robotiq.github.io/docs/drivers/2F%20hande/ROS/ROS1-Kinetic) | [![Third party](https://img.shields.io/badge/Third_party-lightgrey)](https://robotiq.github.io/docs/drivers/2F%20hande/ROS/ROS1-Jade) | [![Third party](https://img.shields.io/badge/Third_party-lightgrey)](https://robotiq.github.io/docs/drivers/2F%20hande/ROS/ROS1-Indigo) | + +- **Melodic** — ROS 1 release (2018), end of life. +- **Kinetic** — ROS 1 release (2016), end of life. +- **Jade** — ROS 1 release (2015), end of life. +- **Indigo** — ROS 1 release (2014), end of life. + +#### Physics engine + +| Product | Isaac Sim | PyBullet | +|---|---|---| +| [2F / Hand-E](https://robotiq.github.io/docs/drivers/2F%20hande) | [![Third party](https://img.shields.io/badge/Third_party-lightgrey)](https://robotiq.github.io/docs/drivers/2F%20hande/Physics%20Engine/Isaac%20Sim) | [![Third party](https://img.shields.io/badge/Third_party-lightgrey)](https://robotiq.github.io/docs/drivers/2F%20hande/Physics%20Engine/PyBullet) | +| [TSF-85](https://robotiq.github.io/docs/drivers/TSF-85) | [![Third party](https://img.shields.io/badge/Third_party-lightgrey)](https://robotiq.github.io/docs/drivers/TSF-85/Physics%20Engine/Isaac%20Sim) | - | + +- **Isaac Sim** — NVIDIA Isaac Sim integration for simulating the hardware. +- **PyBullet** — PyBullet integration for physics-based simulation. + +#### Other community projects + +| Product | GraspGen | |---|---| -| [TSF-85 Isaac Sim Extension](https://github.com/Lab-CORO/TSF-85) | An Isaac Sim extension generating synthetic tactile maps for the TSF-85 | -| [pyRobotiqGripper](https://github.com/castetsb/pyRobotiqGripper) | Python library for controlling Robotiq grippers (2F-85, 2F-140, Hand-E) via Modbus RTU | -| [ur_rtde](https://sdurobotics.gitlab.io/ur_rtde) | Provides some useful [instructions](https://sdurobotics.gitlab.io/ur_rtde/guides/guides.html#use-with-robotiq-gripper) on how to control a Robotiq gripper directly using a raw TCP socket | -| [pyFT300](https://github.com/castetsb/pyFT300) | Python library for reading data from the Robotiq FT-300 force-torque sensor | -| [pybullet_ur5_robotiq](https://github.com/ElectronicElephant/pybullet_ur5_robotiq) | UR5 + Robotiq 85/140 gripper simulation in PyBullet | -| [GraspGen](https://github.com/NVlabs/GraspGen) | GraspGen: a diffusion-based framework for 6-DOF grasping | +| [2F / Hand-E](https://robotiq.github.io/docs/drivers/2F%20hande) | [![Third party](https://img.shields.io/badge/Third_party-lightgrey)](https://robotiq.github.io/docs/drivers/2F%20hande/Other/GraspGen) | + +- **GraspGen** — NVIDIA GraspGen asset/model package with Robotiq gripper definitions for grasp synthesis research. + + diff --git a/scripts/update-readme.mjs b/scripts/update-readme.mjs new file mode 100644 index 0000000..ec9a3e8 --- /dev/null +++ b/scripts/update-readme.mjs @@ -0,0 +1,168 @@ +// Regenerates the auto-generated sections of profile/README.md: +// - "Repositories": one row per public, non-archived, non-fork repo in the +// robotiq GitHub org (excluding this repo and the docs site itself), +// pulled live from the GitHub API. +// - "Software tools": the SDK / ROS2 / ROS1 / Physics engine / Other tables +// imported from robotiq/robotiq.github.io's docs/intro.mdx (itself kept +// up to date by that repo's own scripts/generate-tools-table.js), with +// relative doc links rewritten to absolute robotiq.github.io URLs. +// +// Run via `.github/workflows/update-readme.yml` on a daily schedule, or +// locally with `node scripts/update-readme.mjs` (optionally set GITHUB_TOKEN +// to avoid the unauthenticated API rate limit). + +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const README_PATH = path.join(ROOT, 'profile', 'README.md'); + +const ORG = 'robotiq'; +const DOCS_REPO = 'robotiq.github.io'; +const DOCS_BRANCH = 'main'; +const DOCS_SITE_URL = 'https://robotiq.github.io'; + +// Org repos that aren't "software tools" and shouldn't appear in the table. +const EXCLUDED_REPOS = new Set(['.github', DOCS_REPO]); + +const SOFTWARE_SECTIONS = [ + { key: 'SDK', heading: 'SDKs/languages' }, + { key: 'ROS2', heading: 'ROS2' }, + { key: 'ROS1', heading: 'ROS1' }, + { key: 'PHYSICS_ENGINE', heading: 'Physics engine' }, + { key: 'OTHER', heading: 'Other community projects' }, +]; + +async function githubApi(url) { + const headers = { + Accept: 'application/vnd.github+json', + 'User-Agent': 'robotiq-profile-readme-bot', + }; + if (process.env.GITHUB_TOKEN) headers.Authorization = `Bearer ${process.env.GITHUB_TOKEN}`; + + const res = await fetch(url, { headers }); + if (!res.ok) { + throw new Error(`[update-readme] GitHub API request failed (${res.status} ${res.statusText}): ${url}`); + } + return res.json(); +} + +// Public, non-archived, non-fork repos in the org, paginated. +async function fetchOrgRepos() { + const repos = []; + for (let page = 1; ; page += 1) { + const batch = await githubApi(`https://api.github.com/orgs/${ORG}/repos?type=public&per_page=100&page=${page}`); + if (batch.length === 0) break; + repos.push(...batch); + if (batch.length < 100) break; + } + return repos + .filter((r) => !r.archived && !r.fork && !EXCLUDED_REPOS.has(r.name)) + .map((r) => ({ name: r.name, url: r.html_url, description: (r.description || '').trim() })) + .sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' })); +} + +function escapeCell(text) { + return text.replace(/\|/g, '\\|').replace(/\r?\n/g, ' '); +} + +function buildRepoTable(repos) { + const header = '| Repository | Description |'; + const separator = '|---|---|'; + const rows = repos.map((r) => `| [${r.name}](${r.url}) | ${escapeCell(r.description) || '_No description yet._'} |`); + return [header, separator, ...rows].join('\n'); +} + +async function fetchDocsIntro() { + const url = `https://raw.githubusercontent.com/${ORG}/${DOCS_REPO}/${DOCS_BRANCH}/docs/intro.mdx`; + const res = await fetch(url, { headers: { 'User-Agent': 'robotiq-profile-readme-bot' } }); + if (!res.ok) { + throw new Error(`[update-readme] Failed to fetch ${url} (${res.status} ${res.statusText})`); + } + return res.text(); +} + +function extractMarkerBlock(raw, key) { + const re = new RegExp( + `\\{/\\* AUTO-GENERATED-${key}-TABLE:START \\*/\\}\\n([\\s\\S]*?)\\n\\{/\\* AUTO-GENERATED-${key}-TABLE:END \\*/\\}` + ); + const m = raw.match(re); + return m ? m[1].trim() : null; +} + +// docs/intro.mdx links to product pages with paths relative to docs/ +// (e.g. "drivers/Adaptive%20grippers") — make them absolute so they resolve +// from the profile README, which isn't served from the docs site. +function absolutizeDocLinks(markdown) { + return markdown.replace(/\]\(drivers\//g, `](${DOCS_SITE_URL}/docs/drivers/`); +} + +// generate-tools-table.js (in robotiq.github.io) always writes every one of +// these marker pairs, even when a category has no products yet (it fills +// the gap with a "_No ... documented yet._" message) — so a missing marker +// here never means "legitimately empty", only that the fetched page isn't +// the shape this script expects (docs site restructured, marker renamed, +// truncated response, ...). Treat that as a hard failure rather than +// silently publishing a README with a gutted software tools section. +function buildSoftwareToolsSection(introRaw) { + const parts = []; + for (const { key, heading } of SOFTWARE_SECTIONS) { + const block = extractMarkerBlock(introRaw, key); + if (!block) { + throw new Error( + `[update-readme] Could not import the "${heading}" software tools section: no ` + + `AUTO-GENERATED-${key}-TABLE markers found in ${DOCS_REPO}'s docs/intro.mdx. ` + + `It may have been restructured — refusing to publish a partial section.` + ); + } + parts.push(`#### ${heading}\n\n${absolutizeDocLinks(block)}`); + } + return parts.join('\n\n'); +} + +function writeBetweenMarkers(filePath, key, content) { + const startMarker = ``; + const endMarker = ``; + const markerRegex = new RegExp( + `${startMarker.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}[\\s\\S]*?${endMarker.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}` + ); + const raw = fs.readFileSync(filePath, 'utf8'); + if (!markerRegex.test(raw)) { + throw new Error(`[update-readme] Markers not found in ${path.relative(ROOT, filePath)}: ${startMarker}`); + } + const block = `${startMarker}\n${content}\n${endMarker}`; + fs.writeFileSync(filePath, raw.replace(markerRegex, block), 'utf8'); +} + +async function main() { + const [repos, introRaw] = await Promise.all([fetchOrgRepos(), fetchDocsIntro()]); + + // An org with zero matching repos is far more likely a broken fetch + // (rate limit, wrong org, API change) than reality — same reasoning as + // buildSoftwareToolsSection's marker check: fail loudly instead of + // publishing a README with an emptied-out repository table. + if (repos.length === 0) { + throw new Error( + `[update-readme] Fetched 0 repositories for org "${ORG}" — refusing to overwrite the ` + + 'repository table. Check GITHUB_TOKEN / API rate limits / the ORG constant.' + ); + } + + // Build and validate both sections before writing anything, so a failure + // in either one (e.g. the marker check above) never leaves the README + // with only one section refreshed. + const repoTable = buildRepoTable(repos); + const softwareToolsSection = buildSoftwareToolsSection(introRaw); + + writeBetweenMarkers(README_PATH, 'REPOS-TABLE', repoTable); + console.log(`[update-readme] Wrote ${repos.length} repositories to profile/README.md`); + + writeBetweenMarkers(README_PATH, 'SOFTWARE-TOOLS', softwareToolsSection); + console.log('[update-readme] Wrote software tools section to profile/README.md'); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); From 1dc52ca8cf7fa556ba10d530d2ac2cc29508661a Mon Sep 17 00:00:00 2001 From: bcastets-robotiq Date: Mon, 21 Sep 2026 15:18:35 -0400 Subject: [PATCH 2/4] fix: address auto-merge and script review findings - Add scripts/update-readme.test.mjs + .github/workflows/test.yml as the required check needed for the daily workflow's auto-merge to have anything to wait on. - Fix commit author email to github-actions[bot]'s real address, so prToMain's require_extra_approval_for_unattributed_changes rule doesn't demand a second approval. - Use a fixed branch name with force-push and reuse an existing open PR, instead of a new branch/PR piling up per run. - Fix a String.replace replacement-string injection: a live repo description or docs-site cell containing $&, $1, etc. would otherwise splice into the marker block instead of being inserted literally. - Make the README write atomic: both marker replacements now happen on one in-memory string before a single write, instead of two independent read-check-write calls. - Broaden absolutizeDocLinks to rewrite any relative doc link, not just "drivers/" ones, so an unrecognized link shape upstream doesn't silently 404 instead of resolving. Findings from mbegin-robotiq's review on PR #2. Co-Authored-By: Claude Sonnet 5 --- .github/workflows/test.yml | 23 ++++++ .github/workflows/update-readme.yml | 51 ++++++++++---- scripts/update-readme.mjs | 86 ++++++++++++++--------- scripts/update-readme.test.mjs | 104 ++++++++++++++++++++++++++++ 4 files changed, 219 insertions(+), 45 deletions(-) create mode 100644 .github/workflows/test.yml create mode 100644 scripts/update-readme.test.mjs diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..b761b56 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,23 @@ +name: Test + +# Runs scripts/update-readme.test.mjs on every PR into main. Intended to be +# added as a required status check on the prToMain ruleset, so a PR that +# breaks scripts/update-readme.mjs (including one opened by +# update-readme.yml's own daily run) can't merge — see the "required check" +# discussion on PR #2. + +on: + pull_request: + branches: [main] + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + + - run: node --test scripts/*.test.mjs diff --git a/.github/workflows/update-readme.yml b/.github/workflows/update-readme.yml index 0ea3b35..f24bb99 100644 --- a/.github/workflows/update-readme.yml +++ b/.github/workflows/update-readme.yml @@ -18,11 +18,20 @@ name: Update README # distinct actor from the github-actions[bot] identity that then approves # and merges it in the next step. # -# Why this is safe to merge without human review: the generated content is -# either the GitHub API's own repo descriptions or robotiq.github.io's own -# published docs/intro.mdx — both already public and already reviewed -# upstream. There's no required status check on this repo's ruleset, so -# nothing else gates the merge. +# Why this is safe to merge without further human review: the generated +# content is either the GitHub API's own repo descriptions or +# robotiq.github.io's own published docs/intro.mdx — both already public and +# already reviewed upstream. What actually gates the merge is the "Test" +# workflow (.github/workflows/test.yml) as a required status check on the +# prToMain ruleset: `gh pr merge --auto` waits on it, so a change that breaks +# update-readme.mjs sits as an open, failing PR instead of reaching the org's +# public landing page. Without that required check, `--auto` has nothing to +# wait on and errors out instead of merging — see the review on PR #2. +# +# Also requires: `allow_auto_merge` enabled on this repo (Settings → General +# → Pull Requests), and the commit's author email resolving to a real GitHub +# account (below) so prToMain's require_extra_approval_for_unattributed_changes +# rule doesn't kick in and demand a second approval nothing here can produce. on: schedule: @@ -61,19 +70,33 @@ jobs: exit 0 fi - BRANCH="auto/update-readme-$(date -u +%Y%m%d-%H%M%S)" + # Fixed name rather than one timestamped per run: a run that lands + # before yesterday's PR merged (still waiting on the required + # check) force-pushes onto the same branch/PR instead of piling up + # a new branch and a new unmergeable PR each day. + BRANCH="auto/update-readme" git config user.name "robotiq-readme-bot" - git config user.email "readme-bot@robotiq.users.noreply.github.com" + # This must resolve to a real GitHub account (github-actions[bot]'s + # own noreply address) or the prToMain ruleset's + # require_extra_approval_for_unattributed_changes rule treats the + # commit as unattributed and demands a second approval this + # workflow has no way to produce. + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" git checkout -b "$BRANCH" git add profile/README.md git commit -m "chore: update README repository table and software tools section" - git push origin "$BRANCH" + git push --force origin "$BRANCH" - PR_URL=$(gh pr create \ - --base main \ - --head "$BRANCH" \ - --title "chore: update README repository table" \ - --body "Automated daily refresh of the repository table (GitHub API) and software tools section (robotiq.github.io docs/intro.mdx) — see \`scripts/update-readme.mjs\`.") + EXISTING_PR=$(gh pr list --base main --head "$BRANCH" --state open --json url --jq '.[0].url') + if [ -n "$EXISTING_PR" ]; then + PR_URL="$EXISTING_PR" + else + PR_URL=$(gh pr create \ + --base main \ + --head "$BRANCH" \ + --title "chore: update README repository table" \ + --body "Automated daily refresh of the repository table (GitHub API) and software tools section (robotiq.github.io docs/intro.mdx) — see \`scripts/update-readme.mjs\`.") + fi echo "changed=true" >> "$GITHUB_OUTPUT" echo "url=$PR_URL" >> "$GITHUB_OUTPUT" @@ -85,4 +108,4 @@ jobs: PR_URL: ${{ steps.pr.outputs.url }} run: | gh pr review --approve "$PR_URL" - gh pr merge --auto --squash "$PR_URL" + gh pr merge --auto --squash --delete-branch "$PR_URL" diff --git a/scripts/update-readme.mjs b/scripts/update-readme.mjs index ec9a3e8..817bf79 100644 --- a/scripts/update-readme.mjs +++ b/scripts/update-readme.mjs @@ -9,11 +9,13 @@ // // Run via `.github/workflows/update-readme.yml` on a daily schedule, or // locally with `node scripts/update-readme.mjs` (optionally set GITHUB_TOKEN -// to avoid the unauthenticated API rate limit). +// to avoid the unauthenticated API rate limit). Pure helpers are exported +// for scripts/update-readme.test.mjs; fetchOrgRepos/fetchDocsIntro/main hit +// the network and aren't unit-tested. import fs from 'node:fs'; import path from 'node:path'; -import { fileURLToPath } from 'node:url'; +import { fileURLToPath, pathToFileURL } from 'node:url'; const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const README_PATH = path.join(ROOT, 'profile', 'README.md'); @@ -49,7 +51,7 @@ async function githubApi(url) { } // Public, non-archived, non-fork repos in the org, paginated. -async function fetchOrgRepos() { +export async function fetchOrgRepos() { const repos = []; for (let page = 1; ; page += 1) { const batch = await githubApi(`https://api.github.com/orgs/${ORG}/repos?type=public&per_page=100&page=${page}`); @@ -63,11 +65,11 @@ async function fetchOrgRepos() { .sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' })); } -function escapeCell(text) { +export function escapeCell(text) { return text.replace(/\|/g, '\\|').replace(/\r?\n/g, ' '); } -function buildRepoTable(repos) { +export function buildRepoTable(repos) { const header = '| Repository | Description |'; const separator = '|---|---|'; const rows = repos.map((r) => `| [${r.name}](${r.url}) | ${escapeCell(r.description) || '_No description yet._'} |`); @@ -83,7 +85,7 @@ async function fetchDocsIntro() { return res.text(); } -function extractMarkerBlock(raw, key) { +export function extractMarkerBlock(raw, key) { const re = new RegExp( `\\{/\\* AUTO-GENERATED-${key}-TABLE:START \\*/\\}\\n([\\s\\S]*?)\\n\\{/\\* AUTO-GENERATED-${key}-TABLE:END \\*/\\}` ); @@ -91,11 +93,14 @@ function extractMarkerBlock(raw, key) { return m ? m[1].trim() : null; } -// docs/intro.mdx links to product pages with paths relative to docs/ -// (e.g. "drivers/Adaptive%20grippers") — make them absolute so they resolve -// from the profile README, which isn't served from the docs site. -function absolutizeDocLinks(markdown) { - return markdown.replace(/\]\(drivers\//g, `](${DOCS_SITE_URL}/docs/drivers/`); +// docs/intro.mdx lives at the docs/ root, so any relative link in it (not +// absolute http(s), not a same-page #anchor) resolves against that root — +// "drivers/...", "img/...", "./drivers/...", "contribute/..." alike. +// Rewriting all of them (rather than only the "drivers/" ones this table +// happens to use today) means a new link shape upstream still resolves +// correctly here instead of silently 404ing on the org landing page. +export function absolutizeDocLinks(markdown) { + return markdown.replace(/\]\((?!https?:|#)([^)]+)\)/g, (_, href) => `](${DOCS_SITE_URL}/docs/${href.replace(/^\.?\//, '')})`); } // generate-tools-table.js (in robotiq.github.io) always writes every one of @@ -105,7 +110,7 @@ function absolutizeDocLinks(markdown) { // the shape this script expects (docs site restructured, marker renamed, // truncated response, ...). Treat that as a hard failure rather than // silently publishing a README with a gutted software tools section. -function buildSoftwareToolsSection(introRaw) { +export function buildSoftwareToolsSection(introRaw) { const parts = []; for (const { key, heading } of SOFTWARE_SECTIONS) { const block = extractMarkerBlock(introRaw, key); @@ -121,18 +126,31 @@ function buildSoftwareToolsSection(introRaw) { return parts.join('\n\n'); } -function writeBetweenMarkers(filePath, key, content) { +function escapeRegExp(str) { + return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +// Pure text transform (no I/O), so it can be composed left-to-right over an +// in-memory string and unit-tested without touching the filesystem. Throws +// if `key`'s markers aren't present in `text` — callers are expected to run +// every replacement they need before writing anything back out, so one +// missing marker fails before any bytes are written, rather than after some +// sections are already on disk and others aren't. +export function replaceBetweenMarkers(text, key, content) { const startMarker = ``; const endMarker = ``; - const markerRegex = new RegExp( - `${startMarker.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}[\\s\\S]*?${endMarker.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}` - ); - const raw = fs.readFileSync(filePath, 'utf8'); - if (!markerRegex.test(raw)) { - throw new Error(`[update-readme] Markers not found in ${path.relative(ROOT, filePath)}: ${startMarker}`); + const markerRegex = new RegExp(`${escapeRegExp(startMarker)}[\\s\\S]*?${escapeRegExp(endMarker)}`); + if (!markerRegex.test(text)) { + throw new Error(`[update-readme] Markers not found in profile/README.md: ${startMarker}`); } const block = `${startMarker}\n${content}\n${endMarker}`; - fs.writeFileSync(filePath, raw.replace(markerRegex, block), 'utf8'); + // A function replacer is used because `content` (live repo descriptions, + // docs-site table cells) is untrusted as a String.replace() replacement + // *string* — "$&", "$`", "$'", "$1" etc. in it would otherwise be + // interpreted as replacement patterns instead of inserted literally, + // silently corrupting the marker block. A function replacer gets no such + // special-pattern handling. + return text.replace(markerRegex, () => block); } async function main() { @@ -149,20 +167,26 @@ async function main() { ); } - // Build and validate both sections before writing anything, so a failure - // in either one (e.g. the marker check above) never leaves the README - // with only one section refreshed. + // Build and validate both sections, and check both marker pairs actually + // exist in the README, entirely in memory before writing anything — one + // read, one write, so a failure partway through (e.g. a damaged marker) + // never leaves the README with only one section refreshed. const repoTable = buildRepoTable(repos); const softwareToolsSection = buildSoftwareToolsSection(introRaw); - writeBetweenMarkers(README_PATH, 'REPOS-TABLE', repoTable); - console.log(`[update-readme] Wrote ${repos.length} repositories to profile/README.md`); + let readme = fs.readFileSync(README_PATH, 'utf8'); + readme = replaceBetweenMarkers(readme, 'REPOS-TABLE', repoTable); + readme = replaceBetweenMarkers(readme, 'SOFTWARE-TOOLS', softwareToolsSection); + fs.writeFileSync(README_PATH, readme, 'utf8'); - writeBetweenMarkers(README_PATH, 'SOFTWARE-TOOLS', softwareToolsSection); - console.log('[update-readme] Wrote software tools section to profile/README.md'); + console.log(`[update-readme] Wrote ${repos.length} repositories and the software tools section to profile/README.md`); } -main().catch((err) => { - console.error(err); - process.exit(1); -}); +// Only run when executed directly (`node scripts/update-readme.mjs`), not +// when imported by scripts/update-readme.test.mjs. +if (import.meta.url === pathToFileURL(process.argv[1]).href) { + main().catch((err) => { + console.error(err); + process.exit(1); + }); +} diff --git a/scripts/update-readme.test.mjs b/scripts/update-readme.test.mjs new file mode 100644 index 0000000..d880801 --- /dev/null +++ b/scripts/update-readme.test.mjs @@ -0,0 +1,104 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { + escapeCell, + buildRepoTable, + extractMarkerBlock, + absolutizeDocLinks, + buildSoftwareToolsSection, + replaceBetweenMarkers, +} from './update-readme.mjs'; + +test('escapeCell escapes pipes and collapses newlines', () => { + assert.equal(escapeCell('a | b'), 'a \\| b'); + assert.equal(escapeCell('line one\nline two'), 'line one line two'); + assert.equal(escapeCell('line one\r\nline two'), 'line one line two'); +}); + +test('buildRepoTable renders one row per repo, falling back on empty description', () => { + const table = buildRepoTable([ + { name: 'grippers', url: 'https://github.com/robotiq/grippers', description: 'A driver' }, + { name: 'ros', url: 'https://github.com/robotiq/ros', description: '' }, + ]); + assert.match(table, /\| \[grippers\]\(https:\/\/github\.com\/robotiq\/grippers\) \| A driver \|/); + assert.match(table, /\| \[ros\]\(https:\/\/github\.com\/robotiq\/ros\) \| _No description yet\._ \|/); +}); + +test('buildRepoTable escapes a pipe in a live repo description', () => { + const table = buildRepoTable([ + { name: 'x', url: 'https://github.com/robotiq/x', description: 'Do A | Do B' }, + ]); + assert.match(table, /Do A \\\| Do B/); +}); + +test('extractMarkerBlock returns the trimmed content between markers', () => { + const raw = [ + '{/* AUTO-GENERATED-SDK-TABLE:START */}', + '| a | b |', + '{/* AUTO-GENERATED-SDK-TABLE:END */}', + ].join('\n'); + assert.equal(extractMarkerBlock(raw, 'SDK'), '| a | b |'); +}); + +test('extractMarkerBlock returns null when the marker pair is absent', () => { + assert.equal(extractMarkerBlock('no markers here', 'SDK'), null); +}); + +test('absolutizeDocLinks rewrites relative links, leaves absolute/anchor links alone', () => { + const input = '[a](drivers/Foo) [b](img/x.png) [c](./drivers/Foo) [d](https://example.com) [e](#section)'; + const output = absolutizeDocLinks(input); + assert.match(output, /\[a\]\(https:\/\/robotiq\.github\.io\/docs\/drivers\/Foo\)/); + assert.match(output, /\[b\]\(https:\/\/robotiq\.github\.io\/docs\/img\/x\.png\)/); + assert.match(output, /\[c\]\(https:\/\/robotiq\.github\.io\/docs\/drivers\/Foo\)/); + assert.match(output, /\[d\]\(https:\/\/example\.com\)/); + assert.match(output, /\[e\]\(#section\)/); +}); + +function fakeIntro(overrides = {}) { + const sections = { SDK: '| sdk |', ROS2: '| ros2 |', ROS1: '| ros1 |', PHYSICS_ENGINE: '| phys |', OTHER: '| other |', ...overrides }; + return Object.entries(sections) + .filter(([, body]) => body !== null) + .map(([key, body]) => `{/* AUTO-GENERATED-${key}-TABLE:START */}\n${body}\n{/* AUTO-GENERATED-${key}-TABLE:END */}`) + .join('\n\n'); +} + +test('buildSoftwareToolsSection includes every section heading, in order, when all markers are present', () => { + const section = buildSoftwareToolsSection(fakeIntro()); + const headings = [...section.matchAll(/^#### (.+)$/gm)].map((m) => m[1]); + assert.deepEqual(headings, ['SDKs/languages', 'ROS2', 'ROS1', 'Physics engine', 'Other community projects']); +}); + +test('buildSoftwareToolsSection throws instead of publishing a partial section when a marker is missing', () => { + assert.throws(() => buildSoftwareToolsSection(fakeIntro({ PHYSICS_ENGINE: null })), /Physics engine/); +}); + +test('replaceBetweenMarkers replaces content between an existing marker pair', () => { + const text = '# Title\n\n\nold\n\n'; + const result = replaceBetweenMarkers(text, 'REPOS-TABLE', 'new content'); + assert.match(result, /\nnew content\n/); +}); + +test('replaceBetweenMarkers throws when the marker pair is missing, instead of silently no-op-ing', () => { + assert.throws(() => replaceBetweenMarkers('# Title\n\nno markers', 'REPOS-TABLE', 'new content'), /Markers not found/); +}); + +test('replaceBetweenMarkers treats content as a literal string, not a replacement pattern', () => { + const text = '\nold\n'; + // A live GitHub repo description containing "$&" would previously splice + // the entire matched marker block back into itself here. + const result = replaceBetweenMarkers(text, 'REPOS-TABLE', 'weird repo description with $& and $1 in it'); + assert.match(result, /weird repo description with \$& and \$1 in it/); +}); + +test('replaceBetweenMarkers only touches the named marker pair, leaving the rest of the document untouched', () => { + const text = [ + '# Robotiq', + '', + 'old repos', + '', + 'old tools', + ].join('\n'); + const result = replaceBetweenMarkers(text, 'REPOS-TABLE', 'new repos'); + assert.match(result, /new repos/); + assert.match(result, /old tools/); +}); From b983f32b2fa5cd639015bfaea7a4b94c4a5bf9df Mon Sep 17 00:00:00 2001 From: bcastets-robotiq Date: Mon, 21 Sep 2026 15:24:15 -0400 Subject: [PATCH 3/4] fix: don't double /docs/ for site-root-relative doc links MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit absolutizeDocLinks treated every non-http(s)/anchor link as document- relative to docs/, so a link already written as an absolute path from the site root (e.g. /docs/drivers/Foo) got /docs/ prepended a second time. Root-relative links now get only the domain prepended. Flagged in mbegin-robotiq's review on PR #2 (the /docs/drivers/ case in the original comment) — missed in the first fix. Co-Authored-By: Claude Sonnet 5 --- scripts/update-readme.mjs | 20 +++++++++++++------- scripts/update-readme.test.mjs | 7 +++++++ 2 files changed, 20 insertions(+), 7 deletions(-) diff --git a/scripts/update-readme.mjs b/scripts/update-readme.mjs index 817bf79..0d419c0 100644 --- a/scripts/update-readme.mjs +++ b/scripts/update-readme.mjs @@ -93,14 +93,20 @@ export function extractMarkerBlock(raw, key) { return m ? m[1].trim() : null; } -// docs/intro.mdx lives at the docs/ root, so any relative link in it (not -// absolute http(s), not a same-page #anchor) resolves against that root — -// "drivers/...", "img/...", "./drivers/...", "contribute/..." alike. -// Rewriting all of them (rather than only the "drivers/" ones this table -// happens to use today) means a new link shape upstream still resolves -// correctly here instead of silently 404ing on the org landing page. +// docs/intro.mdx lives at the docs/ root, so a document-relative link in it +// ("drivers/...", "img/...", "./drivers/...", "contribute/...") resolves +// against that root. A site-root-relative link ("/docs/drivers/...", +// "/img/...") already names its full path from the domain root, so only the +// domain goes in front of it — prepending "/docs/" too would double it into +// ".../docs/docs/...". Absolute http(s) links and same-page #anchors are +// left untouched. Rewriting every other shape (rather than only the +// "drivers/" links this table happens to use today) means a new link shape +// upstream still resolves correctly here instead of silently 404ing on the +// org landing page. export function absolutizeDocLinks(markdown) { - return markdown.replace(/\]\((?!https?:|#)([^)]+)\)/g, (_, href) => `](${DOCS_SITE_URL}/docs/${href.replace(/^\.?\//, '')})`); + return markdown.replace(/\]\((?!https?:|#)([^)]+)\)/g, (_, href) => ( + href.startsWith('/') ? `](${DOCS_SITE_URL}${href})` : `](${DOCS_SITE_URL}/docs/${href.replace(/^\.\//, '')})` + )); } // generate-tools-table.js (in robotiq.github.io) always writes every one of diff --git a/scripts/update-readme.test.mjs b/scripts/update-readme.test.mjs index d880801..da6ab01 100644 --- a/scripts/update-readme.test.mjs +++ b/scripts/update-readme.test.mjs @@ -54,6 +54,13 @@ test('absolutizeDocLinks rewrites relative links, leaves absolute/anchor links a assert.match(output, /\[e\]\(#section\)/); }); +test('absolutizeDocLinks prepends only the domain to a site-root-relative link, without doubling /docs/', () => { + const output = absolutizeDocLinks('[a](/docs/drivers/Foo) [b](/img/x.png)'); + assert.match(output, /\[a\]\(https:\/\/robotiq\.github\.io\/docs\/drivers\/Foo\)/); + assert.doesNotMatch(output, /docs\/docs/); + assert.match(output, /\[b\]\(https:\/\/robotiq\.github\.io\/img\/x\.png\)/); +}); + function fakeIntro(overrides = {}) { const sections = { SDK: '| sdk |', ROS2: '| ros2 |', ROS1: '| ros1 |', PHYSICS_ENGINE: '| phys |', OTHER: '| other |', ...overrides }; return Object.entries(sections) From bb3b0947fb8c78750ab7767dd6a734e5a9284069 Mon Sep 17 00:00:00 2001 From: bcastets-robotiq Date: Mon, 21 Sep 2026 15:32:05 -0400 Subject: [PATCH 4/4] test: add fixture, idempotence, and unchanged-on-failure coverage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the three gaps against mbegin-robotiq's suggested test matrix (PR #2 comment) that the first test-suite commit didn't cover: - scripts/fixtures/intro.mdx, a real snapshot of the docs site's intro.mdx, used to exercise the marker/link regexes against actual badge markup and multi-column tables instead of only synthetic single-cell markers — catches upstream format drift that a marker-presence check alone wouldn't. - An idempotence test: regenerating from the same inputs twice produces byte-identical output. - A file-level test that a failed second replaceBetweenMarkers call (mirroring main()'s read-transform-write sequence) leaves the README file on disk completely untouched, not just that the function throws. Co-Authored-By: Claude Sonnet 5 --- scripts/fixtures/intro.mdx | 83 ++++++++++++++++++++++++++++++++++ scripts/update-readme.test.mjs | 77 +++++++++++++++++++++++++++++++ 2 files changed, 160 insertions(+) create mode 100644 scripts/fixtures/intro.mdx diff --git a/scripts/fixtures/intro.mdx b/scripts/fixtures/intro.mdx new file mode 100644 index 0000000..25818c8 --- /dev/null +++ b/scripts/fixtures/intro.mdx @@ -0,0 +1,83 @@ +--- +title: Overview +sidebar_label: Overview +sidebar_position: 0 +--- + +## Hardware documentation + +Robotiq hardware communication protocols are described in detail in the product +manuals, which serve as the reference for developing custom drivers. + +Hardware manuals are available on Robotiq support website: +https://robotiq.com/support + +## Software tools + +Badge colors indicate who maintains the integration: ![Robotiq](https://img.shields.io/badge/Robotiq-blue) an official Robotiq-maintained driver, ![Third party](https://img.shields.io/badge/Third_party-lightgrey) a community-maintained project. + +### SDKs/languages + +{/* AUTO-GENERATED-SDK-TABLE:START */} +| Product | C | C++ | Python | +|---|---|---|---| +| [2F / Hand-E](drivers/2F%20hande) | - | [![Robotiq](https://img.shields.io/badge/Robotiq-blue)](drivers/2F%20hande/SDK/C++) | [![Third party](https://img.shields.io/badge/Third_party-lightgrey)](drivers/2F%20hande/SDK/Python) | +| [FT300-S](drivers/FT300) | [![Robotiq](https://img.shields.io/badge/Robotiq-blue)](drivers/FT300/SDK/C) | - | [![Third party](https://img.shields.io/badge/Third_party-lightgrey)](drivers/FT300/SDK/Python) | +| [TSF-85](drivers/TSF-85) | - | [![Robotiq](https://img.shields.io/badge/Robotiq-blue)](drivers/TSF-85/SDK/C++) | [![Robotiq](https://img.shields.io/badge/Robotiq-blue)](drivers/TSF-85/SDK/Python) | + +- **C** — Low-level C driver talking directly to the hardware's communication protocol (e.g. Modbus RTU, serial). +- **C++** — Low-level C++ driver/SDK for direct hardware integration. +- **Python** — Python driver/SDK for scripting and rapid prototyping. +{/* AUTO-GENERATED-SDK-TABLE:END */} + +### ROS + +#### ROS2 + +{/* AUTO-GENERATED-ROS2-TABLE:START */} +| Product | Rolling | Jazzy | Iron | Humble | +|---|---|---|---|---| +| [2F / Hand-E](drivers/2F%20hande) | [![Third party](https://img.shields.io/badge/Third_party-lightgrey)](drivers/2F%20hande/ROS/ROS2-Rolling) | - | [![Third party](https://img.shields.io/badge/Third_party-lightgrey)](drivers/2F%20hande/ROS/ROS2-Iron) | [![Third party](https://img.shields.io/badge/Third_party-lightgrey)](drivers/2F%20hande/ROS/ROS2-Humble) | +| [FT300-S](drivers/FT300) | - | - | - | [![Third party](https://img.shields.io/badge/Third_party-lightgrey)](drivers/FT300/ROS/ROS2-Humble) | +| [TSF-85](drivers/TSF-85) | - | [![Robotiq](https://img.shields.io/badge/Robotiq-blue)](drivers/TSF-85/ROS/ROS2-Jazzy) | - | - | + +- **Rolling** — ROS 2 rolling development distro, always tracking the latest sources. +- **Jazzy** — ROS 2 LTS release (2024), supported until 2029. +- **Iron** — ROS 2 release (2023), end of life. +- **Humble** — ROS 2 LTS release (2022), supported until 2027. +{/* AUTO-GENERATED-ROS2-TABLE:END */} + +#### ROS1 + +{/* AUTO-GENERATED-ROS1-TABLE:START */} +| Product | Melodic | Kinetic | Jade | Indigo | +|---|---|---|---|---| +| [2F / Hand-E](drivers/2F%20hande) | [![Third party](https://img.shields.io/badge/Third_party-lightgrey)](drivers/2F%20hande/ROS/ROS1-Melodic) | [![Third party](https://img.shields.io/badge/Third_party-lightgrey)](drivers/2F%20hande/ROS/ROS1-Kinetic) | [![Third party](https://img.shields.io/badge/Third_party-lightgrey)](drivers/2F%20hande/ROS/ROS1-Jade) | [![Third party](https://img.shields.io/badge/Third_party-lightgrey)](drivers/2F%20hande/ROS/ROS1-Indigo) | + +- **Melodic** — ROS 1 release (2018), end of life. +- **Kinetic** — ROS 1 release (2016), end of life. +- **Jade** — ROS 1 release (2015), end of life. +- **Indigo** — ROS 1 release (2014), end of life. +{/* AUTO-GENERATED-ROS1-TABLE:END */} + +### Physics engine + +{/* AUTO-GENERATED-PHYSICS_ENGINE-TABLE:START */} +| Product | Isaac Sim | PyBullet | +|---|---|---| +| [2F / Hand-E](drivers/2F%20hande) | [![Third party](https://img.shields.io/badge/Third_party-lightgrey)](drivers/2F%20hande/Physics%20Engine/Isaac%20Sim) | [![Third party](https://img.shields.io/badge/Third_party-lightgrey)](drivers/2F%20hande/Physics%20Engine/PyBullet) | +| [TSF-85](drivers/TSF-85) | [![Third party](https://img.shields.io/badge/Third_party-lightgrey)](drivers/TSF-85/Physics%20Engine/Isaac%20Sim) | - | + +- **Isaac Sim** — NVIDIA Isaac Sim integration for simulating the hardware. +- **PyBullet** — PyBullet integration for physics-based simulation. +{/* AUTO-GENERATED-PHYSICS_ENGINE-TABLE:END */} + +### Other community projects + +{/* AUTO-GENERATED-OTHER-TABLE:START */} +| Product | GraspGen | +|---|---| +| [2F / Hand-E](drivers/2F%20hande) | [![Third party](https://img.shields.io/badge/Third_party-lightgrey)](drivers/2F%20hande/Other/GraspGen) | + +- **GraspGen** — NVIDIA GraspGen asset/model package with Robotiq gripper definitions for grasp synthesis research. +{/* AUTO-GENERATED-OTHER-TABLE:END */} \ No newline at end of file diff --git a/scripts/update-readme.test.mjs b/scripts/update-readme.test.mjs index da6ab01..fdfa1de 100644 --- a/scripts/update-readme.test.mjs +++ b/scripts/update-readme.test.mjs @@ -1,5 +1,9 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; import { escapeCell, buildRepoTable, @@ -9,6 +13,8 @@ import { replaceBetweenMarkers, } from './update-readme.mjs'; +const FIXTURES_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), 'fixtures'); + test('escapeCell escapes pipes and collapses newlines', () => { assert.equal(escapeCell('a | b'), 'a \\| b'); assert.equal(escapeCell('line one\nline two'), 'line one line two'); @@ -109,3 +115,74 @@ test('replaceBetweenMarkers only touches the named marker pair, leaving the rest assert.match(result, /new repos/); assert.match(result, /old tools/); }); + +// Mirrors main()'s actual sequence: read the README once, chain +// replaceBetweenMarkers calls over the in-memory string, write once at the +// end. If a later call throws (e.g. the README's own SOFTWARE-TOOLS marker +// is damaged), fs.writeFileSync is never reached — proving that on disk, +// not just in the return value of one function call. +test('a failed second replacement leaves the README file on disk completely unchanged', () => { + const dir = mkdtempSync(path.join(tmpdir(), 'update-readme-test-')); + const file = path.join(dir, 'README.md'); + const original = [ + '# Robotiq', + '', + 'old repos', + '', + 'no software tools markers here', + ].join('\n'); + writeFileSync(file, original, 'utf8'); + + try { + assert.throws(() => { + let text = readFileSync(file, 'utf8'); + text = replaceBetweenMarkers(text, 'REPOS-TABLE', 'new repos'); + text = replaceBetweenMarkers(text, 'SOFTWARE-TOOLS', 'new tools'); // throws — SOFTWARE-TOOLS marker absent + writeFileSync(file, text, 'utf8'); // never reached + }, /Markers not found/); + + assert.equal(readFileSync(file, 'utf8'), original); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +// A real docs/intro.mdx snapshot (scripts/fixtures/intro.mdx), not the +// minimal synthetic markers used above — exercises the actual regexes +// against real badge markup, multi-column tables and legend bullets, so a +// change to extractMarkerBlock/absolutizeDocLinks that breaks on real +// formatting (but not on the synthetic fixture) shows up here. +test('buildSoftwareToolsSection matches a real intro.mdx fixture (catches upstream format drift)', () => { + const fixture = readFileSync(path.join(FIXTURES_DIR, 'intro.mdx'), 'utf8'); + const section = buildSoftwareToolsSection(fixture); + + const headings = [...section.matchAll(/^#### (.+)$/gm)].map((m) => m[1]); + assert.deepEqual(headings, ['SDKs/languages', 'ROS2', 'ROS1', 'Physics engine', 'Other community projects']); + + // Links absolutized against the docs site, not left root-relative. + assert.match(section, /\[2F \/ Hand-E\]\(https:\/\/robotiq\.github\.io\/docs\/drivers\/2F%20hande\)/); + assert.match(section, /\]\(https:\/\/robotiq\.github\.io\/docs\/drivers\/2F%20hande\/SDK\/C\+\+\)/); + // Legend text (not just table rows) survives. + assert.match(section, /ROS 2 LTS release \(2022\), supported until 2027\./); + // Nothing upstream-relative leaks into the README unresolved. + assert.doesNotMatch(section, /\]\(drivers\//); +}); + +test('regenerating from the same inputs is idempotent — byte-identical output both times', () => { + const repos = [ + { name: 'grippers', url: 'https://github.com/robotiq/grippers', description: 'A driver' }, + { name: 'ros', url: 'https://github.com/robotiq/ros', description: 'ROS packages' }, + ]; + assert.equal(buildRepoTable(repos), buildRepoTable(repos)); + + const fixture = readFileSync(path.join(FIXTURES_DIR, 'intro.mdx'), 'utf8'); + assert.equal(buildSoftwareToolsSection(fixture), buildSoftwareToolsSection(fixture)); + + // Applying the same replacement twice in sequence (as a second run of the + // script would, against its own previous output) reaches a fixed point. + const text = 'x'; + const content = buildRepoTable(repos); + const once = replaceBetweenMarkers(text, 'REPOS-TABLE', content); + const twice = replaceBetweenMarkers(once, 'REPOS-TABLE', content); + assert.equal(once, twice); +});