diff --git a/.claude/agents/testlink-qa.md b/.claude/agents/testlink-qa.md new file mode 100644 index 0000000000..7806fc6ee5 --- /dev/null +++ b/.claude/agents/testlink-qa.md @@ -0,0 +1,32 @@ +--- +name: testlink-qa +description: QA operator for the TestLink test management tool. Use for browsing or searching test cases, authoring test cases and suites (with steps), planning test runs (plans, builds, linking, assignments), recording execution verdicts with evidence, and summarizing test plan health or flakiness. Requires the "testlink" MCP server (TESTLINK_APIKEY set). +tools: Read, Bash, mcp__testlink__* +--- + +You are a QA operator working against a TestLink instance through the `testlink` MCP tools. You browse the test case repository, author well-structured test cases, organize test runs, record execution results with evidence, and report on plan health. + +## Ground rules + +- **Report every ID you create.** Whenever you create a suite, case, plan, build, or execution, state its numeric id (and for cases, the external id such as `P8T-123` after fetching it with `get_case`) so the user can find it in the TestLink UI and you can reference it later. +- **Confirm before destructive or bulk operations.** Ask the user before: replacing a test case's steps (`update_case` with `steps` REPLACES the whole list), bulk-linking many cases to a plan, bulk assignments, unassigning testers (userId 0), or recording executions for more than a handful of cases at once. Small single-item creations (one suite, one case, one verdict the user asked for) do not need re-confirmation. +- **Never fabricate results.** Only record an execution verdict when the user tells you the outcome or you have real evidence (e.g. output from a command you ran with Bash). Put the evidence in the execution notes: what was observed, environment, log excerpts, and the source of the evidence. +- **Ask when the target is ambiguous.** If several projects/plans/suites could match what the user means, list the candidates and ask instead of guessing. + +## How to navigate + +- IDs: most tools use the **internal** case id (`tcase_id`); only `record_execution` uses the **external** id (`PREFIX-number`, from `tc_external_id` plus the project prefix). `get_case` shows both. +- Start sessions with `list_projects`, then drill down with `list_suites` / `list_suite_cases` or jump straight to `search_cases`. +- `get_case` returns full steps plus recent executions (verdict, build, tester, notes) — use it before editing a case and when investigating a failure history. + +## Workflows + +**Author a case:** find or `create_suite` the right suite → `create_case` with summary, preconditions, importance, and numbered steps (each step: concrete action + observable expected result) → report the new id. To modify later, `get_case` first, then `update_case`; when changing steps, send the complete final step list. + +**Plan a run:** `create_plan` (or pick an existing one) → `create_build` for the version under test → `link_cases_to_plan` with the chosen case ids → optionally `assign_cases` to testers from `list_users` → show the resulting `plan_queue`. + +**Record results:** for each executed case, `record_execution` with plan id, build id, external case id, verdict (`p`/`f`/`b`) and evidence notes. Summarize what was recorded (execution ids included). + +**Report health:** `plan_status` for totals and per-build breakdown; `plan_queue` for what is left to run and who owns it; `plan_flaky` for unstable cases (keep `days` small — 7 or less — on large plans; wide windows can exhaust server memory). Present numbers plainly: passed/failed/blocked/not-run counts, pass rate, notable failures and flaky cases, and what to do next. + +You may use Read to inspect files in this repository (e.g. to base test cases on actual code or docs) and Bash for read-only checks such as curl probes when the user asks you to gather evidence. Do not modify repository files. diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 0000000000..8085070322 --- /dev/null +++ b/.mcp.json @@ -0,0 +1,13 @@ +{ + "mcpServers": { + "testlink": { + "type": "stdio", + "command": "node", + "args": ["mcp/dist/index.js"], + "env": { + "TESTLINK_URL": "${TESTLINK_URL:-http://localhost:8090}", + "TESTLINK_APIKEY": "${TESTLINK_APIKEY}" + } + } + } +} diff --git a/cfg/oauth_samples/oauth.oidc.inc.php b/cfg/oauth_samples/oauth.oidc.inc.php new file mode 100644 index 0000000000..010a97bf8d --- /dev/null +++ b/cfg/oauth_samples/oauth.oidc.inc.php @@ -0,0 +1,54 @@ +OAuthServers['oidc'] = array(); +$tlCfg->OAuthServers['oidc']['oauth_name'] = 'oidc'; // do not change this +$tlCfg->OAuthServers['oidc']['oauth_enabled'] = true; +$tlCfg->OAuthServers['oidc']['oauth_grant_type'] = 'authorization_code'; + +$tlCfg->OAuthServers['oidc']['redirect_uri'] = + (empty($_SERVER['HTTPS']) ? 'http://' : 'https://') . + $_SERVER['HTTP_HOST'] . '/login.php'; + +$tlCfg->OAuthServers['oidc']['oauth_client_id'] = 'CHANGE_WITH_CLIENT_ID'; +$tlCfg->OAuthServers['oidc']['oauth_client_secret'] = 'CHANGE_WITH_CLIENT_SECRET'; + +// EITHER: point at the discovery document and let TestLink resolve +// the endpoints automatically ... +// +// Keycloak: https:///realms//.well-known/openid-configuration +// Okta: https://.okta.com/.well-known/openid-configuration +// Auth0: https://.auth0.com/.well-known/openid-configuration +// Azure v2: https://login.microsoftonline.com//v2.0/.well-known/openid-configuration +$tlCfg->OAuthServers['oidc']['discovery_url'] = + 'https://CHANGE_WITH_IDP_HOST/.well-known/openid-configuration'; + +// ... OR configure the three endpoints explicitly (discovery wins +// only for keys left empty): +// $tlCfg->OAuthServers['oidc']['oauth_url'] = ''; // authorization_endpoint +// $tlCfg->OAuthServers['oidc']['token_url'] = ''; // token_endpoint +// $tlCfg->OAuthServers['oidc']['oauth_profile'] = ''; // userinfo_endpoint + +$tlCfg->OAuthServers['oidc']['oauth_scope'] = 'openid profile email'; + +// optional: only allow users whose email is on this domain +// $tlCfg->OAuthServers['oidc']['oauth_domain'] = 'example.com'; diff --git a/cfg/reports.cfg.php b/cfg/reports.cfg.php index ac915e9d5c..a61d7e6970 100644 --- a/cfg/reports.cfg.php +++ b/cfg/reports.cfg.php @@ -84,15 +84,24 @@ 'format' => 'format_html,format_pseudo_ods' ); -$tlCfg->reports_list['report_by_tsuite'] = +$tlCfg->reports_list['report_by_tsuite'] = array('title' => 'link_report_by_tsuite', 'url' => 'lib/results/resultsByTSuite.php', 'enabled' => 'all', - 'directLink' => + 'directLink' => '%slnl.php?apikey=%s&tproject_id=%s&tplan_id=%s&format=0&type=report_by_tsuite', 'format' => 'format_html' ); +$tlCfg->reports_list['results_trend'] = + array('title' => 'link_report_results_trend', + 'url' => 'lib/results/resultsTrend.php', + 'enabled' => 'all', + 'directLink' => + '%slnl.php?apikey=%s&tproject_id=%s&tplan_id=%s&format=0&type=results_trend', + 'format' => 'format_html' +); + $tlCfg->reports_list['baseline_l1l2'] = array('title' => 'baseline_l1l2', 'url' => 'lib/results/baselinel1l2.php', diff --git a/config.inc.php b/config.inc.php index 008e65c755..4537bc28c1 100644 --- a/config.inc.php +++ b/config.inc.php @@ -313,6 +313,20 @@ $tlCfg->notifications->userSignUp->to->roles = array(TL_ROLES_ADMIN); $tlCfg->notifications->userSignUp->to->users = null; // i.e. array('login01','login02'); +/** + * Outgoing webhooks - notify external systems (Slack/Teams bridges, CI, etc.) + * when events happen inside TestLink. Disabled when empty. + * @see lib/functions/webhook_api.php for payload format and signature. + * + * Example (put in custom_config.inc.php): + * $tlCfg->webhooks = array( + * array('url' => 'https://hooks.example.com/testlink', + * 'events' => array('execution.created'), // or array('*') + * 'secret' => 'shared-secret'), + * ); + */ +$tlCfg->webhooks = array(); + // ---------------------------------------------------------------------------- /* [LOGGING] */ diff --git a/docs/modernization/MODERNIZATION.md b/docs/modernization/MODERNIZATION.md new file mode 100644 index 0000000000..111dd96de1 --- /dev/null +++ b/docs/modernization/MODERNIZATION.md @@ -0,0 +1,214 @@ +# TestLink Modernization (branch `php8-modernization`) + +This document describes every change on the `php8-modernization` +branch, with before/after code for the important fixes, measured +performance numbers, installation steps, and cautions. It is meant to +be read next to the existing [README](../../README.md), which remains +authoritative for classic TestLink — nothing in the legacy application +was removed or redesigned; everything here is **additive**. + +- **Base:** `testlink_1_9_20_fixed` @ `444bceb5f` (TestLink 1.9.20 "Raijin", 2025 refactor) +- **Branch:** `php8-modernization` — 9 commits, 62+ files, ≈13,000 added lines +- **Runtime verified on:** PHP 8.2.32, MariaDB 12.3.2, Node 24 (UI build only) +- **AI assistance:** developed in a Claude Code session powered by + **Claude Fable 5**, with Sonnet/Opus subagents used for repetitive + modules. Every change was runtime-verified against a live instance + (10,000 test cases / 500,000 executions) before being committed. + +--- + +## 1. PHP 8 fatal-error fixes (commit `fix: PHP 8 fatal errors…`) + +These were hard crashes on PHP 8, found by running the application +(not just linting). + +### 1.1 `lib/functions/string_api.php` — undefined constants + removed `split()` + +Rendering **any value containing a URL** (e.g. a string custom field +shown through `string_display_links()`) crashed with +`Error: Undefined constant "LINKS_NEW_WINDOW"`. The constants were +adapted from MantisBT but never defined in TestLink. `split()` was +removed in PHP 7.0. + +```php +// BEFORE (crashes on PHP 8) +if( config_get( 'html_make_links' ) == LINKS_NEW_WINDOW ) { // undefined +... +list( $t_path, $t_param ) = split( '\?', $t_url, 2 ); // removed fn + +// AFTER +if( !defined( 'LINKS_SAME_WINDOW' ) ) { define( 'LINKS_SAME_WINDOW', 1 ); } +if( !defined( 'LINKS_NEW_WINDOW' ) ) { define( 'LINKS_NEW_WINDOW', 2 ); } +... +list( $t_path, $t_param ) = explode( '?', $t_url, 2 ); +``` + +### 1.2 `lib/api/xmlrpc/v1/xmlrpc.class.php` — parse error killed the whole XML-RPC API + +```php +// BEFORE — parse error, the entire XML-RPC v1 API failed to load +$platformSet = (arrya)$this->tplanMgr->getPlatforms( $tplan_id, $opt ); +// AFTER +$platformSet = (array)$this->tplanMgr->getPlatforms( $tplan_id, $opt ); +``` + +### 1.3 Others +- `lib/api/rest/v3/custom/api/RestApiCustomExample.class.php` — bare + `lib` constant (fatal on REST v3 load) → quoted. +- `lib/functions/testplan.class.php` — `${var}` string interpolation + (deprecated 8.2) → `{$var}`. +- `tcImport.php`, `tcCreateFromIssue.php`, `neverRunByPP.php` — + optional-parameter-before-required declarations. +- REST v3 `authenticate()` — header lookup now uses PSR-7 + `getHeaderLine()` (case-insensitive; proxies lowercase header names) + and the 401 response it builds is actually returned + (`$response->withStatus(401)` result was previously discarded). +- Known remaining upstream issue (worked around, not touched): + `requirement_spec_mgr::create()`'s default `$type` references the + undefined constant `TL_REQ_SPEC_TYPE_FEATURE`; all callers must pass + the type explicitly (the new REST endpoint does). + +## 2. Performance (commit `perf: add execution indexes…` and query reshapes) + +Measured on a single plan with **500,000 execution rows**. + +| Endpoint / page | Before | After | How | +|---|---|---|---| +| plan status totals | 123 ms (full scan) | **46 ms** | new index + query reshape below | +| flaky analysis (30-day window) | PHP memory-limit **fatal** | **~0.2 s** (365-day: 0.29 s) | window bound + row streaming | +| dashboard end-to-end (SPA, production build) | — | **~0.4 s** | 7.5× under a 3 s budget | +| legacy `resultsTC.php` result matrix | 11.1 MB HTML per view | ~30 KB (SPA equivalent) | pagination + by-suite rollup | + +New migration `install/sql/alter_tables/1.9.21/mysql/DB.1.9.21/step1/db_schema_update.sql`: + +```sql +ALTER TABLE /*prefix*/executions + ADD INDEX /*prefix*/idx_exec_tplan_tcv_id (testplan_id, tcversion_id, id); +ALTER TABLE /*prefix*/executions + ADD INDEX /*prefix*/idx_exec_tplan_ts (testplan_id, execution_ts); +``` + +Representative query reshape (`getPlanSummary`): + +```sql +-- BEFORE: window function forces a scan of every execution of the plan +SELECT status, COUNT(*) FROM ( + SELECT E.status, + ROW_NUMBER() OVER (PARTITION BY E.tcversion_id ORDER BY E.id DESC) rn + FROM executions E WHERE E.testplan_id = ?) Z +WHERE rn = 1 GROUP BY status; + +-- AFTER: loose index scan + PK join; cost follows #versions, not #executions +SELECT E2.status, COUNT(*) FROM ( + SELECT MAX(id) AS mid FROM executions + WHERE testplan_id = ? GROUP BY tcversion_id) M +JOIN executions E2 ON E2.id = M.mid +GROUP BY E2.status; +``` + +## 3. New backend features + +- **Webhooks** (`lib/functions/webhook_api.php`) — HMAC-SHA256-signed + JSON POST (`X-TestLink-Signature`, GitHub style) on + `execution.created`, fired from both the UI and API write paths. + Best-effort with short timeouts; disabled unless `$tlCfg->webhooks` + is configured. +- **Generic OIDC login** (`lib/functions/oauth_providers/oidc.php` + + sample `cfg/oauth_samples/oauth.oidc.inc.php`) — works with any + spec-compliant IdP (Keycloak, Okta, Auth0, Azure AD v2). Endpoints + may be resolved from `.well-known/openid-configuration`. Identity is + read from the `userinfo` endpoint over TLS (no local JWT + verification needed). Also fixes an undefined-variable bug in the + azuread branch of `OAuth2Call.php`. +- **~60 REST v3 JSON endpoints** powering the new UI (and useful + standalone): auth login → API key, suites tree, case detail/update + (step replace), case/suite/plan/build/project creation, plan link, + execution recording, per-plan summary / trend / flaky / queue / + matrix (case & suite) / by-tester / by-build / by-keyword, + assignments, users admin, keywords, platforms, custom fields, + requirements (specs, coverage, plan rollup), milestones, search, + execution attachments (upload/list/download), printable documents, + TestLink-XML export/import. Pagination via `?page=&limit=` + everywhere it matters; legacy callers keep their old behavior. +- **Execution trend & flaky report** for the legacy UI too + (`lib/results/resultsTrend.php`, dependency-free server-side SVG, + registered in the reports menu). + +## 4. New SPA (`ui/`) + +React 19 + Vite + TypeScript + TanStack Router/Query + Tailwind v4. +103 KB gzipped, served as static files from `ui/dist/` by the same +web server — no extra services. Localized in **English, Korean, +Vietnamese** (typed dictionaries; an incomplete translation is a +compile error). Screens: + +Dashboard · Test cases (tree, authoring with steps, XML import/export, +printable spec) · Requirements (specs, coverage, plan rollup) · Run +(work queue, verdict recorder, evidence attachments, assignments) · +Matrix (by-suite ratio bars ⇄ paginated case grid) · Plans (projects, +plans, builds, milestones) · Reports (trend, build comparison, +by tester, by keyword, flaky, CSV, printable report) · Admin (users, +keywords, platforms, custom fields) · global search. + +### Before / after + +| Legacy | Modern | +|---|---| +| ![before metrics](images/before-legacy-metrics.jpg) | ![after dashboard](images/after-dashboard.jpg) | +| ![before spec](images/before-legacy-testspec.jpg) | ![after test cases](images/after-testcases.jpg) | +| ![before execution](images/before-legacy-execution.jpg) | ![after run (Korean)](images/after-run-korean.jpg) | + +More: ![matrix](images/after-matrix-by-suite.jpg) +![requirements](images/after-requirements.jpg) +![admin](images/after-admin.jpg) + +## 5. MCP server & agent (`mcp/`, `.mcp.json`, `.claude/agents/`) + +A Model Context Protocol server (TypeScript, stdio) exposing 19 +agent-ergonomic tools (`search_cases`, `get_case`, `create_case`, +`record_execution`, `plan_status`, `plan_flaky`, `assign_cases`, …) +over the REST API, plus a ready-made `testlink-qa` Claude Code +subagent definition. See [mcp/README.md](../../mcp/README.md). + +## 6. Installation + +Classic installation is unchanged — follow the existing README. +Additions for this branch: + +1. **PHP 8.2+ / MariaDB or MySQL** as usual; run the classic installer. +2. **Apply the new indexes** (existing installs): + `install/sql/alter_tables/1.9.21/mysql/DB.1.9.21/step1/db_schema_update.sql` + (replace `/*prefix*/` with your table prefix, usually empty). +3. **Build the SPA** (optional but recommended): + ```bash + cd ui && npm install && npm run build + # then open http:///ui/dist/index.html and sign in + ``` +4. **Webhooks / OIDC** (optional): copy the samples referenced in + `config.inc.php` (`$tlCfg->webhooks`) and + `cfg/oauth_samples/oauth.oidc.inc.php` into your + `custom_config.inc.php`. +5. **MCP** (optional): `cd mcp && npm install && npm run build`, then + export `TESTLINK_APIKEY` and open the repo in Claude Code + (`.mcp.json` is auto-detected). + +## 7. Cautions + +- **Change the default `admin/admin` password** before exposing the + instance anywhere. +- The SPA authenticates with the user's **API key** (minted at first + SPA login via `POST /auth/login`); treat it like a password. +- The new REST endpoints require the `Apikey` header; 401 is returned + otherwise (this was previously a 200 — clients relying on that bug + should be updated). +- `deleteKeyword` also removes keyword↔case links (same as legacy + management behavior). +- XML import skips cases whose **name already exists in the target + suite** (idempotent re-import). +- Whole-project spec documents are capped at 2,000 cases with a + visible truncation note. +- The flaky report defaults to a **30-day window** (`?days=`, max 365). +- The legacy frames UI is untouched and fully functional; the SPA is + additive and can be adopted gradually. +- `ui/node_modules`, `ui/dist`, `mcp/node_modules`, `mcp/dist` are + build artifacts (not committed). diff --git a/docs/modernization/images/after-admin.jpg b/docs/modernization/images/after-admin.jpg new file mode 100644 index 0000000000..c09e4c08b4 Binary files /dev/null and b/docs/modernization/images/after-admin.jpg differ diff --git a/docs/modernization/images/after-dashboard.jpg b/docs/modernization/images/after-dashboard.jpg new file mode 100644 index 0000000000..011ca63a08 Binary files /dev/null and b/docs/modernization/images/after-dashboard.jpg differ diff --git a/docs/modernization/images/after-matrix-by-suite.jpg b/docs/modernization/images/after-matrix-by-suite.jpg new file mode 100644 index 0000000000..c69abc3bfd Binary files /dev/null and b/docs/modernization/images/after-matrix-by-suite.jpg differ diff --git a/docs/modernization/images/after-requirements.jpg b/docs/modernization/images/after-requirements.jpg new file mode 100644 index 0000000000..88743d64f0 Binary files /dev/null and b/docs/modernization/images/after-requirements.jpg differ diff --git a/docs/modernization/images/after-run-korean.jpg b/docs/modernization/images/after-run-korean.jpg new file mode 100644 index 0000000000..c8fefb4419 Binary files /dev/null and b/docs/modernization/images/after-run-korean.jpg differ diff --git a/docs/modernization/images/after-testcases.jpg b/docs/modernization/images/after-testcases.jpg new file mode 100644 index 0000000000..98d0fb381f Binary files /dev/null and b/docs/modernization/images/after-testcases.jpg differ diff --git a/docs/modernization/images/before-legacy-execution.jpg b/docs/modernization/images/before-legacy-execution.jpg new file mode 100644 index 0000000000..9bb50d3406 Binary files /dev/null and b/docs/modernization/images/before-legacy-execution.jpg differ diff --git a/docs/modernization/images/before-legacy-metrics.jpg b/docs/modernization/images/before-legacy-metrics.jpg new file mode 100644 index 0000000000..9f74eed5b9 Binary files /dev/null and b/docs/modernization/images/before-legacy-metrics.jpg differ diff --git a/docs/modernization/images/before-legacy-testspec.jpg b/docs/modernization/images/before-legacy-testspec.jpg new file mode 100644 index 0000000000..4ece7a7c93 Binary files /dev/null and b/docs/modernization/images/before-legacy-testspec.jpg differ diff --git a/gui/templates/tl-classic/results/resultsTrend.tpl b/gui/templates/tl-classic/results/resultsTrend.tpl new file mode 100644 index 0000000000..c1cb0dcc72 --- /dev/null +++ b/gui/templates/tl-classic/results/resultsTrend.tpl @@ -0,0 +1,58 @@ +{* +TestLink Open Source Project - http://testlink.sourceforge.net/ + +Purpose: smarty template - execution trend & flaky test report + +@filesource resultsTrend.tpl +*} + +{lang_get var="labels" + s='testproject,test_plan,th_test_case,trend_no_data, + trend_daily_executions,trend_flaky_title,trend_flaky_help, + trend_flaky_flips,trend_flaky_execs,trend_analyzed_hint'} + +{include file="inc_head.tpl"} + + +

{$gui->title}

+ +
+ {$labels.testproject}: {$gui->tproject_name|escape}   + {$labels.test_plan}: {$gui->tplan_name|escape} +
+ +

{$labels.trend_daily_executions}

+{if $gui->trendSVG != ''} +
+ {$gui->trendSVG} +
+{else} +
{$labels.trend_no_data}
+{/if} + +

{$labels.trend_flaky_title}

+
+ {$labels.trend_flaky_help}
+ {$labels.trend_analyzed_hint}: {$gui->flakyAnalyzed} +
+{if count($gui->flaky) > 0} + + + + + + + {foreach $gui->flaky as $tcvid => $item} + + + + + + {/foreach} +
{$labels.th_test_case}{$labels.trend_flaky_flips}{$labels.trend_flaky_execs}
{$item.name|escape}{$item.flips}{$item.total}
+{else} +
{$labels.trend_no_data}
+{/if} + + + diff --git a/install/sql/alter_tables/1.9.21/mysql/DB.1.9.21/step1/db_schema_update.sql b/install/sql/alter_tables/1.9.21/mysql/DB.1.9.21/step1/db_schema_update.sql new file mode 100644 index 0000000000..31225f2649 --- /dev/null +++ b/install/sql/alter_tables/1.9.21/mysql/DB.1.9.21/step1/db_schema_update.sql @@ -0,0 +1,29 @@ +# TestLink Open Source Project - http://testlink.sourceforge.net/ +# This script is distributed under the GNU General Public License 2 or later. +# --------------------------------------------------------------------------------------- +# @filesource db_schema_update.sql +# +# SQL script - updates DB schema for MySQL - +# From TestLink 1.9.20 to 1.9.21 +# +# Performance indexes for execution-heavy installations. +# +# The dashboard/metrics queries filter executions by test plan and +# resolve the LATEST execution per test case version. The historic +# executions_idx1 index starts with tcversion_id, so a plan-scoped +# scan could not use it and degraded to a full table scan +# (measured: full scan over 500k rows for status totals). +# +# idx_exec_tplan_tcv_id backs: +# - latest-status-per-version windows +# (WHERE testplan_id=? ... PARTITION BY tcversion_id ORDER BY id) +# - flaky/flip analysis (WHERE testplan_id=? ORDER BY tcversion_id, id) +# idx_exec_tplan_ts backs: +# - daily trend aggregation +# (WHERE testplan_id=? GROUP BY DATE(execution_ts)) + +ALTER TABLE /*prefix*/executions + ADD INDEX /*prefix*/idx_exec_tplan_tcv_id (testplan_id, tcversion_id, id); + +ALTER TABLE /*prefix*/executions + ADD INDEX /*prefix*/idx_exec_tplan_ts (testplan_id, execution_ts); diff --git a/lib/api/rest/v3/RestApi.class.php b/lib/api/rest/v3/RestApi.class.php index fe418bddec..bd529a3d9f 100644 --- a/lib/api/rest/v3/RestApi.class.php +++ b/lib/api/rest/v3/RestApi.class.php @@ -30,6 +30,9 @@ require_once('../../../../config.inc.php'); require_once('common.php'); +// exportDataToXML() lives here — needed by the legacy suite/case +// XML exporters reused by the /testsuites/{id}/xml endpoint +require_once('xml.inc.php'); use Psr\Http\Message\ServerRequestInterface as Request; use Psr\Http\Server\RequestHandlerInterface as RequestHandler; @@ -161,30 +164,36 @@ public function __construct() { /** * */ - public function authenticate(Request $request, RequestHandler $handler) + public function authenticate(Request $request, RequestHandler $handler) { - $hh = $request->getHeaders(); - + // the login endpoint is the one route that cannot require a key + $rp = $request->getUri()->getPath(); + if (substr($rp, -11) == '/auth/login' && + strtoupper($request->getMethod()) == 'POST') { + return $handler->handle($request); + } + $apiKey = null; - // @20200317 - Not tested + // @20200317 - Not tested // IMPORTANT NOTICE: 'PHP_AUTH_USER' // it seems this needs special configuration // with Apache when you use CGI Module // http://man.hubwiz.com/docset/PHP.docset/Contents/Resources/ // Documents/php.net/manual/en/features.http-auth.html - // + // + // PSR-7 getHeaderLine() is case-insensitive, so this also matches + // proxies that normalize header names to lowercase. $apiKeySet = [ 'Apikey', - 'ApiKey', - 'APIKEY', 'PHP_AUTH_USER' ]; foreach( $apiKeySet as $accessKey ) { - if (isset($hh[$accessKey])) { - $apiKey = trim($hh[$accessKey][0]); + $hval = trim($request->getHeaderLine($accessKey)); + if ($hval != '') { + $apiKey = $hval; break; - } + } } if ($apiKey != null && $apiKey != '') { @@ -207,16 +216,19 @@ public function authenticate(Request $request, RequestHandler $handler) } $response = new Response(); $response->getBody()->write($msg); - $response->withStatus(401); - return $response; + return $response->withStatus(401); } /** * */ - public function setContentTypeJSON(Request $request, RequestHandler $handler) + public function setContentTypeJSON(Request $request, RequestHandler $handler) { $response = $handler->handle($request); + // binary endpoints (attachment download) set their own type + if ($response->getHeaderLine('Content-Type') != '') { + return $response; + } return $response ->withHeader('Content-Type', 'application/json'); } @@ -319,40 +331,59 @@ private function getProjects($idCard=null, $opt=null) * Will return LATEST VERSION of each test case. * Does return test step info ? * + * Supports optional pagination via query string: + * ?page=<1-based page number>&limit= + * When neither page nor limit is provided, ALL test cases are + * returned in a single response (backward compatible behavior). + * When paginated, the response carries page/limit/total so clients + * can iterate. + * * @param array idCard if provided identifies test project * 'id' -> DBID * 'name' -> - * 'prefix' -> - */ - public function getProjectTestCases(Request $request, Response $response, $idCard) + * 'prefix' -> + */ + public function getProjectTestCases(Request $request, Response $response, $idCard) { - $op = array('status' => 'ok', - 'message' => 'ok', + $op = array('status' => 'ok', + 'message' => 'ok', 'items' => null); - $tproject = $this->getProjects($idCard, + $tproject = $this->getProjects($idCard, array('output' => 'internal')); if( !is_null($tproject) ) { $tcaseIDSet = array(); $this->tprojectMgr->get_all_testcases_id($tproject['id'],$tcaseIDSet); + $qs = $request->getQueryParams(); + $paginated = isset($qs['page']) || isset($qs['limit']); + if( $paginated ) { + $limit = isset($qs['limit']) ? max(1,intval($qs['limit'])) : 100; + $page = isset($qs['page']) ? max(1,intval($qs['page'])) : 1; + $op['total'] = count($tcaseIDSet); + $op['page'] = $page; + $op['limit'] = $limit; + $tcaseIDSet = array_slice($tcaseIDSet,($page-1)*$limit,$limit); + } + if( !is_null($tcaseIDSet) && count($tcaseIDSet) > 0 ) { $op['items'] = array(); foreach( $tcaseIDSet as $key => $tcaseID ) { $item = $this->tcaseMgr->get_last_version_info($tcaseID); - $item['keywords'] = + $item['keywords'] = $this->tcaseMgr->get_keywords_map($tcaseID,$item['tcversion_id']); - $item['customfields'] = + $item['customfields'] = $this->tcaseMgr->get_linked_cfields_at_design($tcaseID,$item['tcversion_id'],null,null,$tproject['id']); $op['items'][] = $item; } } } else { - $op['message'] = "No Test Project identified by '" . $idCard . "'!"; + $op['message'] = "No Test Project identified by '" . + (is_array($idCard) ? implode(',', $idCard) : $idCard) . "'!"; $op['status'] = 'error'; } - + $payload = json_encode($op); $response->getBody()->write($payload); return $response; @@ -1183,8 +1214,8 @@ public function createTestCase(Request $request, } // create obj with standard properties - $op['message'] = 'After buildTestCaseObj() >> ' . $tcase = $this->buildTestCaseObj($item); + $op['message'] = 'After buildTestCaseObj() >> ' . json_encode($tcase); $this->checkRelatives($tcase); $ou = $this->tcaseMgr->createFromObject($tcase); @@ -1215,10 +1246,37 @@ public function createTestCase(Request $request, * "notes" * "testProject": {"prefix":"APR"} */ - public function createKeyword(Request $request, - Response $response, - $args) + public function createKeyword(Request $request, + Response $response, + $args) { + // New SPA/admin path: body carries testProjectID directly. + // {testProjectID, keyword, notes?} -> addKeyword by project id. + // The legacy prefix path (below) is preserved untouched for + // existing callers that send testProject.prefix. + $probe = json_decode((string)$request->getBody()); + if (null != $probe && isset($probe->testProjectID)) { + $op = array('status' => 'ok', 'message' => 'ok'); + try { + if (!isset($probe->keyword) || trim((string)$probe->keyword) === '') { + throw new Exception('Body must carry testProjectID and a non-empty keyword'); + } + $pid = intval($probe->testProjectID); + $notes = isset($probe->notes) ? strval($probe->notes) : ''; + $ou = $this->tprojectMgr->addKeyword($pid, trim((string)$probe->keyword), $notes); + if ($ou['status'] < tl::OK) { + throw new Exception($ou['msg']); + } + $op['id'] = intval($ou['id']); + } catch (Exception $e) { + $op = array('status' => 'error', + 'message' => $this->msgFromException($e)); + $response = $response->withStatus(400); + } + $response->getBody()->write(json_encode($op)); + return $response; + } + $op = $this->getStdIDKO(); try { @@ -1697,7 +1755,2356 @@ function byeHTTP500($msg=null) */ function msgFromException($e) { - return $e->getMessage() . - ' - offending line number: ' . $e->getLine(); + return $e->getMessage() . + ' - offending line number: ' . $e->getLine(); + } + + // ================================================================== + // Endpoints backing the SPA (ui/) — JSON in, JSON out. + // ================================================================== + + /** + * POST /auth/login {login, password} + * On success returns the user's API key (creating one when the + * user has none yet) so the SPA can use the standard Apikey header. + */ + public function authLogin(Request $request, Response $response, $args) + { + $op = array('status' => 'error', 'message' => 'Invalid credentials'); + $item = json_decode($request->getBody()); + + if (null != $item && isset($item->login) && isset($item->password)) { + $user = new tlUser(); + $user->login = trim($item->login); + if ($user->readFromDB($this->db, tlUser::USER_O_SEARCH_BYLOGIN) >= tl::OK && + $user->isActive && + $user->comparePassword($this->db, $item->password) == tl::OK) { + + if (strlen(trim((string)$user->userApiKey)) == 0) { + // no stored API key -> mint one (32 hex chars, column limit) + $user->userApiKey = bin2hex(random_bytes(16)); + $sql = " UPDATE {$this->tables['users']} " . + " SET script_key = '" . + $this->db->prepare_string($user->userApiKey) . "'" . + " WHERE id = " . intval($user->dbID); + $this->db->exec_query($sql); + } + + $op = array('status' => 'ok', + 'apikey' => $user->userApiKey, + 'user' => array('id' => intval($user->dbID), + 'login' => $user->login, + 'firstName' => $user->firstName, + 'lastName' => $user->lastName)); + } + } + + if ($op['status'] != 'ok') { + $response = $response->withStatus(401); + } + $response->getBody()->write(json_encode($op)); + return $response; + } + + /** + * GET /testprojects/{id}/suites + * Full test suite hierarchy of a project plus per-suite test case + * counts, so a client can render the tree without N requests. + */ + public function getProjectSuites(Request $request, Response $response, $args) + { + $safeID = intval($args['id']); + $sql = " WITH RECURSIVE st AS " . + " (SELECT id, parent_id, name, node_order " . + " FROM {$this->tables['nodes_hierarchy']} " . + " WHERE parent_id = {$safeID} AND node_type_id = 2 " . + " UNION ALL " . + " SELECT nh.id, nh.parent_id, nh.name, nh.node_order " . + " FROM {$this->tables['nodes_hierarchy']} nh " . + " JOIN st ON nh.parent_id = st.id " . + " WHERE nh.node_type_id = 2) " . + " SELECT * FROM st ORDER BY parent_id, node_order, id "; + $suites = (array)$this->db->get_recordset($sql); + + $sql = " SELECT parent_id, COUNT(*) AS qty " . + " FROM {$this->tables['nodes_hierarchy']} " . + " WHERE node_type_id = 3 GROUP BY parent_id "; + $counts = array(); + foreach ((array)$this->db->get_recordset($sql) as $row) { + $counts[$row['parent_id']] = intval($row['qty']); + } + + foreach ($suites as &$suite) { + $suite['id'] = intval($suite['id']); + $suite['parent_id'] = intval($suite['parent_id']); + $suite['tcCount'] = isset($counts[$suite['id']]) ? $counts[$suite['id']] : 0; + } + unset($suite); + + $response->getBody()->write(json_encode( + array('status' => 'ok', 'projectID' => $safeID, 'items' => $suites))); + return $response; + } + + /** + * GET /testsuites/{id}/testcases + * Summary rows of the test cases directly inside a suite. + */ + public function getSuiteTestCases(Request $request, Response $response, $args) + { + $safeID = intval($args['id']); + $sql = " SELECT NHTC.id, NHTC.name, NHTC.node_order, " . + " MAX(TCV.version) AS latest_version, " . + " MAX(TCV.tc_external_id) AS tc_external_id, " . + " MAX(TCV.importance) AS importance, " . + " MAX(TCV.status) AS status " . + " FROM {$this->tables['nodes_hierarchy']} NHTC " . + " JOIN {$this->tables['nodes_hierarchy']} NHTCV ON NHTCV.parent_id = NHTC.id " . + " JOIN {$this->tables['tcversions']} TCV ON TCV.id = NHTCV.id " . + " WHERE NHTC.parent_id = {$safeID} AND NHTC.node_type_id = 3 " . + " GROUP BY NHTC.id, NHTC.name, NHTC.node_order " . + " ORDER BY NHTC.node_order, NHTC.id "; + $items = (array)$this->db->get_recordset($sql); + $response->getBody()->write(json_encode( + array('status' => 'ok', 'suiteID' => $safeID, 'items' => $items))); + return $response; + } + + /** + * GET /testcases/{id}/detail + * Latest version of one test case: attributes, steps, recent runs. + */ + public function getTestCaseDetail(Request $request, Response $response, $args) + { + $safeID = intval($args['id']); + + $sql = " SELECT NHTC.name, NHTC.parent_id AS suite_id, TCV.id AS tcversion_id, " . + " TCV.version, TCV.tc_external_id, TCV.summary, TCV.preconditions, " . + " TCV.importance, TCV.status, TCV.execution_type, TCV.active " . + " FROM {$this->tables['nodes_hierarchy']} NHTC " . + " JOIN {$this->tables['nodes_hierarchy']} NHTCV ON NHTCV.parent_id = NHTC.id " . + " JOIN {$this->tables['tcversions']} TCV ON TCV.id = NHTCV.id " . + " WHERE NHTC.id = {$safeID} " . + " ORDER BY TCV.version DESC LIMIT 1 "; + $item = $this->db->get_recordset($sql); + if (is_null($item)) { + $response->getBody()->write(json_encode( + array('status' => 'error', 'message' => 'Test case does not exist'))); + return $response->withStatus(404); + } + $item = current($item); + $tcvID = intval($item['tcversion_id']); + + $sql = " SELECT TCS.step_number, TCS.actions, TCS.expected_results, " . + " TCS.execution_type " . + " FROM {$this->tables['tcsteps']} TCS " . + " JOIN {$this->tables['nodes_hierarchy']} NH ON NH.id = TCS.id " . + " WHERE NH.parent_id = {$tcvID} ORDER BY TCS.step_number "; + $item['steps'] = (array)$this->db->get_recordset($sql); + + $sql = " SELECT E.id, E.status, E.execution_ts, E.build_id, E.notes, " . + " B.name AS build_name, U.login AS tester " . + " FROM {$this->tables['executions']} E " . + " LEFT JOIN {$this->tables['builds']} B ON B.id = E.build_id " . + " LEFT JOIN {$this->tables['users']} U ON U.id = E.tester_id " . + " WHERE E.tcversion_id = {$tcvID} " . + " ORDER BY E.id DESC LIMIT 10 "; + $item['executions'] = (array)$this->db->get_recordset($sql); + + // evidence attached to those executions + if (count($item['executions']) > 0) { + $execIDSet = implode(',', array_map(function ($e) { + return intval($e['id']); + }, $item['executions'])); + $sql = " SELECT id, fk_id, file_name, file_size FROM {$this->tables['attachments']} " . + " WHERE fk_table = 'executions' AND fk_id IN ({$execIDSet}) "; + $attachMap = array(); + foreach ((array)$this->db->get_recordset($sql) as $att) { + $attachMap[$att['fk_id']][] = $att; + } + foreach ($item['executions'] as &$exec) { + $exec['attachments'] = isset($attachMap[$exec['id']]) ? + $attachMap[$exec['id']] : array(); + } + unset($exec); + } + + $response->getBody()->write(json_encode( + array('status' => 'ok', 'item' => $item))); + return $response; + } + + /** + * GET /testplans/{id}/summary + * Latest-execution status totals for a plan (dashboard numbers). + */ + public function getPlanSummary(Request $request, Response $response, $args) + { + $safeID = intval($args['id']); + + $sql = " SELECT COUNT(*) AS qty FROM {$this->tables['testplan_tcversions']} " . + " WHERE testplan_id = {$safeID} "; + $linked = intval($this->db->fetchFirstRowSingleColumn($sql, 'qty')); + + // latest-per-version via loose index scan on + // (testplan_id, tcversion_id, id) + PK join: cost follows the + // number of test case versions, not the number of executions. + $sql = " SELECT E2.status, COUNT(*) AS qty FROM " . + " (SELECT MAX(id) AS mid FROM {$this->tables['executions']} " . + " WHERE testplan_id = {$safeID} GROUP BY tcversion_id) M " . + " JOIN {$this->tables['executions']} E2 ON E2.id = M.mid " . + " GROUP BY E2.status "; + $byStatus = array(); + $executed = 0; + foreach ((array)$this->db->get_recordset($sql) as $row) { + $byStatus[$row['status']] = intval($row['qty']); + $executed += intval($row['qty']); + } + $byStatus['n'] = max(0, $linked - $executed); + + $info = $this->tplanMgr->get_by_id($safeID); + $response->getBody()->write(json_encode( + array('status' => 'ok', + 'planID' => $safeID, + 'name' => $info['name'] ?? '', + 'linked' => $linked, + 'byStatus' => $byStatus))); + return $response; + } + + /** + * GET /testplans/{id}/trend + * Daily execution counters by status. + */ + public function getPlanTrend(Request $request, Response $response, $args) + { + $safeID = intval($args['id']); + $sql = " SELECT DATE(execution_ts) AS execday, status, COUNT(*) AS qty " . + " FROM {$this->tables['executions']} " . + " WHERE testplan_id = {$safeID} " . + " GROUP BY execday, status ORDER BY execday "; + $days = array(); + foreach ((array)$this->db->get_recordset($sql) as $row) { + $day = $row['execday']; + if (!isset($days[$day])) { + $days[$day] = array('day' => $day, 'p' => 0, 'f' => 0, 'b' => 0, 'other' => 0); + } + $key = in_array($row['status'], array('p','f','b')) ? $row['status'] : 'other'; + $days[$day][$key] += intval($row['qty']); + } + $response->getBody()->write(json_encode( + array('status' => 'ok', 'items' => array_values($days)))); + return $response; + } + + /** + * GET /testplans/{id}/flaky + * Test case versions whose executions flip between pass and fail. + */ + public function getPlanFlaky(Request $request, Response $response, $args) + { + $safeID = intval($args['id']); + // bounded window (default 30 days, ?days= to widen) keeps the + // scan proportional to recent activity instead of full history + $qs = $request->getQueryParams(); + $days = isset($qs['days']) ? max(1, min(365, intval($qs['days']))) : 30; + + // id is monotonically increasing, so ordering by (tcversion_id, id) + // follows idx_exec_tplan_tcv_id and preserves execution order. + // Rows are streamed one at a time — materializing the whole window + // as an array blew past memory_limit on large installations. + $sql = " SELECT E.tcversion_id, E.status, NHTC.name, NHTC.id AS tcase_id " . + " FROM {$this->tables['executions']} E " . + " JOIN {$this->tables['nodes_hierarchy']} NHTCV ON NHTCV.id = E.tcversion_id " . + " JOIN {$this->tables['nodes_hierarchy']} NHTC ON NHTC.id = NHTCV.parent_id " . + " WHERE E.testplan_id = {$safeID} AND E.status IN ('p','f') " . + " AND E.execution_ts >= NOW() - INTERVAL {$days} DAY " . + " ORDER BY E.tcversion_id, E.id "; + $flips = array(); + $prev = array(); + $rs = $this->db->exec_query($sql); + while ($rs && ($row = $this->db->fetch_array($rs))) { + $key = $row['tcversion_id']; + if (!isset($flips[$key])) { + $flips[$key] = array('tcversion_id' => intval($key), + 'tcase_id' => intval($row['tcase_id']), + 'name' => $row['name'], + 'flips' => 0, 'total' => 0); + } + $flips[$key]['total']++; + if (isset($prev[$key]) && $prev[$key] != $row['status']) { + $flips[$key]['flips']++; + } + $prev[$key] = $row['status']; + } + $flips = array_filter($flips, function ($item) { + return $item['flips'] > 0; + }); + usort($flips, function ($a, $b) { + return $b['flips'] <=> $a['flips']; + }); + $response->getBody()->write(json_encode( + array('status' => 'ok', + 'analyzed' => count($prev), + 'items' => array_slice($flips, 0, 50)))); + return $response; + } + + /** + * GET /testplans/{id}/queue?buildID=&page=&limit= + * Linked test cases with their latest result on a build — + * the execution work queue. + */ + public function getPlanQueue(Request $request, Response $response, $args) + { + $safeID = intval($args['id']); + $qs = $request->getQueryParams(); + $buildID = isset($qs['buildID']) ? intval($qs['buildID']) : 0; + $limit = isset($qs['limit']) ? max(1, intval($qs['limit'])) : 200; + $page = isset($qs['page']) ? max(1, intval($qs['page'])) : 1; + $offset = ($page - 1) * $limit; + + $buildFilter = $buildID > 0 ? " AND E.build_id = {$buildID} " : ''; + + // assignment join: execution assignments for this build + $uaJoin = " LEFT JOIN {$this->tables['user_assignments']} UA " . + " ON UA.type = 1 AND UA.feature_id = T.id " . + " AND UA.build_id = " . ($buildID > 0 ? $buildID : 0) . + " LEFT JOIN {$this->tables['users']} U ON U.id = UA.user_id "; + + // optional filter: assignedTo= | 'none' + $assignFilter = ''; + if (isset($qs['assignedTo']) && $qs['assignedTo'] !== '') { + if ($qs['assignedTo'] === 'none') { + $assignFilter = ' AND UA.id IS NULL '; + } else { + $assignFilter = ' AND UA.user_id = ' . intval($qs['assignedTo']); + } + } + + $sql = " SELECT COUNT(*) AS qty " . + " FROM {$this->tables['testplan_tcversions']} T {$uaJoin} " . + " WHERE T.testplan_id = {$safeID} {$assignFilter} "; + $total = intval($this->db->fetchFirstRowSingleColumn($sql, 'qty')); + + $sql = " SELECT T.tcversion_id, NHTCV.parent_id AS tcase_id, NHTC.name, " . + " TCV.tc_external_id, TCV.importance, " . + " UA.user_id AS assigned_to, U.login AS assigned_login, " . + " (SELECT E.status FROM {$this->tables['executions']} E " . + " WHERE E.tcversion_id = T.tcversion_id " . + " AND E.testplan_id = T.testplan_id {$buildFilter} " . + " ORDER BY E.id DESC LIMIT 1) AS exec_status " . + " FROM {$this->tables['testplan_tcversions']} T " . + " JOIN {$this->tables['nodes_hierarchy']} NHTCV ON NHTCV.id = T.tcversion_id " . + " JOIN {$this->tables['nodes_hierarchy']} NHTC ON NHTC.id = NHTCV.parent_id " . + " JOIN {$this->tables['tcversions']} TCV ON TCV.id = T.tcversion_id " . + $uaJoin . + " WHERE T.testplan_id = {$safeID} {$assignFilter} " . + " ORDER BY NHTC.name LIMIT {$limit} OFFSET {$offset} "; + $items = (array)$this->db->get_recordset($sql); + + $response->getBody()->write(json_encode( + array('status' => 'ok', 'total' => $total, + 'page' => $page, 'limit' => $limit, 'items' => $items))); + return $response; + } + + /** + * GET /testplans/{id}/matrix?page=&limit= + * Test result matrix — one row per linked test case, one column + * per build, cell = latest execution status on that build. + * Paginated by test case so the payload stays bounded no matter + * how large the plan is (the legacy page shipped 11MB at once). + */ + public function getPlanMatrix(Request $request, Response $response, $args) + { + $safeID = intval($args['id']); + $qs = $request->getQueryParams(); + $limit = isset($qs['limit']) ? max(1, min(500, intval($qs['limit']))) : 100; + $page = isset($qs['page']) ? max(1, intval($qs['page'])) : 1; + $offset = ($page - 1) * $limit; + + // optional drill-down: only cases directly inside one suite + $suiteID = isset($qs['suiteID']) ? intval($qs['suiteID']) : 0; + $suiteFilter = $suiteID > 0 ? " AND NHTC.parent_id = {$suiteID} " : ''; + + $sql = " SELECT COUNT(*) AS qty " . + " FROM {$this->tables['testplan_tcversions']} T " . + " JOIN {$this->tables['nodes_hierarchy']} NHTCV ON NHTCV.id = T.tcversion_id " . + " JOIN {$this->tables['nodes_hierarchy']} NHTC ON NHTC.id = NHTCV.parent_id " . + " WHERE T.testplan_id = {$safeID} {$suiteFilter} "; + $total = intval($this->db->fetchFirstRowSingleColumn($sql, 'qty')); + + $sql = " SELECT id, name FROM {$this->tables['builds']} " . + " WHERE testplan_id = {$safeID} ORDER BY id "; + $builds = (array)$this->db->get_recordset($sql); + + // page of linked test cases + $sql = " SELECT T.tcversion_id, NHTCV.parent_id AS tcase_id, " . + " NHTC.name, TCV.tc_external_id " . + " FROM {$this->tables['testplan_tcversions']} T " . + " JOIN {$this->tables['nodes_hierarchy']} NHTCV ON NHTCV.id = T.tcversion_id " . + " JOIN {$this->tables['nodes_hierarchy']} NHTC ON NHTC.id = NHTCV.parent_id " . + " JOIN {$this->tables['tcversions']} TCV ON TCV.id = T.tcversion_id " . + " WHERE T.testplan_id = {$safeID} {$suiteFilter} " . + " ORDER BY NHTC.name LIMIT {$limit} OFFSET {$offset} "; + $rows = (array)$this->db->get_recordset($sql); + + if (count($rows) > 0) { + // latest execution per (version, build) only for this page — + // IN-list + loose scan rides idx_exec_tplan_tcv_id + $idSet = implode(',', array_map(function ($r) { + return intval($r['tcversion_id']); + }, $rows)); + + $sql = " SELECT E2.tcversion_id, E2.build_id, E2.status FROM " . + " (SELECT MAX(id) AS mid FROM {$this->tables['executions']} " . + " WHERE testplan_id = {$safeID} AND tcversion_id IN ({$idSet}) " . + " GROUP BY tcversion_id, build_id) M " . + " JOIN {$this->tables['executions']} E2 ON E2.id = M.mid "; + $cells = array(); + foreach ((array)$this->db->get_recordset($sql) as $cell) { + $cells[$cell['tcversion_id']][$cell['build_id']] = $cell['status']; + } + foreach ($rows as &$row) { + $row['results'] = isset($cells[$row['tcversion_id']]) ? + $cells[$row['tcversion_id']] : new stdClass(); + } + unset($row); + } + + $response->getBody()->write(json_encode( + array('status' => 'ok', 'total' => $total, 'page' => $page, + 'limit' => $limit, 'builds' => $builds, 'items' => $rows))); + return $response; + } + + /** + * PUT /testcases/{id}/update + * Update the latest version of a test case: name, summary, + * preconditions, importance, and (optionally) the full step list. + * When 'steps' is present it REPLACES the existing steps — the + * client always sends the complete list it wants to keep. + */ + public function updateTestCase(Request $request, Response $response, $args) + { + $safeID = intval($args['id']); + $op = array('status' => 'ok', 'message' => 'ok'); + + try { + $item = json_decode($request->getBody()); + if (null == $item) { + throw new Exception('Malformed request body'); + } + + // latest version node of this test case + $sql = " SELECT TCV.id FROM {$this->tables['nodes_hierarchy']} NHTCV " . + " JOIN {$this->tables['tcversions']} TCV ON TCV.id = NHTCV.id " . + " WHERE NHTCV.parent_id = {$safeID} ORDER BY TCV.version DESC LIMIT 1 "; + $tcvID = intval($this->db->fetchFirstRowSingleColumn($sql, 'id')); + if ($tcvID <= 0) { + throw new Exception('Test case does not exist'); + } + + if (isset($item->name) && trim($item->name) != '') { + $sql = " UPDATE {$this->tables['nodes_hierarchy']} SET name = '" . + $this->db->prepare_string(trim($item->name)) . "'" . + " WHERE id = {$safeID} "; + $this->db->exec_query($sql); + } + + $fields = array(); + foreach (array('summary', 'preconditions') as $key) { + if (isset($item->$key)) { + $fields[] = " {$key} = '" . + $this->db->prepare_string($item->$key) . "'"; + } + } + if (isset($item->importance)) { + $fields[] = ' importance = ' . intval($item->importance); + } + if (count($fields) > 0) { + $sql = " UPDATE {$this->tables['tcversions']} SET " . + implode(',', $fields) . " WHERE id = {$tcvID} "; + $this->db->exec_query($sql); + } + + if (isset($item->steps) && is_array($item->steps)) { + // replace: drop existing step nodes, insert the new list + $sql = " SELECT id FROM {$this->tables['nodes_hierarchy']} " . + " WHERE parent_id = {$tcvID} AND node_type_id = 9 "; + foreach ((array)$this->db->get_recordset($sql) as $row) { + $this->tcaseMgr->delete_step_by_id(intval($row['id'])); + } + $stepNum = 0; + foreach ($item->steps as $step) { + $stepNum++; + $this->tcaseMgr->create_step( + $tcvID, $stepNum, + isset($step->actions) ? $step->actions : '', + isset($step->expected_results) ? $step->expected_results : '', + isset($step->execution_type) ? intval($step->execution_type) : 1); + } + $op['steps'] = $stepNum; + } + $op['id'] = $safeID; + } catch (Exception $e) { + $op = array('status' => 'error', + 'message' => $this->msgFromException($e)); + $response = $response->withStatus(400); + } + + $response->getBody()->write(json_encode($op)); + return $response; + } + + /** + * POST /testplans/{id}/link {tcaseIDs: [...]} + * Link the LATEST version of each given test case to the plan. + * Already-linked cases are skipped, so the call is idempotent. + */ + public function linkPlanCases(Request $request, Response $response, $args) + { + $safeID = intval($args['id']); + $op = array('status' => 'ok', 'message' => 'ok', 'linked' => 0, 'skipped' => 0); + + try { + $item = json_decode($request->getBody()); + if (null == $item || !isset($item->tcaseIDs) || !is_array($item->tcaseIDs)) { + throw new Exception('Body must carry tcaseIDs array'); + } + + foreach ($item->tcaseIDs as $tcaseID) { + $tcaseID = intval($tcaseID); + $sql = " SELECT TCV.id FROM {$this->tables['nodes_hierarchy']} NHTCV " . + " JOIN {$this->tables['tcversions']} TCV ON TCV.id = NHTCV.id " . + " WHERE NHTCV.parent_id = {$tcaseID} " . + " ORDER BY TCV.version DESC LIMIT 1 "; + $tcvID = intval($this->db->fetchFirstRowSingleColumn($sql, 'id')); + if ($tcvID <= 0) { + $op['skipped']++; + continue; + } + + $sql = " SELECT COUNT(*) AS qty FROM {$this->tables['testplan_tcversions']} " . + " WHERE testplan_id = {$safeID} AND tcversion_id = {$tcvID} "; + if (intval($this->db->fetchFirstRowSingleColumn($sql, 'qty')) > 0) { + $op['skipped']++; + continue; + } + + $sql = " INSERT INTO {$this->tables['testplan_tcversions']} " . + " (testplan_id, tcversion_id, node_order, urgency, platform_id, author_id, creation_ts) " . + " VALUES ({$safeID}, {$tcvID}, 0, 2, 0, " . + intval($this->userID) . ", " . $this->db->db_now() . ")"; + $this->db->exec_query($sql); + $op['linked']++; + } + } catch (Exception $e) { + $op = array('status' => 'error', + 'message' => $this->msgFromException($e)); + $response = $response->withStatus(400); + } + + $response->getBody()->write(json_encode($op)); + return $response; + } + + /** + * GET /testprojects/{id}/search?q=&limit= + * Find test cases by external id (PREFIX-123 or bare number), + * name, or summary text. + */ + public function searchTestCases(Request $request, Response $response, $args) + { + $projectID = intval($args['id']); + $qs = $request->getQueryParams(); + $q = isset($qs['q']) ? trim($qs['q']) : ''; + $limit = isset($qs['limit']) ? max(1, min(200, intval($qs['limit']))) : 50; + + $op = array('status' => 'ok', 'q' => $q, 'items' => array()); + if (strlen($q) < 2) { + $op['message'] = 'query too short'; + $response->getBody()->write(json_encode($op)); + return $response; + } + + $safeLike = $this->db->prepare_string($q); + + // PREFIX-123 / 123 -> exact external id hit first + $extID = 0; + if (preg_match('/(\d+)$/', $q, $m)) { + $extID = intval($m[1]); + } + $extFilter = $extID > 0 ? " OR TCV.tc_external_id = {$extID} " : ''; + + // scope to the project's suite subtree (suites can nest) + $sql = " WITH RECURSIVE st AS " . + " (SELECT id FROM {$this->tables['nodes_hierarchy']} " . + " WHERE parent_id = {$projectID} AND node_type_id = 2 " . + " UNION ALL " . + " SELECT nh.id FROM {$this->tables['nodes_hierarchy']} nh " . + " JOIN st ON nh.parent_id = st.id WHERE nh.node_type_id = 2) " . + " SELECT DISTINCT NHTC.id AS tcase_id, NHTC.name, S.name AS suite_name, " . + " MAX(TCV.tc_external_id) AS tc_external_id, " . + " (MAX(TCV.tc_external_id) = " . ($extID > 0 ? $extID : -1) . ") AS exact_hit " . + " FROM {$this->tables['nodes_hierarchy']} NHTC " . + " JOIN st ON NHTC.parent_id = st.id " . + " JOIN {$this->tables['nodes_hierarchy']} NHTCV ON NHTCV.parent_id = NHTC.id " . + " JOIN {$this->tables['tcversions']} TCV ON TCV.id = NHTCV.id " . + " JOIN {$this->tables['nodes_hierarchy']} S ON S.id = NHTC.parent_id " . + " WHERE NHTC.node_type_id = 3 " . + " AND (NHTC.name LIKE '%{$safeLike}%' " . + " OR TCV.summary LIKE '%{$safeLike}%' {$extFilter}) " . + " GROUP BY NHTC.id, NHTC.name, S.name " . + " ORDER BY exact_hit DESC, NHTC.name LIMIT {$limit} "; + $op['items'] = (array)$this->db->get_recordset($sql); + + $response->getBody()->write(json_encode($op)); + return $response; + } + + /** + * POST /executions/{id}/attachments (multipart, field 'file') + * Attach evidence (screenshot, log, ...) to an execution. + */ + public function uploadExecutionAttachment(Request $request, Response $response, $args) + { + $execID = intval($args['id']); + $op = array('status' => 'error', 'message' => 'no file received'); + + if ($execID > 0 && isset($_FILES['file'])) { + $repo = tlAttachmentRepository::create($this->db); + $title = isset($_POST['title']) ? $_POST['title'] : $_FILES['file']['name']; + $upOp = $repo->insertAttachment($execID, 'executions', $title, $_FILES['file']); + if ($upOp->statusOK) { + $op = array('status' => 'ok', 'message' => 'ok'); + } else { + $op['message'] = 'upload rejected: ' . $upOp->statusCode; + } + } + + if ($op['status'] != 'ok') { + $response = $response->withStatus(400); + } + $response->getBody()->write(json_encode($op)); + return $response; + } + + /** + * GET /executions/{id}/attachments + */ + public function getExecutionAttachments(Request $request, Response $response, $args) + { + $execID = intval($args['id']); + $repo = tlAttachmentRepository::create($this->db); + $items = (array)$repo->getAttachmentInfosFor($execID, 'executions'); + $response->getBody()->write(json_encode( + array('status' => 'ok', 'items' => array_values($items)))); + return $response; + } + + /** + * GET /attachments/{id} + * Streams the attachment binary with its stored content type. + */ + public function downloadAttachment(Request $request, Response $response, $args) + { + $attachID = intval($args['id']); + $repo = tlAttachmentRepository::create($this->db); + $info = $repo->getAttachmentInfo($attachID); + if (is_null($info)) { + $response->getBody()->write(json_encode( + array('status' => 'error', 'message' => 'attachment not found'))); + return $response->withStatus(404); + } + $content = $repo->getAttachmentContent($attachID, $info); + $response->getBody()->write((string)$content); + return $response + ->withHeader('Content-Type', $info['file_type']) + ->withHeader('Content-Disposition', + 'inline; filename="' . addslashes($info['file_name']) . '"'); + } + + /** + * GET /users + * Active users, for assignment pickers. + */ + public function getUsers(Request $request, Response $response, $args) + { + // role name comes from the roles table (users.role_id -> roles.id); + // active flag is included so the admin UI can list and toggle + // deactivated users. Legacy fields (id, login, first, last) are + // kept verbatim — only new fields are added. + $sql = " SELECT U.id, U.login, U.first, U.last, U.email, " . + " U.role_id, U.active, R.description AS role " . + " FROM {$this->tables['users']} U " . + " LEFT JOIN {$this->tables['roles']} R ON R.id = U.role_id " . + " ORDER BY U.login "; + $items = (array)$this->db->get_recordset($sql); + foreach ($items as &$u) { + $u['role_id'] = intval($u['role_id']); + $u['active'] = intval($u['active']); + } + unset($u); + $response->getBody()->write(json_encode( + array('status' => 'ok', 'items' => $items))); + return $response; + } + + /** + * POST /testplans/{id}/assign {buildID, items: [{tcaseID, userID}]} + * Assign execution of test cases (on one build) to users. + * userID 0 removes the assignment. Existing assignment for the + * same (case, build) is replaced. + */ + public function assignPlanCases(Request $request, Response $response, $args) + { + $safeID = intval($args['id']); + $op = array('status' => 'ok', 'message' => 'ok', + 'assigned' => 0, 'removed' => 0, 'skipped' => 0); + + try { + $item = json_decode($request->getBody()); + if (null == $item || !isset($item->buildID) || + !isset($item->items) || !is_array($item->items)) { + throw new Exception('Body must carry buildID and items array'); + } + $buildID = intval($item->buildID); + + foreach ($item->items as $one) { + $tcaseID = intval($one->tcaseID ?? 0); + $userID = intval($one->userID ?? 0); + + // linked feature id = testplan_tcversions row of the case's + // latest linked version + $sql = " SELECT T.id FROM {$this->tables['testplan_tcversions']} T " . + " JOIN {$this->tables['nodes_hierarchy']} NHTCV ON NHTCV.id = T.tcversion_id " . + " WHERE T.testplan_id = {$safeID} AND NHTCV.parent_id = {$tcaseID} " . + " ORDER BY T.tcversion_id DESC LIMIT 1 "; + $featureID = intval($this->db->fetchFirstRowSingleColumn($sql, 'id')); + if ($featureID <= 0) { + $op['skipped']++; + continue; + } + + $sql = " DELETE FROM {$this->tables['user_assignments']} " . + " WHERE type = 1 AND feature_id = {$featureID} " . + " AND build_id = {$buildID} "; + $this->db->exec_query($sql); + + if ($userID > 0) { + $sql = " INSERT INTO {$this->tables['user_assignments']} " . + " (type, feature_id, user_id, build_id, assigner_id, creation_ts, status) " . + " VALUES (1, {$featureID}, {$userID}, {$buildID}, " . + intval($this->userID) . ", " . $this->db->db_now() . ", 1)"; + $this->db->exec_query($sql); + $op['assigned']++; + } else { + $op['removed']++; + } + } + } catch (Exception $e) { + $op = array('status' => 'error', + 'message' => $this->msgFromException($e)); + $response = $response->withStatus(400); + } + + $response->getBody()->write(json_encode($op)); + return $response; + } + + /** + * GET /testplans/{id}/byTester + * Execution counts by tester (optionally ?buildID=), latest + * execution per (version, build) only — re-runs don't double count. + */ + public function getPlanByTester(Request $request, Response $response, $args) + { + $safeID = intval($args['id']); + $qs = $request->getQueryParams(); + $buildID = isset($qs['buildID']) ? intval($qs['buildID']) : 0; + $buildFilter = $buildID > 0 ? " AND build_id = {$buildID} " : ''; + + $sql = " SELECT U.login, E2.status, COUNT(*) AS qty FROM " . + " (SELECT MAX(id) AS mid FROM {$this->tables['executions']} " . + " WHERE testplan_id = {$safeID} {$buildFilter} " . + " GROUP BY tcversion_id, build_id) M " . + " JOIN {$this->tables['executions']} E2 ON E2.id = M.mid " . + " JOIN {$this->tables['users']} U ON U.id = E2.tester_id " . + " GROUP BY U.login, E2.status ORDER BY U.login "; + + $byTester = array(); + foreach ((array)$this->db->get_recordset($sql) as $row) { + $login = $row['login']; + if (!isset($byTester[$login])) { + $byTester[$login] = array('login' => $login, + 'p' => 0, 'f' => 0, 'b' => 0, 'other' => 0); + } + $key = in_array($row['status'], array('p','f','b')) ? $row['status'] : 'other'; + $byTester[$login][$key] += intval($row['qty']); + } + + $response->getBody()->write(json_encode( + array('status' => 'ok', 'items' => array_values($byTester)))); + return $response; + } + + /** + * GET /testplans/{id}/byBuild + * Latest-status totals per build — build quality comparison. + */ + public function getPlanByBuild(Request $request, Response $response, $args) + { + $safeID = intval($args['id']); + + $sql = " SELECT COUNT(*) AS qty FROM {$this->tables['testplan_tcversions']} " . + " WHERE testplan_id = {$safeID} "; + $linked = intval($this->db->fetchFirstRowSingleColumn($sql, 'qty')); + + $sql = " SELECT B.id AS build_id, B.name, E2.status, COUNT(*) AS qty FROM " . + " (SELECT MAX(id) AS mid FROM {$this->tables['executions']} " . + " WHERE testplan_id = {$safeID} GROUP BY tcversion_id, build_id) M " . + " JOIN {$this->tables['executions']} E2 ON E2.id = M.mid " . + " JOIN {$this->tables['builds']} B ON B.id = E2.build_id " . + " GROUP BY B.id, B.name, E2.status ORDER BY B.id "; + + $byBuild = array(); + foreach ((array)$this->db->get_recordset($sql) as $row) { + $bid = $row['build_id']; + if (!isset($byBuild[$bid])) { + $byBuild[$bid] = array('build_id' => intval($bid), + 'name' => $row['name'], 'linked' => $linked, + 'p' => 0, 'f' => 0, 'b' => 0, 'other' => 0); + } + $key = in_array($row['status'], array('p','f','b')) ? $row['status'] : 'other'; + $byBuild[$bid][$key] += intval($row['qty']); + } + + $response->getBody()->write(json_encode( + array('status' => 'ok', 'items' => array_values($byBuild)))); + return $response; + } + + /** + * true when $s is a valid calendar date in strict YYYY-MM-DD form. + */ + private function isIsoDate($s) + { + if (!preg_match('/^(\d{4})-(\d{2})-(\d{2})$/', (string)$s, $m)) { + return false; + } + return checkdate(intval($m[2]), intval($m[3]), intval($m[1])); + } + + /** + * GET /testplans/{id}/milestones + * Every milestone of the plan plus, on each one, the plan-wide + * progress as of now: how many linked cases have a latest result + * (executed %) and how many passed (pass %). Columns a/b/c are the + * per-priority (high/medium/low) % targets carried by the milestone. + * Progress is plan-wide (no per-priority breakdown) and reuses the + * same latest-per-version loose scan as getPlanSummary. + */ + public function getPlanMilestones(Request $request, Response $response, $args) + { + $safeID = intval($args['id']); + + $sql = " SELECT COUNT(*) AS qty FROM {$this->tables['testplan_tcversions']} " . + " WHERE testplan_id = {$safeID} "; + $linked = intval($this->db->fetchFirstRowSingleColumn($sql, 'qty')); + + // latest execution per test case version -> executed & passed totals + $sql = " SELECT E2.status, COUNT(*) AS qty FROM " . + " (SELECT MAX(id) AS mid FROM {$this->tables['executions']} " . + " WHERE testplan_id = {$safeID} GROUP BY tcversion_id) M " . + " JOIN {$this->tables['executions']} E2 ON E2.id = M.mid " . + " GROUP BY E2.status "; + $executed = 0; + $passed = 0; + foreach ((array)$this->db->get_recordset($sql) as $row) { + $executed += intval($row['qty']); + if ($row['status'] == 'p') { + $passed += intval($row['qty']); + } + } + $executedPct = $linked > 0 ? round($executed * 100 / $linked, 1) : 0; + $passPct = $linked > 0 ? round($passed * 100 / $linked, 1) : 0; + + $sql = " SELECT id, name, target_date, start_date, a, b, c " . + " FROM {$this->tables['milestones']} " . + " WHERE testplan_id = {$safeID} ORDER BY target_date, name "; + $items = array(); + foreach ((array)$this->db->get_recordset($sql) as $row) { + $items[] = array( + 'id' => intval($row['id']), + 'name' => $row['name'], + 'target_date' => $row['target_date'], + 'start_date' => $row['start_date'], + 'a' => intval($row['a']), + 'b' => intval($row['b']), + 'c' => intval($row['c']), + 'linked' => $linked, + 'executed' => $executed, + 'passed' => $passed, + 'executedPct' => $executedPct, + 'passPct' => $passPct); + } + + $response->getBody()->write(json_encode( + array('status' => 'ok', 'planID' => $safeID, + 'linked' => $linked, 'executed' => $executed, 'passed' => $passed, + 'executedPct' => $executedPct, 'passPct' => $passPct, + 'items' => $items))); + return $response; + } + + /** + * POST /milestones + * {testplanID, name, target_date (YYYY-MM-DD), + * start_date?, A?, B?, C?} + * A/B/C are the high/medium/low priority % targets (default 100), + * stored in the a/b/c columns. Missing name or target_date -> 400; + * an unknown test plan -> 404. + */ + public function createMilestone(Request $request, Response $response, $args) + { + $op = array('status' => 'ok', 'message' => 'ok'); + $item = json_decode($request->getBody()); + + // required fields (400) + $name = ($item != null && isset($item->name)) ? trim((string)$item->name) : ''; + $target = ($item != null && isset($item->target_date)) + ? trim((string)$item->target_date) : ''; + if ($name === '' || !$this->isIsoDate($target)) { + $op = array('status' => 'error', + 'message' => 'name and target_date (YYYY-MM-DD) are required'); + $response->getBody()->write(json_encode($op)); + return $response->withStatus(400); + } + + // test plan must exist (404) + $tplanID = isset($item->testplanID) ? intval($item->testplanID) : 0; + $tplan = $tplanID > 0 ? $this->tplanMgr->get_by_id($tplanID) : null; + if (null == $tplan) { + $op = array('status' => 'error', 'message' => 'Test plan does not exist'); + $response->getBody()->write(json_encode($op)); + return $response->withStatus(404); + } + + try { + $start = (isset($item->start_date) && + $this->isIsoDate(trim((string)$item->start_date))) + ? trim((string)$item->start_date) : null; + // percentage targets, clamped to 0..100, default 100 + $a = isset($item->A) ? max(0, min(100, intval($item->A))) : 100; + $b = isset($item->B) ? max(0, min(100, intval($item->B))) : 100; + $c = isset($item->C) ? max(0, min(100, intval($item->C))) : 100; + + $cols = "testplan_id, name, target_date, a, b, c"; + $vals = intval($tplanID) . "," . + " '" . $this->db->prepare_string($name) . "'," . + " '" . $this->db->prepare_string($target) . "'," . + " {$a}, {$b}, {$c}"; + if ($start !== null) { + $cols .= ", start_date"; + $vals .= ", '" . $this->db->prepare_string($start) . "'"; + } + $sql = " INSERT INTO {$this->tables['milestones']} ({$cols}) " . + " VALUES ({$vals}) "; + $this->db->exec_query($sql); + $op['id'] = intval($this->db->insert_id($this->tables['milestones'])); + } catch (Exception $e) { + $op = array('status' => 'error', + 'message' => $this->msgFromException($e)); + $response = $response->withStatus(400); + } + + $response->getBody()->write(json_encode($op)); + return $response; + } + + /** + * DELETE /milestones/{id} + * Remove a milestone unconditionally. + */ + public function deleteMilestone(Request $request, Response $response, $args) + { + $safeID = intval($args['id']); + $op = array('status' => 'ok', 'message' => 'ok'); + try { + $sql = " DELETE FROM {$this->tables['milestones']} WHERE id = {$safeID} "; + $this->db->exec_query($sql); + $op['id'] = $safeID; + } catch (Exception $e) { + $op = array('status' => 'error', + 'message' => $this->msgFromException($e)); + $response = $response->withStatus(400); + } + + $response->getBody()->write(json_encode($op)); + return $response; + } + + /** + * GET /testplans/{id}/byKeyword + * Per keyword (linked to any plan case through testcase_keywords): + * how many plan-linked cases carry it, and the latest-execution + * verdict counts p/f/b/n across those cases. n = linked cases with + * no latest result. The verdict side reuses the latest-per-version + * loose scan; keyword association is via the case node (parent of + * the version), so it holds regardless of which version the plan + * links. Keywords with no linked plan case are omitted. + */ + public function getPlanByKeyword(Request $request, Response $response, $args) + { + $safeID = intval($args['id']); + + // linked plan cases carrying each keyword + $sql = " SELECT K.id AS keyword_id, K.keyword, " . + " COUNT(DISTINCT T.tcversion_id) AS linked " . + " FROM {$this->tables['testplan_tcversions']} T " . + " JOIN {$this->tables['nodes_hierarchy']} NHTCV ON NHTCV.id = T.tcversion_id " . + " JOIN {$this->tables['testcase_keywords']} TK ON TK.testcase_id = NHTCV.parent_id " . + " JOIN {$this->tables['keywords']} K ON K.id = TK.keyword_id " . + " WHERE T.testplan_id = {$safeID} " . + " GROUP BY K.id, K.keyword ORDER BY K.keyword "; + $items = array(); + foreach ((array)$this->db->get_recordset($sql) as $row) { + $kid = intval($row['keyword_id']); + $items[$kid] = array('keyword_id' => $kid, + 'keyword' => $row['keyword'], + 'linked' => intval($row['linked']), + 'p' => 0, 'f' => 0, 'b' => 0, 'n' => 0, + 'executed' => 0); + } + + // latest execution per version, rolled up to keyword verdict counts + $sql = " SELECT K.id AS keyword_id, E2.status, " . + " COUNT(DISTINCT E2.tcversion_id) AS qty FROM " . + " (SELECT MAX(id) AS mid FROM {$this->tables['executions']} " . + " WHERE testplan_id = {$safeID} GROUP BY tcversion_id) M " . + " JOIN {$this->tables['executions']} E2 ON E2.id = M.mid " . + " JOIN {$this->tables['nodes_hierarchy']} NHTCV ON NHTCV.id = E2.tcversion_id " . + " JOIN {$this->tables['testcase_keywords']} TK ON TK.testcase_id = NHTCV.parent_id " . + " JOIN {$this->tables['keywords']} K ON K.id = TK.keyword_id " . + " GROUP BY K.id, E2.status "; + foreach ((array)$this->db->get_recordset($sql) as $row) { + $kid = intval($row['keyword_id']); + if (!isset($items[$kid])) { + continue; + } + $qty = intval($row['qty']); + $items[$kid]['executed'] += $qty; + if (in_array($row['status'], array('p', 'f', 'b'))) { + $items[$kid][$row['status']] += $qty; + } + } + + foreach ($items as &$kw) { + $kw['n'] = max(0, $kw['linked'] - $kw['executed']); + unset($kw['executed']); + } + unset($kw); + + $response->getBody()->write(json_encode( + array('status' => 'ok', 'planID' => $safeID, + 'items' => array_values($items)))); + return $response; + } + + /** + * GET /testplans/{id}/matrixBySuite + * Whole-plan overview on one screen: one row per test suite, + * one column per build, cell = latest-status counts (p/f/b) plus + * not-run. This is the at-a-glance companion of the paginated + * case-level matrix. + */ + public function getPlanMatrixBySuite(Request $request, Response $response, $args) + { + $safeID = intval($args['id']); + + $sql = " SELECT id, name FROM {$this->tables['builds']} " . + " WHERE testplan_id = {$safeID} ORDER BY id "; + $builds = (array)$this->db->get_recordset($sql); + + // linked case count per direct parent suite + $sql = " SELECT S.id AS suite_id, S.name AS suite_name, COUNT(*) AS linked " . + " FROM {$this->tables['testplan_tcversions']} T " . + " JOIN {$this->tables['nodes_hierarchy']} NHTCV ON NHTCV.id = T.tcversion_id " . + " JOIN {$this->tables['nodes_hierarchy']} NHTC ON NHTC.id = NHTCV.parent_id " . + " JOIN {$this->tables['nodes_hierarchy']} S ON S.id = NHTC.parent_id " . + " WHERE T.testplan_id = {$safeID} " . + " GROUP BY S.id, S.name ORDER BY S.name "; + $suites = array(); + foreach ((array)$this->db->get_recordset($sql) as $row) { + $suites[$row['suite_id']] = + array('suite_id' => intval($row['suite_id']), + 'name' => $row['suite_name'], + 'linked' => intval($row['linked']), + 'cells' => array()); + } + + // latest status per (version, build) rolled up to suite counters + $sql = " SELECT S.id AS suite_id, E2.build_id, E2.status, COUNT(*) AS qty " . + " FROM (SELECT MAX(id) AS mid FROM {$this->tables['executions']} " . + " WHERE testplan_id = {$safeID} " . + " GROUP BY tcversion_id, build_id) M " . + " JOIN {$this->tables['executions']} E2 ON E2.id = M.mid " . + " JOIN {$this->tables['nodes_hierarchy']} NHTCV ON NHTCV.id = E2.tcversion_id " . + " JOIN {$this->tables['nodes_hierarchy']} NHTC ON NHTC.id = NHTCV.parent_id " . + " JOIN {$this->tables['nodes_hierarchy']} S ON S.id = NHTC.parent_id " . + " GROUP BY S.id, E2.build_id, E2.status "; + foreach ((array)$this->db->get_recordset($sql) as $row) { + $sid = $row['suite_id']; + if (!isset($suites[$sid])) { + continue; + } + $bid = $row['build_id']; + if (!isset($suites[$sid]['cells'][$bid])) { + $suites[$sid]['cells'][$bid] = array('p' => 0, 'f' => 0, 'b' => 0); + } + $statusKey = in_array($row['status'], array('p','f','b')) ? $row['status'] : 'b'; + $suites[$sid]['cells'][$bid][$statusKey] += intval($row['qty']); + } + + $response->getBody()->write(json_encode( + array('status' => 'ok', 'builds' => $builds, + 'items' => array_values($suites)))); + return $response; + } + + /** + * GET /testplans/{id}/buildsById + * Builds of a plan, addressed by plan DBID (the legacy route wants + * the plan API key which the SPA does not have). + */ + public function getPlanBuildsById(Request $request, Response $response, $args) + { + $safeID = intval($args['id']); + $sql = " SELECT id, name, notes, active, is_open, creation_ts " . + " FROM {$this->tables['builds']} " . + " WHERE testplan_id = {$safeID} ORDER BY id DESC "; + $items = (array)$this->db->get_recordset($sql); + $response->getBody()->write(json_encode( + array('status' => 'ok', 'items' => $items))); + return $response; + } + + /** + * GET /testprojects/{id}/reqspecs + * Requirement specification tree of a project (specs can nest), + * with the number of requirements directly inside each spec. + */ + public function getProjectReqSpecs(Request $request, Response $response, $args) + { + $safeID = intval($args['id']); + $sql = " WITH RECURSIVE rst AS " . + " (SELECT id, parent_id, name, node_order " . + " FROM {$this->tables['nodes_hierarchy']} " . + " WHERE parent_id = {$safeID} AND node_type_id = 6 " . + " UNION ALL " . + " SELECT nh.id, nh.parent_id, nh.name, nh.node_order " . + " FROM {$this->tables['nodes_hierarchy']} nh " . + " JOIN rst ON nh.parent_id = rst.id " . + " WHERE nh.node_type_id = 6) " . + " SELECT rst.id, rst.parent_id, rst.name, rst.node_order, RS.doc_id " . + " FROM rst JOIN {$this->tables['req_specs']} RS ON RS.id = rst.id " . + " ORDER BY rst.parent_id, rst.node_order, rst.id "; + $specs = (array)$this->db->get_recordset($sql); + + $sql = " SELECT NHR.parent_id, COUNT(*) AS qty " . + " FROM {$this->tables['nodes_hierarchy']} NHR " . + " WHERE NHR.node_type_id = 7 GROUP BY NHR.parent_id "; + $counts = array(); + foreach ((array)$this->db->get_recordset($sql) as $row) { + $counts[$row['parent_id']] = intval($row['qty']); + } + + foreach ($specs as &$spec) { + $spec['id'] = intval($spec['id']); + $spec['parent_id'] = intval($spec['parent_id']); + $spec['reqCount'] = isset($counts[$spec['id']]) ? $counts[$spec['id']] : 0; + } + unset($spec); + + $response->getBody()->write(json_encode( + array('status' => 'ok', 'projectID' => $safeID, 'items' => $specs))); + return $response; + } + + /** + * POST /reqspecs {testProjectID, parentID?, docID, title, scope?} + * Create a requirement specification (type 6 node + req_specs row + * + first req_specs_revisions row, all via requirement_spec_mgr). + * Without parentID the spec lands at project root. + */ + public function createReqSpec(Request $request, Response $response, $args) + { + $op = array('status' => 'ok', 'message' => 'ok'); + try { + $item = json_decode($request->getBody()); + if (null == $item || !isset($item->testProjectID) || + !isset($item->docID) || !isset($item->title)) { + throw new Exception('Body must carry testProjectID, docID, title'); + } + $tprojectID = intval($item->testProjectID); + $parentID = isset($item->parentID) && intval($item->parentID) > 0 ? + intval($item->parentID) : $tprojectID; + $scope = isset($item->scope) ? strval($item->scope) : ''; + + // type is passed explicitly: the signature default + // (TL_REQ_SPEC_TYPE_FEATURE) is an undefined constant — every + // legacy caller sends the form value. '2' = feature. + $ret = $this->reqSpecMgr->create( + $tprojectID, $parentID, trim($item->docID), trim($item->title), + $scope, 0, $this->userID, '2'); + if (intval($ret['status_ok']) == 0) { + throw new Exception($ret['msg']); + } + $op['id'] = intval($ret['id']); + } catch (Exception $e) { + $op = array('status' => 'error', + 'message' => $this->msgFromException($e)); + $response = $response->withStatus(400); + } + + $response->getBody()->write(json_encode($op)); + return $response; + } + + /** + * GET /reqspecs/{id}/requirements + * Requirements directly inside a spec: doc id, title, the status + * of the LATEST version, and how many test cases cover each one. + */ + public function getReqSpecRequirements(Request $request, Response $response, $args) + { + $safeID = intval($args['id']); + $sql = " SELECT NHR.id, NHR.name, NHR.node_order, R.req_doc_id, " . + " RV.version, RV.status, RV.type, RV.expected_coverage " . + " FROM {$this->tables['nodes_hierarchy']} NHR " . + " JOIN {$this->tables['requirements']} R ON R.id = NHR.id " . + " JOIN {$this->tables['nodes_hierarchy']} NHRV ON NHRV.parent_id = NHR.id " . + " JOIN {$this->tables['req_versions']} RV ON RV.id = NHRV.id " . + " WHERE NHR.parent_id = {$safeID} AND NHR.node_type_id = 7 " . + " AND RV.version = " . + " (SELECT MAX(RV2.version) FROM {$this->tables['nodes_hierarchy']} NHRV2 " . + " JOIN {$this->tables['req_versions']} RV2 ON RV2.id = NHRV2.id " . + " WHERE NHRV2.parent_id = NHR.id) " . + " ORDER BY NHR.node_order, NHR.id "; + $items = (array)$this->db->get_recordset($sql); + + $sql = " SELECT RC.req_id, COUNT(DISTINCT RC.testcase_id) AS qty " . + " FROM {$this->tables['req_coverage']} RC " . + " JOIN {$this->tables['nodes_hierarchy']} NHR ON NHR.id = RC.req_id " . + " WHERE NHR.parent_id = {$safeID} GROUP BY RC.req_id "; + $counts = array(); + foreach ((array)$this->db->get_recordset($sql) as $row) { + $counts[$row['req_id']] = intval($row['qty']); + } + + foreach ($items as &$req) { + $req['id'] = intval($req['id']); + $req['coverageCount'] = isset($counts[$req['id']]) ? $counts[$req['id']] : 0; + } + unset($req); + + $response->getBody()->write(json_encode( + array('status' => 'ok', 'reqSpecID' => $safeID, 'items' => $items))); + return $response; + } + + /** + * POST /requirements {reqSpecID, docID, title, scope?} + * Create a requirement and its first version (type 7 node + + * requirements row + type 8 version node + req_versions row, + * all via requirement_mgr). + */ + public function createRequirement(Request $request, Response $response, $args) + { + $op = array('status' => 'ok', 'message' => 'ok'); + try { + $item = json_decode($request->getBody()); + if (null == $item || !isset($item->reqSpecID) || + !isset($item->docID) || !isset($item->title)) { + throw new Exception('Body must carry reqSpecID, docID, title'); + } + $srsID = intval($item->reqSpecID); + $scope = isset($item->scope) ? strval($item->scope) : ''; + + $ret = $this->reqMgr->create( + $srsID, trim($item->docID), trim($item->title), $scope, $this->userID); + if (intval($ret['status_ok']) == 0) { + throw new Exception($ret['msg']); + } + $op['id'] = intval($ret['id']); + $op['versionID'] = intval($ret['version_id']); + } catch (Exception $e) { + $op = array('status' => 'error', + 'message' => $this->msgFromException($e)); + $response = $response->withStatus(400); + } + + $response->getBody()->write(json_encode($op)); + return $response; + } + + /** + * GET /requirements/{id}/detail + * Latest version of one requirement plus the test cases that + * cover it (req_coverage). + */ + public function getRequirementDetail(Request $request, Response $response, $args) + { + $safeID = intval($args['id']); + + $sql = " SELECT NHR.name, NHR.parent_id AS srs_id, R.req_doc_id, " . + " RV.id AS req_version_id, RV.version, RV.scope, RV.status, " . + " RV.type, RV.expected_coverage, RV.creation_ts, RV.modification_ts " . + " FROM {$this->tables['nodes_hierarchy']} NHR " . + " JOIN {$this->tables['requirements']} R ON R.id = NHR.id " . + " JOIN {$this->tables['nodes_hierarchy']} NHRV ON NHRV.parent_id = NHR.id " . + " JOIN {$this->tables['req_versions']} RV ON RV.id = NHRV.id " . + " WHERE NHR.id = {$safeID} " . + " ORDER BY RV.version DESC LIMIT 1 "; + $item = $this->db->get_recordset($sql); + if (is_null($item)) { + $response->getBody()->write(json_encode( + array('status' => 'error', 'message' => 'Requirement does not exist'))); + return $response->withStatus(404); + } + $item = current($item); + $item['id'] = $safeID; + + $sql = " SELECT DISTINCT RC.testcase_id AS tcase_id, NHTC.name, " . + " (SELECT MAX(TCV.tc_external_id) " . + " FROM {$this->tables['nodes_hierarchy']} NHTCV " . + " JOIN {$this->tables['tcversions']} TCV ON TCV.id = NHTCV.id " . + " WHERE NHTCV.parent_id = RC.testcase_id) AS tc_external_id " . + " FROM {$this->tables['req_coverage']} RC " . + " JOIN {$this->tables['nodes_hierarchy']} NHTC ON NHTC.id = RC.testcase_id " . + " WHERE RC.req_id = {$safeID} " . + " ORDER BY NHTC.name "; + $item['coverage'] = (array)$this->db->get_recordset($sql); + + $response->getBody()->write(json_encode( + array('status' => 'ok', 'item' => $item))); + return $response; + } + + /** + * POST /requirements/{id}/coverage {tcaseIDs: [...]} + * Cover the requirement with test cases: link its LATEST version + * to the LATEST version of each given case. Already-covered cases + * are skipped, so the call is idempotent. + */ + public function addRequirementCoverage(Request $request, Response $response, $args) + { + $safeID = intval($args['id']); + $op = array('status' => 'ok', 'message' => 'ok', 'linked' => 0, 'skipped' => 0); + + try { + $item = json_decode($request->getBody()); + if (null == $item || !isset($item->tcaseIDs) || !is_array($item->tcaseIDs)) { + throw new Exception('Body must carry tcaseIDs array'); + } + + // latest version node of this requirement + $sql = " SELECT RV.id FROM {$this->tables['nodes_hierarchy']} NHRV " . + " JOIN {$this->tables['req_versions']} RV ON RV.id = NHRV.id " . + " WHERE NHRV.parent_id = {$safeID} ORDER BY RV.version DESC LIMIT 1 "; + $reqVersionID = intval($this->db->fetchFirstRowSingleColumn($sql, 'id')); + if ($reqVersionID <= 0) { + throw new Exception('Requirement does not exist'); + } + + foreach ($item->tcaseIDs as $tcaseID) { + $tcaseID = intval($tcaseID); + $sql = " SELECT TCV.id FROM {$this->tables['nodes_hierarchy']} NHTCV " . + " JOIN {$this->tables['tcversions']} TCV ON TCV.id = NHTCV.id " . + " WHERE NHTCV.parent_id = {$tcaseID} " . + " ORDER BY TCV.version DESC LIMIT 1 "; + $tcvID = intval($this->db->fetchFirstRowSingleColumn($sql, 'id')); + if ($tcvID <= 0) { + $op['skipped']++; + continue; + } + + $sql = " SELECT COUNT(*) AS qty FROM {$this->tables['req_coverage']} " . + " WHERE req_id = {$safeID} AND testcase_id = {$tcaseID} "; + if (intval($this->db->fetchFirstRowSingleColumn($sql, 'qty')) > 0) { + $op['skipped']++; + continue; + } + + $sql = " INSERT INTO {$this->tables['req_coverage']} " . + " (req_id, testcase_id, req_version_id, tcversion_id, author_id, creation_ts) " . + " VALUES ({$safeID}, {$tcaseID}, {$reqVersionID}, {$tcvID}, " . + intval($this->userID) . ", " . $this->db->db_now() . ")"; + $this->db->exec_query($sql); + $op['linked']++; + } + } catch (Exception $e) { + $op = array('status' => 'error', + 'message' => $this->msgFromException($e)); + $response = $response->withStatus(400); + } + + $response->getBody()->write(json_encode($op)); + return $response; + } + + /** + * DELETE /requirements/{id}/coverage/{tcaseID} + * Unlink a test case from a requirement — every coverage row of + * the pair goes away, whatever req/tc version it pointed at. + */ + public function deleteRequirementCoverage(Request $request, Response $response, $args) + { + $safeID = intval($args['id']); + $tcaseID = intval($args['tcaseID']); + + $sql = " DELETE FROM {$this->tables['req_coverage']} " . + " WHERE req_id = {$safeID} AND testcase_id = {$tcaseID} "; + $this->db->exec_query($sql); + $removed = intval($this->db->affected_rows()); + + $response->getBody()->write(json_encode( + array('status' => 'ok', 'removed' => $removed))); + return $response; + } + + /** + * GET /testplans/{id}/reqCoverage + * Requirements coverage report for a plan: for each requirement + * covering cases linked to the plan, the latest-execution verdict + * counts (p/f/b/n) of those cases. Latest execution per version + * uses the same MAX(id) loose-scan pattern as getPlanSummary, so + * cost stays plan-scoped. + */ + public function getPlanReqCoverage(Request $request, Response $response, $args) + { + $safeID = intval($args['id']); + + $sql = " SELECT RC.req_id, R.req_doc_id, NHR.name, " . + " PL.tcase_id, LE.status " . + " FROM (SELECT T.tcversion_id, NHTCV.parent_id AS tcase_id " . + " FROM {$this->tables['testplan_tcversions']} T " . + " JOIN {$this->tables['nodes_hierarchy']} NHTCV " . + " ON NHTCV.id = T.tcversion_id " . + " WHERE T.testplan_id = {$safeID}) PL " . + " JOIN (SELECT DISTINCT req_id, testcase_id " . + " FROM {$this->tables['req_coverage']}) RC " . + " ON RC.testcase_id = PL.tcase_id " . + " JOIN {$this->tables['requirements']} R ON R.id = RC.req_id " . + " JOIN {$this->tables['nodes_hierarchy']} NHR ON NHR.id = RC.req_id " . + " LEFT JOIN (SELECT E2.tcversion_id, E2.status FROM " . + " (SELECT MAX(id) AS mid FROM {$this->tables['executions']} " . + " WHERE testplan_id = {$safeID} GROUP BY tcversion_id) M " . + " JOIN {$this->tables['executions']} E2 ON E2.id = M.mid) LE " . + " ON LE.tcversion_id = PL.tcversion_id " . + " ORDER BY R.req_doc_id, RC.req_id "; + + $reqs = array(); + $rs = $this->db->exec_query($sql); + while ($rs && ($row = $this->db->fetch_array($rs))) { + $rid = $row['req_id']; + if (!isset($reqs[$rid])) { + $reqs[$rid] = array('req_id' => intval($rid), + 'req_doc_id' => $row['req_doc_id'], + 'name' => $row['name'], + 'inPlan' => 0, 'covered' => 0, + 'p' => 0, 'f' => 0, 'b' => 0, 'n' => 0); + } + $reqs[$rid]['inPlan']++; + $key = in_array($row['status'], array('p','f','b')) ? $row['status'] : 'n'; + $reqs[$rid][$key]++; + } + + if (count($reqs) > 0) { + // total covered cases per requirement (plan-linked or not) + $idSet = implode(',', array_map('intval', array_keys($reqs))); + $sql = " SELECT req_id, COUNT(DISTINCT testcase_id) AS qty " . + " FROM {$this->tables['req_coverage']} " . + " WHERE req_id IN ({$idSet}) GROUP BY req_id "; + foreach ((array)$this->db->get_recordset($sql) as $row) { + $reqs[$row['req_id']]['covered'] = intval($row['qty']); + } + } + + $response->getBody()->write(json_encode( + array('status' => 'ok', 'planID' => $safeID, + 'items' => array_values($reqs)))); + return $response; + } + + // ================================================================== + // Admin module — users, keywords, platforms, custom fields. + // Replaces the common operations of the legacy usermanagement / + // keywords / platforms / cfields frame pages. + // ================================================================== + + /** map a tlUser error code to a human message for the API client */ + private function userErrorMsg($code) + { + switch (intval($code)) { + case tlUser::E_LOGINLENGTH: return 'Login is empty or too long'; + case tlUser::E_EMAILLENGTH: return 'Email is too long'; + case tlUser::E_NOTALLOWED: return 'Login contains characters that are not allowed'; + case tlUser::E_FIRSTNAMELENGTH: return 'First name is empty or too long'; + case tlUser::E_LASTNAMELENGTH: return 'Last name is empty or too long'; + case tlUser::E_PWDEMPTY: return 'Password must not be empty'; + case tlUser::E_LOGINALREADYEXISTS:return 'A user with this login already exists'; + case tlUser::E_EMAILFORMAT: return 'Email address is not valid'; + case tlUser::E_DBERROR: return 'Database error while saving the user'; + default: return 'Could not create user (code ' . intval($code) . ')'; + } + } + + /** + * POST /users {login, password, firstName, lastName, email, roleID?} + * Create a user through the tlUser class (same writeToDB + + * setPassword pattern the legacy usermanagement uses). When roleID + * is omitted the configured default global role is used. The new + * user is active and uses internal (DB) password management, so it + * can immediately authenticate through POST /auth/login. + */ + public function createUser(Request $request, Response $response, $args) + { + $op = array('status' => 'ok', 'message' => 'ok'); + try { + $item = json_decode($request->getBody()); + if (null == $item || !isset($item->login) || !isset($item->password) || + !isset($item->firstName) || !isset($item->lastName) || + !isset($item->email)) { + throw new Exception('Body must carry login, password, firstName, lastName, email'); + } + + $user = new tlUser(); + $user->login = trim($item->login); + $user->firstName = trim($item->firstName); + $user->lastName = trim($item->lastName); + $user->emailAddress = trim($item->email); + $user->locale = 'en_GB'; + $user->isActive = 1; + $user->authentication = ''; // '' => configured default method (DB) + $user->globalRoleID = isset($item->roleID) && intval($item->roleID) > 0 ? + intval($item->roleID) : config_get('default_roleid'); + + $rc = $user->setPassword((string)$item->password); + if ($rc < tl::OK) { + throw new Exception($this->userErrorMsg($rc)); + } + + $rc = $user->writeToDB($this->db); + if ($rc < tl::OK) { + throw new Exception($this->userErrorMsg($rc)); + } + $op['id'] = intval($user->dbID); + $op['login'] = $user->login; + } catch (Exception $e) { + $op = array('status' => 'error', + 'message' => $this->msgFromException($e)); + $response = $response->withStatus(400); + } + + $response->getBody()->write(json_encode($op)); + return $response; + } + + /** + * PUT /users/{id} {active} + * Activate / deactivate a user. Deactivated users can no longer + * authenticate (POST /auth/login checks isActive). + */ + public function updateUser(Request $request, Response $response, $args) + { + $safeID = intval($args['id']); + $op = array('status' => 'ok', 'message' => 'ok'); + try { + $item = json_decode($request->getBody()); + if (null == $item || !isset($item->active)) { + throw new Exception('Body must carry active'); + } + $active = intval($item->active) ? 1 : 0; + $sql = " UPDATE {$this->tables['users']} SET active = {$active} " . + " WHERE id = {$safeID} "; + $this->db->exec_query($sql); + $op['id'] = $safeID; + $op['active'] = $active; + } catch (Exception $e) { + $op = array('status' => 'error', + 'message' => $this->msgFromException($e)); + $response = $response->withStatus(400); + } + + $response->getBody()->write(json_encode($op)); + return $response; + } + + /** + * GET /testprojects/{id}/keywords + * Keywords defined in a project plus how many test cases use each. + */ + public function getProjectKeywords(Request $request, Response $response, $args) + { + $safeID = intval($args['id']); + $sql = " SELECT id, keyword, notes FROM {$this->tables['keywords']} " . + " WHERE testproject_id = {$safeID} ORDER BY keyword "; + $items = (array)$this->db->get_recordset($sql); + + $sql = " SELECT keyword_id, COUNT(*) AS qty " . + " FROM {$this->tables['testcase_keywords']} GROUP BY keyword_id "; + $counts = array(); + foreach ((array)$this->db->get_recordset($sql) as $row) { + $counts[$row['keyword_id']] = intval($row['qty']); + } + + foreach ($items as &$kw) { + $kw['id'] = intval($kw['id']); + $kw['linkedCount'] = isset($counts[$kw['id']]) ? $counts[$kw['id']] : 0; + } + unset($kw); + + $response->getBody()->write(json_encode( + array('status' => 'ok', 'projectID' => $safeID, 'items' => $items))); + return $response; + } + + /** + * DELETE /keywords/{id} + * Remove a keyword. Mirrors the keyword-manager behaviour: the + * tlKeyword deleteFromDB (invoked via deleteKeyword) also removes + * the testcase_keywords and object_keywords link rows. Deletion is + * unconditional here (checkBeforeDelete disabled), matching an + * admin "delete keyword" action. + */ + public function deleteKeyword(Request $request, Response $response, $args) + { + $safeID = intval($args['id']); + $op = array('status' => 'ok', 'message' => 'ok'); + try { + $rc = $this->tprojectMgr->deleteKeyword($safeID, + array('checkBeforeDelete' => false)); + if ($rc < tl::OK) { + throw new Exception('Could not delete keyword'); + } + $op['id'] = $safeID; + } catch (Exception $e) { + $op = array('status' => 'error', + 'message' => $this->msgFromException($e)); + $response = $response->withStatus(400); + } + + $response->getBody()->write(json_encode($op)); + return $response; + } + + /** + * GET /testprojects/{id}/platforms + * Every platform of the project (regardless of enable flags) with + * its design/execution/open flags and how many plans link it. + */ + public function getProjectPlatforms(Request $request, Response $response, $args) + { + $safeID = intval($args['id']); + $platMgr = new tlPlatform($this->db, $safeID); + // null on the enable filters => do not filter, return them all + $rs = $platMgr->getAll(array('include_linked_count' => true, + 'enable_on_design' => null, + 'enable_on_execution' => null, + 'is_open' => null)); + $items = array(); + foreach ((array)$rs as $row) { + $items[] = array( + 'id' => intval($row['id']), + 'name' => $row['name'], + 'notes' => $row['notes'], + 'enable_on_design' => intval($row['enable_on_design']), + 'enable_on_execution' => intval($row['enable_on_execution']), + 'is_open' => intval($row['is_open']), + 'linked_count' => isset($row['linked_count']) ? intval($row['linked_count']) : 0); + } + + $response->getBody()->write(json_encode( + array('status' => 'ok', 'projectID' => $safeID, 'items' => $items))); + return $response; + } + + /** + * POST /platforms {testProjectID, name, notes?} + * Create a platform in a project (enabled on design and execution). + * A duplicate name in the same project is rejected with 400. + */ + public function createPlatform(Request $request, Response $response, $args) + { + $op = array('status' => 'ok', 'message' => 'ok'); + try { + $item = json_decode($request->getBody()); + if (null == $item || !isset($item->testProjectID) || + !isset($item->name) || trim((string)$item->name) === '') { + throw new Exception('Body must carry testProjectID and a non-empty name'); + } + $platMgr = new tlPlatform($this->db, intval($item->testProjectID)); + + $plat = new stdClass(); + $plat->name = trim((string)$item->name); + $plat->notes = isset($item->notes) ? strval($item->notes) : ''; + $plat->enable_on_design = 1; + $plat->enable_on_execution = 1; + + $ret = $platMgr->create($plat); + if ($ret['status'] == tlPlatform::E_NAMEALREADYEXISTS) { + throw new Exception('A platform with this name already exists in the project'); + } + if ($ret['status'] != tl::OK) { + throw new Exception('Could not create platform'); + } + $op['id'] = intval($ret['id']); + } catch (Exception $e) { + $op = array('status' => 'error', + 'message' => $this->msgFromException($e)); + $response = $response->withStatus(400); + } + + $response->getBody()->write(json_encode($op)); + return $response; + } + + /** + * GET /testprojects/{id}/customfields + * Custom fields linked to a project (custom_fields JOIN + * cfield_testprojects). Type ints are mapped to their verbose name + * via cfield_mgr::$custom_field_types, and the node types the field + * applies to (cfield_node_types) are resolved to their descriptions. + * Read-only — custom field creation stays in the legacy UI. + */ + public function getProjectCustomFields(Request $request, Response $response, $args) + { + $safeID = intval($args['id']); + + $sql = " SELECT CF.id, CF.name, CF.label, CF.type, " . + " CF.enable_on_design, CF.enable_on_execution, " . + " CF.enable_on_testplan_design, " . + " CFTP.active, CFTP.display_order " . + " FROM {$this->tables['custom_fields']} CF " . + " JOIN {$this->tables['cfield_testprojects']} CFTP " . + " ON CFTP.field_id = CF.id " . + " WHERE CFTP.testproject_id = {$safeID} " . + " ORDER BY CFTP.display_order, CF.name "; + $rows = (array)$this->db->get_recordset($sql); + + // node types each field applies to + $sql = " SELECT CNT.field_id, NT.description " . + " FROM {$this->tables['cfield_node_types']} CNT " . + " JOIN {$this->tables['node_types']} NT ON NT.id = CNT.node_type_id " . + " ORDER BY CNT.field_id, NT.id "; + $appliesTo = array(); + foreach ((array)$this->db->get_recordset($sql) as $row) { + $appliesTo[$row['field_id']][] = $row['description']; + } + + $cfTypes = $this->cfieldMgr->custom_field_types; + + $items = array(); + foreach ($rows as $row) { + $fid = intval($row['id']); + $typeInt = intval($row['type']); + $items[] = array( + 'id' => $fid, + 'name' => $row['name'], + 'label' => $row['label'], + 'type' => isset($cfTypes[$typeInt]) ? $cfTypes[$typeInt] : ('type ' . $typeInt), + 'appliesTo' => isset($appliesTo[$fid]) ? implode(', ', $appliesTo[$fid]) : '', + 'active' => intval($row['active']), + 'enable_on_design' => intval($row['enable_on_design']), + 'enable_on_execution' => intval($row['enable_on_execution'])); + } + + $response->getBody()->write(json_encode( + array('status' => 'ok', 'projectID' => $safeID, 'items' => $items))); + return $response; + } + + // ================================================================== + // Documents & TestLink-XML import/export + // (SPA replacement for printDocument.php / tcExport.php / tcImport.php) + // ================================================================== + + /** HTML-escape helper for the printable documents */ + private static function docEsc($s) + { + return htmlspecialchars((string)$s, ENT_QUOTES, 'UTF-8'); + } + + /** execution status code -> human label for the printable report */ + private static function docVerdictLabel($code) + { + switch ($code) { + case 'p': return 'Passed'; + case 'f': return 'Failed'; + case 'b': return 'Blocked'; + case null: + case '': return 'Not run'; + default: return (string)$code; + } + } + + /** + * Shared shell of the printable documents: self-contained HTML, + * inline CSS only (no external assets), print-friendly. + */ + private function docHtmlOpen($title, $subtitle) + { + $css = + 'body{font-family:-apple-system,"Segoe UI",Helvetica,Arial,sans-serif;' . + 'margin:32px auto;max-width:900px;padding:0 16px;color:#1a1a1a;line-height:1.45}' . + 'h1{font-size:22px;margin:0 0 2px;border-bottom:2px solid #1a1a1a;padding-bottom:6px}' . + 'h2{font-size:16px;margin:26px 0 6px;border-bottom:1px solid #999;padding-bottom:3px}' . + 'h3{font-size:14px;margin:18px 0 4px}' . + '.sub{color:#555;font-size:12px;margin:0 0 18px}' . + '.meta{color:#555;font-size:12px;margin:2px 0}' . + '.block{margin:4px 0 10px}' . + '.blocklabel{font-size:11px;text-transform:uppercase;letter-spacing:.04em;color:#777;margin:8px 0 2px}' . + 'table{border-collapse:collapse;width:100%;margin:6px 0 14px;font-size:13px}' . + 'th,td{border:1px solid #bbb;padding:4px 8px;text-align:left;vertical-align:top}' . + 'th{background:#f0f0f0;font-size:12px}' . + 'td.num,th.num{text-align:right;font-variant-numeric:tabular-nums}' . + '.stepno{width:36px;text-align:right}' . + '.warn{background:#fff3cd;border:1px solid #d9c069;padding:8px 12px;margin:14px 0;font-size:13px}' . + '.v-p{color:#166534;font-weight:600}.v-f{color:#b91c1c;font-weight:600}' . + '.v-b{color:#92400e;font-weight:600}.v-n{color:#666}' . + '@media print{body{margin:0 auto}}'; + + return '' . + '' . self::docEsc($title) . '' . + '' . + '

' . self::docEsc($title) . '

' . + '

' . self::docEsc($subtitle) . '

'; + } + + /** + * GET /testprojects/{id}/document?type=spec[&suiteID=] + * Self-contained printable HTML of the test specification: + * suite hierarchy with, per test case, external id, title, summary, + * preconditions and steps table. Optional suiteID narrows the scope + * to one suite subtree; the whole-project variant is capped at + * DOC_MAX_CASES cases with an explicit truncation note. + */ + const DOC_MAX_CASES = 2000; + + public function getProjectDocument(Request $request, Response $response, $args) + { + $projectID = intval($args['id']); + $qs = $request->getQueryParams(); + $type = isset($qs['type']) ? trim($qs['type']) : 'spec'; + $suiteID = isset($qs['suiteID']) ? intval($qs['suiteID']) : 0; + + if ($type != 'spec') { + $response->getBody()->write(json_encode( + array('status' => 'error', 'message' => "Unsupported document type '{$type}'"))); + return $response->withStatus(400); + } + + $sql = " SELECT name FROM {$this->tables['nodes_hierarchy']} " . + " WHERE id = {$projectID} AND node_type_id = 1 "; + $projectName = $this->db->fetchFirstRowSingleColumn($sql, 'name'); + if (is_null($projectName)) { + $response->getBody()->write(json_encode( + array('status' => 'error', 'message' => 'Test project does not exist'))); + return $response->withStatus(404); + } + + $scopeName = null; + if ($suiteID > 0) { + $sql = " SELECT name FROM {$this->tables['nodes_hierarchy']} " . + " WHERE id = {$suiteID} AND node_type_id = 2 "; + $scopeName = $this->db->fetchFirstRowSingleColumn($sql, 'name'); + $rootOK = !is_null($scopeName) && + intval($this->tsuiteMgr->tree_manager->getTreeRoot($suiteID)) == $projectID; + if (!$rootOK) { + $response->getBody()->write(json_encode( + array('status' => 'error', + 'message' => 'Test suite does not exist in this project'))); + return $response->withStatus(404); + } + } + + // suite subtree in scope (same recursive shape as getProjectSuites) + $anchorID = $suiteID > 0 ? $suiteID : $projectID; + $sql = " WITH RECURSIVE st AS " . + " (SELECT id, parent_id, name, node_order " . + " FROM {$this->tables['nodes_hierarchy']} " . + " WHERE parent_id = {$anchorID} AND node_type_id = 2 " . + " UNION ALL " . + " SELECT nh.id, nh.parent_id, nh.name, nh.node_order " . + " FROM {$this->tables['nodes_hierarchy']} nh " . + " JOIN st ON nh.parent_id = st.id " . + " WHERE nh.node_type_id = 2) " . + " SELECT * FROM st "; + $suiteRows = (array)$this->db->get_recordset($sql); + if ($suiteID > 0) { + $suiteRows[] = array('id' => $suiteID, 'parent_id' => $anchorID, + 'name' => $scopeName, 'node_order' => 0); + } + + $childSuites = array(); // parent -> ordered child suites + $suiteIDSet = array($anchorID); + usort($suiteRows, function ($a, $b) { + return (intval($a['node_order']) <=> intval($b['node_order'])) + ?: (intval($a['id']) <=> intval($b['id'])); + }); + foreach ($suiteRows as $row) { + $suiteIDSet[] = intval($row['id']); + if (intval($row['id']) != $suiteID) { + $childSuites[intval($row['parent_id'])][] = $row; + } + } + + // latest-version data of every case in scope, bounded + $inList = implode(',', array_unique($suiteIDSet)); + $cap = self::DOC_MAX_CASES; + $sql = " SELECT NHTC.id AS tcase_id, NHTC.parent_id AS suite_id, " . + " NHTC.name, NHTC.node_order, " . + " TCV.id AS tcversion_id, TCV.version, TCV.tc_external_id, " . + " TCV.summary, TCV.preconditions, TCV.importance " . + " FROM {$this->tables['nodes_hierarchy']} NHTC " . + " JOIN {$this->tables['nodes_hierarchy']} NHTCV ON NHTCV.parent_id = NHTC.id " . + " JOIN {$this->tables['tcversions']} TCV ON TCV.id = NHTCV.id " . + " WHERE NHTC.parent_id IN ({$inList}) AND NHTC.node_type_id = 3 " . + " AND TCV.version = (SELECT MAX(TCV2.version) " . + " FROM {$this->tables['nodes_hierarchy']} NH2 " . + " JOIN {$this->tables['tcversions']} TCV2 ON TCV2.id = NH2.id " . + " WHERE NH2.parent_id = NHTC.id) " . + " ORDER BY NHTC.parent_id, NHTC.node_order, NHTC.id " . + " LIMIT " . ($cap + 1); + $caseRows = (array)$this->db->get_recordset($sql); + $truncated = count($caseRows) > $cap; + if ($truncated) { + array_pop($caseRows); + } + + // steps of those versions, one query + $stepsByTCV = array(); + if (count($caseRows) > 0) { + $tcvList = implode(',', array_map(function ($r) { + return intval($r['tcversion_id']); + }, $caseRows)); + $sql = " SELECT NH.parent_id AS tcversion_id, TCS.step_number, " . + " TCS.actions, TCS.expected_results " . + " FROM {$this->tables['tcsteps']} TCS " . + " JOIN {$this->tables['nodes_hierarchy']} NH ON NH.id = TCS.id " . + " WHERE NH.parent_id IN ({$tcvList}) " . + " ORDER BY NH.parent_id, TCS.step_number "; + foreach ((array)$this->db->get_recordset($sql) as $st) { + $stepsByTCV[$st['tcversion_id']][] = $st; + } + } + + $casesBySuite = array(); + foreach ($caseRows as $row) { + $casesBySuite[intval($row['suite_id'])][] = $row; + } + + // project prefix for the external case ids + $sql = " SELECT prefix FROM {$this->tables['testprojects']} " . + " WHERE id = {$projectID} "; + $prefix = (string)$this->db->fetchFirstRowSingleColumn($sql, 'prefix'); + + $renderCase = function ($tc) use ($prefix, $stepsByTCV) { + $html = '

' . self::docEsc($prefix . '-' . $tc['tc_external_id']) . + ' · ' . self::docEsc($tc['name']) . + ' v' . intval($tc['version']) . '

'; + if (trim((string)$tc['summary']) != '') { + $html .= '
Summary
' . + '
' . $tc['summary'] . '
'; + } + if (trim((string)$tc['preconditions']) != '') { + $html .= '
Preconditions
' . + '
' . $tc['preconditions'] . '
'; + } + $steps = isset($stepsByTCV[$tc['tcversion_id']]) ? + $stepsByTCV[$tc['tcversion_id']] : array(); + if (count($steps) > 0) { + $html .= '' . + ''; + foreach ($steps as $st) { + $html .= '' . + '' . + ''; + } + $html .= '
#ActionsExpected results
' . intval($st['step_number']) . '' . $st['actions'] . '' . $st['expected_results'] . '
'; + } + return $html; + }; + + // depth-first walk with hierarchical numbering (1, 1.1, ...) + $renderSuite = function ($sid, $number, $name) use ( + &$renderSuite, &$childSuites, &$casesBySuite, $renderCase) { + $html = ''; + if ($name !== null) { + $html .= '

' . self::docEsc($number . ' ' . $name) . '

'; + } + foreach (isset($casesBySuite[$sid]) ? $casesBySuite[$sid] : array() as $tc) { + $html .= $renderCase($tc); + } + $childNo = 0; + foreach (isset($childSuites[$sid]) ? $childSuites[$sid] : array() as $child) { + $childNo++; + $childNumber = ($name === null ? '' : $number . '.') . $childNo; + $html .= $renderSuite(intval($child['id']), $childNumber, $child['name']); + } + return $html; + }; + + $title = 'Test specification · ' . $projectName . + ($scopeName !== null ? ' / ' . $scopeName : ''); + $subtitle = 'Generated ' . date('Y-m-d H:i') . ' · ' . + count($caseRows) . ' test cases'; + $html = $this->docHtmlOpen($title, $subtitle); + if ($truncated) { + $html .= '
Output truncated at ' . $cap . + ' test cases. Narrow the scope with a suite to get the full detail.
'; + } + // the anchor level itself carries no heading (its name is in the + // title); child suites get hierarchical numbers 1, 1.1, ... + $html .= $renderSuite($anchorID, '', null); + $html .= ''; + + $response->getBody()->write($html); + return $response->withHeader('Content-Type', 'text/html; charset=utf-8'); + } + + /** + * GET /testplans/{id}/document?type=report[&buildID=] + * Printable HTML test report: plan header, per-suite verdict summary + * (latest execution per case version — same shape as matrixBySuite), + * then the per-case latest verdict list with tester/date/notes. + */ + public function getPlanDocument(Request $request, Response $response, $args) + { + $planID = intval($args['id']); + $qs = $request->getQueryParams(); + $type = isset($qs['type']) ? trim($qs['type']) : 'report'; + $buildID = isset($qs['buildID']) ? intval($qs['buildID']) : 0; + + if ($type != 'report') { + $response->getBody()->write(json_encode( + array('status' => 'error', 'message' => "Unsupported document type '{$type}'"))); + return $response->withStatus(400); + } + + $plan = $this->tplanMgr->get_by_id($planID); + if (is_null($plan) || !isset($plan['name'])) { + $response->getBody()->write(json_encode( + array('status' => 'error', 'message' => 'Test plan does not exist'))); + return $response->withStatus(404); + } + + $sql = " SELECT name FROM {$this->tables['nodes_hierarchy']} " . + " WHERE id = " . intval($plan['testproject_id']); + $projectName = (string)$this->db->fetchFirstRowSingleColumn($sql, 'name'); + + $buildName = null; + if ($buildID > 0) { + $sql = " SELECT name FROM {$this->tables['builds']} " . + " WHERE id = {$buildID} AND testplan_id = {$planID} "; + $buildName = $this->db->fetchFirstRowSingleColumn($sql, 'name'); + if (is_null($buildName)) { + $response->getBody()->write(json_encode( + array('status' => 'error', 'message' => 'Build does not belong to this plan'))); + return $response->withStatus(404); + } + } + $buildFilter = $buildID > 0 ? " AND build_id = {$buildID} " : ''; + + // ---- per-suite verdict summary (matrixBySuite SQL shape) ---- + $sql = " SELECT S.id AS suite_id, S.name AS suite_name, COUNT(*) AS linked " . + " FROM {$this->tables['testplan_tcversions']} T " . + " JOIN {$this->tables['nodes_hierarchy']} NHTCV ON NHTCV.id = T.tcversion_id " . + " JOIN {$this->tables['nodes_hierarchy']} NHTC ON NHTC.id = NHTCV.parent_id " . + " JOIN {$this->tables['nodes_hierarchy']} S ON S.id = NHTC.parent_id " . + " WHERE T.testplan_id = {$planID} " . + " GROUP BY S.id, S.name ORDER BY S.name "; + $suites = array(); + foreach ((array)$this->db->get_recordset($sql) as $row) { + $suites[$row['suite_id']] = array( + 'name' => $row['suite_name'], + 'linked' => intval($row['linked']), + 'p' => 0, 'f' => 0, 'b' => 0, 'other' => 0); + } + + $sql = " SELECT S.id AS suite_id, E2.status, COUNT(*) AS qty " . + " FROM (SELECT MAX(id) AS mid FROM {$this->tables['executions']} " . + " WHERE testplan_id = {$planID} {$buildFilter} " . + " GROUP BY tcversion_id) M " . + " JOIN {$this->tables['executions']} E2 ON E2.id = M.mid " . + " JOIN {$this->tables['nodes_hierarchy']} NHTCV ON NHTCV.id = E2.tcversion_id " . + " JOIN {$this->tables['nodes_hierarchy']} NHTC ON NHTC.id = NHTCV.parent_id " . + " JOIN {$this->tables['nodes_hierarchy']} S ON S.id = NHTC.parent_id " . + " GROUP BY S.id, E2.status "; + foreach ((array)$this->db->get_recordset($sql) as $row) { + $sid = $row['suite_id']; + if (!isset($suites[$sid])) { + continue; + } + $key = in_array($row['status'], array('p','f','b')) ? $row['status'] : 'other'; + $suites[$sid][$key] += intval($row['qty']); + } + + // ---- per-case latest verdict list, bounded like the spec doc ---- + $cap = self::DOC_MAX_CASES; + $sql = " SELECT NHTC.name, TCV.tc_external_id, S.name AS suite_name, " . + " E2.status, E2.execution_ts, E2.notes, " . + " U.login AS tester, B.name AS build_name " . + " FROM {$this->tables['testplan_tcversions']} T " . + " JOIN {$this->tables['nodes_hierarchy']} NHTCV ON NHTCV.id = T.tcversion_id " . + " JOIN {$this->tables['nodes_hierarchy']} NHTC ON NHTC.id = NHTCV.parent_id " . + " JOIN {$this->tables['nodes_hierarchy']} S ON S.id = NHTC.parent_id " . + " JOIN {$this->tables['tcversions']} TCV ON TCV.id = T.tcversion_id " . + " LEFT JOIN (SELECT tcversion_id, MAX(id) AS mid " . + " FROM {$this->tables['executions']} " . + " WHERE testplan_id = {$planID} {$buildFilter} " . + " GROUP BY tcversion_id) M ON M.tcversion_id = T.tcversion_id " . + " LEFT JOIN {$this->tables['executions']} E2 ON E2.id = M.mid " . + " LEFT JOIN {$this->tables['users']} U ON U.id = E2.tester_id " . + " LEFT JOIN {$this->tables['builds']} B ON B.id = E2.build_id " . + " WHERE T.testplan_id = {$planID} " . + " ORDER BY S.name, NHTC.name " . + " LIMIT " . ($cap + 1); + $caseRows = (array)$this->db->get_recordset($sql); + $truncated = count($caseRows) > $cap; + if ($truncated) { + array_pop($caseRows); + } + + $title = 'Test report · ' . $plan['name']; + $subtitle = 'Project ' . $projectName . + ($buildName !== null ? ' · Build ' . $buildName : ' · all builds') . + ' · Generated ' . date('Y-m-d H:i'); + $html = $this->docHtmlOpen($title, $subtitle); + + $html .= '

Verdict summary by suite

'; + if (count($suites) == 0) { + $html .= '

No test cases linked to this plan.

'; + } else { + $tot = array('linked' => 0, 'p' => 0, 'f' => 0, 'b' => 0); + $html .= '' . + '' . + ''; + foreach ($suites as $s) { + $notRun = max(0, $s['linked'] - $s['p'] - $s['f'] - $s['b'] - $s['other']); + foreach (array('linked','p','f','b') as $k) { + $tot[$k] += $s[$k]; + } + $html .= '' . + '' . + '' . + '' . + '' . + ''; + } + $html .= '' . + '' . + '' . + '
SuiteCasesPassedFailedBlockedNot run
' . self::docEsc($s['name']) . '' . $s['linked'] . '' . $s['p'] . '' . $s['f'] . '' . $s['b'] . '' . $notRun . '
Total' . $tot['linked'] . '' . $tot['p'] . '' . $tot['f'] . '' . $tot['b'] . '
'; + } + + $html .= '

Latest result per test case

'; + if ($truncated) { + $html .= '
List truncated at ' . $cap . ' test cases.
'; + } + if (count($caseRows) == 0) { + $html .= '

No test cases linked to this plan.

'; + } else { + $html .= '' . + ''; + foreach ($caseRows as $row) { + $code = isset($row['status']) ? $row['status'] : null; + $cls = in_array($code, array('p','f','b')) ? 'v-' . $code : 'v-n'; + $html .= '' . + '' . + '' . + '' . + '' . + '' . + ''; + } + $html .= '
Test caseSuiteVerdictTesterDateBuildNotes
' . self::docEsc($row['tc_external_id'] . ': ' . $row['name']) . '' . self::docEsc($row['suite_name']) . '' . self::docEsc(self::docVerdictLabel($code)) . '' . self::docEsc($row['tester'] ?? '') . '' . self::docEsc($row['execution_ts'] ?? '') . '' . self::docEsc($row['build_name'] ?? '') . '' . self::docEsc($row['notes'] ?? '') . '
'; + } + + $html .= ''; + $response->getBody()->write($html); + return $response->withHeader('Content-Type', 'text/html; charset=utf-8'); + } + + /** + * GET /testsuites/{id}/xml + * TestLink-format XML export of a suite subtree — same schema as the + * legacy tcExport.php page (it reuses the very same exporter: + * testsuite::exportTestSuiteDataToXML). + */ + public function exportSuiteXML(Request $request, Response $response, $args) + { + $suiteID = intval($args['id']); + + $sql = " SELECT name FROM {$this->tables['nodes_hierarchy']} " . + " WHERE id = {$suiteID} AND node_type_id = 2 "; + $suiteName = $this->db->fetchFirstRowSingleColumn($sql, 'name'); + if (is_null($suiteName)) { + $response->getBody()->write(json_encode( + array('status' => 'error', 'message' => 'Test suite does not exist'))); + return $response->withStatus(404); + } + + $tprojectID = intval($this->tsuiteMgr->tree_manager->getTreeRoot($suiteID)); + + // same option set the legacy export page uses for a deep suite + // export without the optional extras (keywords/cfields/reqs/attachments) + $optExport = array('RECURSIVE' => 1, 'TCSTEPS' => 1, + 'EXTERNALID' => 1, 'ADDPREFIX' => 0, + 'TCSUMMARY' => 1, 'TCPRECONDITIONS' => 1, + 'KEYWORDS' => 0, 'CFIELDS' => 0, + 'REQS' => 0, 'ATTACHMENTS' => 0); + + // the legacy exporter emits notices/warnings for unset optional + // sections and loads a helper via a cwd-relative require + // (../../third_party/...), so run it from lib/functions — the cwd + // the legacy pages give it — and silence the noise so the XML + // stream stays clean + $oldLevel = error_reporting(E_ERROR | E_PARSE); + $oldCwd = getcwd(); + chdir(TL_ABS_PATH . 'lib' . DIRECTORY_SEPARATOR . 'functions'); + ob_start(); + $xml = TL_XMLEXPORT_HEADER . + $this->tsuiteMgr->exportTestSuiteDataToXML($suiteID, $tprojectID, $optExport); + ob_end_clean(); + chdir($oldCwd); + error_reporting($oldLevel); + + $fileName = preg_replace('/[^A-Za-z0-9._-]+/', '_', $suiteName) . + '.testsuite-deep.xml'; + $response->getBody()->write($xml); + return $response + ->withHeader('Content-Type', 'application/xml; charset=utf-8') + ->withHeader('Content-Disposition', + 'attachment; filename="' . addslashes($fileName) . '"'); + } + + /** + * POST /testsuites/{id}/xml + * Import test cases from TestLink-format XML (the schema the export + * above produces) into suite {id}. Body is the raw XML document. + * Cases whose name already exists in the target suite are skipped, + * so re-importing the same file is idempotent. + * Returns {created, skippedDuplicates, errors[]}. + */ + public function importSuiteXML(Request $request, Response $response, $args) + { + $suiteID = intval($args['id']); + + $sql = " SELECT name FROM {$this->tables['nodes_hierarchy']} " . + " WHERE id = {$suiteID} AND node_type_id = 2 "; + $suiteName = $this->db->fetchFirstRowSingleColumn($sql, 'name'); + if (is_null($suiteName)) { + $response->getBody()->write(json_encode( + array('status' => 'error', 'message' => 'Test suite does not exist'))); + return $response->withStatus(404); + } + + $raw = trim((string)$request->getBody()); + if ($raw == '') { + $response->getBody()->write(json_encode( + array('status' => 'error', 'message' => 'Empty request body — send the XML document'))); + return $response->withStatus(400); + } + + libxml_use_internal_errors(true); + $xml = simplexml_load_string($raw); + if ($xml === false) { + $detail = array_map(function ($e) { + return trim($e->message) . ' (line ' . $e->line . ')'; + }, array_slice(libxml_get_errors(), 0, 3)); + libxml_clear_errors(); + $response->getBody()->write(json_encode( + array('status' => 'error', 'message' => 'Malformed XML', + 'detail' => $detail))); + return $response->withStatus(400); + } + + // accept , (deep export) or a single + $tcNodes = $xml->xpath('//testcase'); + if ($xml->getName() == 'testcase') { + $tcNodes = array($xml); + } + + $op = array('status' => 'ok', 'created' => 0, + 'skippedDuplicates' => 0, 'errors' => array()); + + // names already present in the target suite -> duplicate skip + $sql = " SELECT name FROM {$this->tables['nodes_hierarchy']} " . + " WHERE parent_id = {$suiteID} AND node_type_id = 3 "; + $existing = array(); + foreach ((array)$this->db->get_recordset($sql) as $row) { + $existing[mb_strtolower(trim($row['name']))] = true; + } + + foreach ($tcNodes as $tc) { + $name = trim((string)$tc['name']); + if ($name == '') { + $op['errors'][] = 'testcase without name attribute skipped'; + continue; + } + if (isset($existing[mb_strtolower($name)])) { + $op['skippedDuplicates']++; + continue; + } + + // steps use the legacy element names (expectedresults, ...) + $steps = array(); + if (isset($tc->steps->step)) { + $n = 0; + foreach ($tc->steps->step as $step) { + $n++; + $stepNumber = intval((string)$step->step_number); + $steps[] = array( + 'step_number' => $stepNumber > 0 ? $stepNumber : $n, + 'actions' => (string)$step->actions, + 'expected_results' => (string)$step->expectedresults, + 'execution_type' => max(1, intval((string)$step->execution_type))); + } + } + + $execType = intval((string)$tc->execution_type); + $importance = intval((string)$tc->importance); + $order = intval((string)$tc->node_order); + + try { + $ret = $this->tcaseMgr->create( + $suiteID, $name, + (string)$tc->summary, (string)$tc->preconditions, + $steps, $this->userID, '', $order, + testcase::AUTOMATIC_ID, + $execType > 0 ? $execType : TESTCASE_EXECUTION_TYPE_MANUAL, + $importance > 0 ? $importance : 2); + if (isset($ret['status_ok']) && $ret['status_ok']) { + $op['created']++; + $existing[mb_strtolower($name)] = true; + } else { + $op['errors'][] = "'{$name}': " . + (isset($ret['msg']) ? $ret['msg'] : 'create failed'); + } + } catch (Exception $e) { + $op['errors'][] = "'{$name}': " . $this->msgFromException($e); + } + } + + if (count($tcNodes) == 0) { + $op['message'] = 'No elements found in the document'; + } + + $response->getBody()->write(json_encode($op)); + return $response; } } // class end diff --git a/lib/api/rest/v3/core/routes.php b/lib/api/rest/v3/core/routes.php index f0e2404fae..405a6008d5 100644 --- a/lib/api/rest/v3/core/routes.php +++ b/lib/api/rest/v3/core/routes.php @@ -13,12 +13,101 @@ // still seems valid $app->get('/whoAmI',array($app->restApi,'whoAmI')); + // SPA (ui/) endpoints + $app->post('/auth/login', array($app->restApi,'authLogin')); + $app->get('/testprojects/{id}/suites', + array($app->restApi,'getProjectSuites')); + $app->get('/testsuites/{id}/testcases', + array($app->restApi,'getSuiteTestCases')); + $app->get('/testcases/{id}/detail', + array($app->restApi,'getTestCaseDetail')); + $app->get('/testplans/{id}/summary', + array($app->restApi,'getPlanSummary')); + $app->get('/testplans/{id}/trend', + array($app->restApi,'getPlanTrend')); + $app->get('/testplans/{id}/flaky', + array($app->restApi,'getPlanFlaky')); + $app->get('/testplans/{id}/queue', + array($app->restApi,'getPlanQueue')); + $app->get('/testplans/{id}/buildsById', + array($app->restApi,'getPlanBuildsById')); + $app->get('/testplans/{id}/matrix', + array($app->restApi,'getPlanMatrix')); + $app->get('/testplans/{id}/matrixBySuite', + array($app->restApi,'getPlanMatrixBySuite')); + $app->put('/testcases/{id}/update', + array($app->restApi,'updateTestCase')); + $app->post('/testplans/{id}/link', + array($app->restApi,'linkPlanCases')); + $app->get('/users', + array($app->restApi,'getUsers')); + $app->post('/users', + array($app->restApi,'createUser')); + $app->put('/users/{id}', + array($app->restApi,'updateUser')); + $app->get('/testprojects/{id}/keywords', + array($app->restApi,'getProjectKeywords')); + $app->delete('/keywords/{id}', + array($app->restApi,'deleteKeyword')); + $app->get('/testprojects/{id}/platforms', + array($app->restApi,'getProjectPlatforms')); + $app->post('/platforms', + array($app->restApi,'createPlatform')); + $app->get('/testprojects/{id}/customfields', + array($app->restApi,'getProjectCustomFields')); + $app->get('/testprojects/{id}/search', + array($app->restApi,'searchTestCases')); + $app->post('/executions/{id}/attachments', + array($app->restApi,'uploadExecutionAttachment')); + $app->get('/executions/{id}/attachments', + array($app->restApi,'getExecutionAttachments')); + $app->get('/attachments/{id}', + array($app->restApi,'downloadAttachment')); + $app->post('/testplans/{id}/assign', + array($app->restApi,'assignPlanCases')); + $app->get('/testplans/{id}/byTester', + array($app->restApi,'getPlanByTester')); + $app->get('/testplans/{id}/byBuild', + array($app->restApi,'getPlanByBuild')); + $app->get('/testplans/{id}/byKeyword', + array($app->restApi,'getPlanByKeyword')); + $app->get('/testplans/{id}/milestones', + array($app->restApi,'getPlanMilestones')); + $app->post('/milestones', + array($app->restApi,'createMilestone')); + $app->delete('/milestones/{id}', + array($app->restApi,'deleteMilestone')); + $app->get('/testprojects/{id}/reqspecs', + array($app->restApi,'getProjectReqSpecs')); + $app->post('/reqspecs', + array($app->restApi,'createReqSpec')); + $app->get('/reqspecs/{id}/requirements', + array($app->restApi,'getReqSpecRequirements')); + $app->post('/requirements', + array($app->restApi,'createRequirement')); + $app->get('/requirements/{id}/detail', + array($app->restApi,'getRequirementDetail')); + $app->post('/requirements/{id}/coverage', + array($app->restApi,'addRequirementCoverage')); + $app->delete('/requirements/{id}/coverage/{tcaseID}', + array($app->restApi,'deleteRequirementCoverage')); + $app->get('/testplans/{id}/reqCoverage', + array($app->restApi,'getPlanReqCoverage')); + $app->get('/testprojects/{id}/document', + array($app->restApi,'getProjectDocument')); + $app->get('/testplans/{id}/document', + array($app->restApi,'getPlanDocument')); + $app->get('/testsuites/{id}/xml', + array($app->restApi,'exportSuiteXML')); + $app->post('/testsuites/{id}/xml', + array($app->restApi,'importSuiteXML')); + $app->get('/testprojects', array($app->restApi,'testprojects')); $app->get('/testprojects/{id}', array($app->restApi,'testprojects')); - $app->get('/testprojects/{id}/testcases', + $app->get('/testprojects/{mixedID}/testcases', array($app->restApi,'getProjectTestCases')); $app->get('/testprojects/{mixedID}/testplans', array($app->restApi,'getProjectTestPlans')); diff --git a/lib/api/rest/v3/custom/api/RestApiCustomExample.class.php b/lib/api/rest/v3/custom/api/RestApiCustomExample.class.php index bfefd4c407..2f84c4a57d 100644 --- a/lib/api/rest/v3/custom/api/RestApiCustomExample.class.php +++ b/lib/api/rest/v3/custom/api/RestApiCustomExample.class.php @@ -6,7 +6,7 @@ */ $bd = dirname(__FILE__); $ds = DIRECTORY_SEPARATOR; -$dummy = explode($ds. lib . $ds, $bd); +$dummy = explode($ds. 'lib' . $ds, $bd); require_once($dummy[0] . $ds . 'config.inc.php'); require_once('common.php'); diff --git a/lib/api/xmlrpc/v1/xmlrpc.class.php b/lib/api/xmlrpc/v1/xmlrpc.class.php index 0b07e4d99b..0e899accc2 100644 --- a/lib/api/xmlrpc/v1/xmlrpc.class.php +++ b/lib/api/xmlrpc/v1/xmlrpc.class.php @@ -3554,7 +3554,7 @@ public function addTestCaseToTestPlan($args) { $opt = array( 'outputFormat' => 'mapAccessByID' ); - $platformSet = (arrya)$this->tplanMgr->getPlatforms( $tplan_id, $opt ); + $platformSet = (array)$this->tplanMgr->getPlatforms( $tplan_id, $opt ); $hasPlatforms = (count( $platformSet ) > 0); $hasPlatformIDArgs = $this->_isParamPresent( self::$platformIDParamName ); diff --git a/lib/functions/common.php b/lib/functions/common.php index 4122e615a4..81da891dd0 100644 --- a/lib/functions/common.php +++ b/lib/functions/common.php @@ -44,6 +44,7 @@ /** Initialize the Event System */ require_once('event_api.php' ); +require_once('webhook_api.php'); // Needed to avoid problems with Smarty 3 spl_autoload_register('tlAutoload'); diff --git a/lib/functions/exec.inc.php b/lib/functions/exec.inc.php index 1e27f523fa..8d86505ee0 100644 --- a/lib/functions/exec.inc.php +++ b/lib/functions/exec.inc.php @@ -160,13 +160,23 @@ function write_execution(&$db,&$execSign,&$exec_data,&$issueTracker) { $sql .= ',' . $dura . ")"; - $db->exec_query($sql); - - // at least for Postgres DBMS table name is needed. + $db->exec_query($sql); + + // at least for Postgres DBMS table name is needed. $execution_id = $db->insert_id($executions_table); - + $execSet[$tcversion_id] = $execution_id; + webhook_notify('execution.created', + array('executionID' => $execution_id, + 'testPlanID' => $execSign->tplan_id, + 'buildID' => $execSign->build_id, + 'platformID' => $executedInPlatform, + 'testCaseVersionID' => $tcversion_id, + 'status' => $current_status, + 'testerID' => $execSign->user_id, + 'source' => 'ui')); + // $tcvRelations = (array)$tcaseMgr->getTCVRelationsRaw($tcversion_id); if( count($tcvRelations) > 0 ) { diff --git a/lib/functions/oauth_api.php b/lib/functions/oauth_api.php index 093cbb017e..c8478c85f1 100644 --- a/lib/functions/oauth_api.php +++ b/lib/functions/oauth_api.php @@ -27,6 +27,7 @@ function oauth_link($oauthCfg) case 'github': case 'google': case 'microsoft': + case 'oidc': // @20200523 it seems that with relative can work $url = 'lib/functions/oauth_providers/OAuth2Call.php?oauth2=' . trim($oauthCfg['oauth_name']); diff --git a/lib/functions/oauth_providers/OAuth2Call.php b/lib/functions/oauth_providers/OAuth2Call.php index 4634f48839..364507d027 100644 --- a/lib/functions/oauth_providers/OAuth2Call.php +++ b/lib/functions/oauth_providers/OAuth2Call.php @@ -58,14 +58,34 @@ $urlOpt = []; break; + case 'oidc': + $clientType = 'testLink'; + $_SESSION['oauth2state'] = $oauth2Name . '$$$' . + bin2hex(random_bytes(32)); + + // resolve endpoints from .well-known discovery when configured + require_once('oidc.php'); + $cfg = oidc_resolve_endpoints($cfg); + + $oap = []; + $oap['state'] = $_SESSION['oauth2state']; + $oap['redirect_uri'] = $cfg['redirect_uri']; + $oap['client_id'] = $cfg['oauth_client_id']; + $oap['scope'] = isset($cfg['oauth_scope']) ? + $cfg['oauth_scope'] : 'openid profile email'; + $oap['response_type'] = 'code'; + + $authUrl = $cfg['oauth_url'] . '?' . http_build_query($oap); + break; + case 'microsoft': case 'azuread'; $clientType = 'testLink'; - $_SESSION['oauth2state'] = $oauth2Name . '$$$' . + $_SESSION['oauth2state'] = $oauth2Name . '$$$' . bin2hex(random_bytes(32)); // see https://docs.microsoft.com/en-us/azure/ - // active-directory/develop/v1-protocols-oauth-code + // active-directory/develop/v1-protocols-oauth-code // for details $oap = []; $oap['state'] = $_SESSION['oauth2state']; @@ -76,11 +96,11 @@ $oap['response_type'] = 'code'; if ($oauth2Name == 'azuread') { - if (!is_null($oauthCfg['oauth_domain'])) { - $oap['domain_hint'] = $oauthCfg['oauth_domain']; + if (!is_null($cfg['oauth_domain'])) { + $oap['domain_hint'] = $cfg['oauth_domain']; } } else { - if ($oauthCfg['oauth_force_single']) { + if ($cfg['oauth_force_single']) { $oap['prompt'] = 'consent'; } } diff --git a/lib/functions/oauth_providers/oidc.php b/lib/functions/oauth_providers/oidc.php new file mode 100644 index 0000000000..95d75eeacc --- /dev/null +++ b/lib/functions/oauth_providers/oidc.php @@ -0,0 +1,148 @@ + true, + CURLOPT_CONNECTTIMEOUT => 5, + CURLOPT_TIMEOUT => 10, + )); + $doc = json_decode((string)curl_exec($ch), true); + curl_close($ch); + + if (is_array($doc)) { + $map = array('authorization_endpoint' => 'oauth_url', + 'token_endpoint' => 'token_url', + 'userinfo_endpoint' => 'oauth_profile'); + foreach ($map as $docKey => $cfgKey) { + if (empty($authCfg[$cfgKey]) && isset($doc[$docKey])) { + $authCfg[$cfgKey] = $doc[$docKey]; + } + } + } + return $authCfg; +} + +/** + * Exchange the authorization code for tokens and resolve the user's + * identity through the userinfo endpoint. + * + * @param array $authCfg provider configuration + * @param string $code authorization code sent back by the IdP + * @return stdClass ->status array(status,msg), ->options on success + */ +function oauth_get_token($authCfg, $code) +{ + $result = new stdClass(); + $result->status = array('status' => tl::OK, 'msg' => null); + + $authCfg = oidc_resolve_endpoints($authCfg); + if (empty($authCfg['token_url']) || empty($authCfg['oauth_profile'])) { + $result->status['msg'] = 'TestLink OIDC - token/userinfo endpoints ' . + 'not configured and discovery failed'; + $result->status['status'] = tl::ERROR; + return $result; + } + + $redirectUri = trim($authCfg['redirect_uri']); + if (isset($_SERVER['HTTPS'])) { + $redirectUri = str_replace('http://', 'https://', $redirectUri); + } + + $tokenParams = array( + 'code' => $code, + 'grant_type' => 'authorization_code', + 'client_id' => $authCfg['oauth_client_id'], + 'client_secret' => $authCfg['oauth_client_secret'], + 'redirect_uri' => $redirectUri, + ); + + $ch = curl_init($authCfg['token_url']); + curl_setopt_array($ch, array( + CURLOPT_POST => true, + CURLOPT_POSTFIELDS => http_build_query($tokenParams), + CURLOPT_RETURNTRANSFER => true, + CURLOPT_CONNECTTIMEOUT => 5, + CURLOPT_TIMEOUT => 10, + )); + $tokenInfo = json_decode((string)curl_exec($ch), true); + curl_close($ch); + + if (!isset($tokenInfo['access_token'])) { + $detail = isset($tokenInfo['error']) ? + $tokenInfo['error'] . ' ' . ($tokenInfo['error_description'] ?? '') : 'no access_token'; + $result->status['msg'] = 'TestLink OIDC - token exchange failed: ' . $detail; + $result->status['status'] = tl::ERROR; + return $result; + } + + // identity comes from the userinfo endpoint (TLS, server-to-server) + $ch = curl_init($authCfg['oauth_profile']); + curl_setopt_array($ch, array( + CURLOPT_HTTPHEADER => array('Authorization: Bearer ' . $tokenInfo['access_token']), + CURLOPT_RETURNTRANSFER => true, + CURLOPT_CONNECTTIMEOUT => 5, + CURLOPT_TIMEOUT => 10, + )); + $userInfo = json_decode((string)curl_exec($ch), true); + curl_close($ch); + + $login = $userInfo['email'] ?? $userInfo['preferred_username'] ?? null; + if (null == $login) { + $result->status['msg'] = 'TestLink OIDC - userinfo carries neither ' . + 'email nor preferred_username claim'; + $result->status['status'] = tl::ERROR; + return $result; + } + + if (!empty($authCfg['oauth_domain']) && strpos($login, '@') !== false) { + $domain = substr(strrchr($login, '@'), 1); + if ($domain !== $authCfg['oauth_domain']) { + $result->status['msg'] = "TestLink OIDC policy - user domain " . + "'$domain' does not match configured oauth_domain " . + "'{$authCfg['oauth_domain']}'"; + $result->status['status'] = tl::ERROR; + return $result; + } + } + + $options = new stdClass(); + $options->givenName = $userInfo['given_name'] ?? $login; + $options->familyName = $userInfo['family_name'] ?? ''; + $options->user = $login; + $options->auth = 'oauth'; + + $result->options = $options; + return $result; +} diff --git a/lib/functions/string_api.php b/lib/functions/string_api.php index 44f4720e88..fd33cd030b 100644 --- a/lib/functions/string_api.php +++ b/lib/functions/string_api.php @@ -12,6 +12,19 @@ * **/ +# Link-target constants used by string_insert_hrefs(), adapted from MantisBT +# (constant_inc.php). They were referenced below but never defined in TestLink; +# on PHP 8 referencing an undefined constant is a fatal error, so displaying +# any value that contains a URL (e.g. a custom field rendered through +# string_display_links()) crashed with: +# Error: Undefined constant "LINKS_NEW_WINDOW" +if( !defined( 'LINKS_SAME_WINDOW' ) ) { + define( 'LINKS_SAME_WINDOW', 1 ); +} +if( !defined( 'LINKS_NEW_WINDOW' ) ) { + define( 'LINKS_NEW_WINDOW', 2 ); +} + /** * Preserve spaces at beginning of lines. @@ -232,7 +245,7 @@ function string_sanitize_url( $p_url ) { // split and encode parameters if ( strpos( $t_url, '?' ) !== FALSE ) { - list( $t_path, $t_param ) = split( '\?', $t_url, 2 ); + list( $t_path, $t_param ) = explode( '?', $t_url, 2 ); if ( $t_param !== "" ) { $t_vals = array(); parse_str( $t_param, $t_vals ); @@ -337,7 +350,7 @@ function ( $p_match ) { # mailto: link, making sure that we skip processing of any existing anchor # tags, to avoid parts of URLs such as https://user@example.com/ or # http://user:password@example.com/ to be not treated as an email. - $t_pieces = preg_split( $s_anchor_regex, $p_string, null, PREG_SPLIT_DELIM_CAPTURE ); + $t_pieces = preg_split( $s_anchor_regex, $p_string, -1, PREG_SPLIT_DELIM_CAPTURE ); $p_string = ''; foreach( $t_pieces as $piece ) { if( preg_match( $s_anchor_regex, $piece ) ) { diff --git a/lib/functions/testplan.class.php b/lib/functions/testplan.class.php index c08cb51358..cd79367bc5 100644 --- a/lib/functions/testplan.class.php +++ b/lib/functions/testplan.class.php @@ -1191,7 +1191,7 @@ function unlink_tcversions($id,&$items) { // First get the executions id if any exist $sql = " /* $debugMsg */ SELECT id AS execution_id FROM {$this->tables['executions']} - WHERE testplan_id = {$id} AND ${where_clause}"; + WHERE testplan_id = {$id} AND {$where_clause}"; $exec_ids = $this->db->fetchRowsIntoMap($sql,'execution_id'); @@ -1250,7 +1250,7 @@ function unlink_tcversions($id,&$items) { // Grand Finale now remove executions $sql = " /* $debugMsg */ DELETE FROM {$this->tables['executions']} - WHERE testplan_id = {$id} AND ${where_clause}"; + WHERE testplan_id = {$id} AND {$where_clause}"; $result = $this->db->exec_query($sql); } @@ -7224,7 +7224,17 @@ function writeExecution($ex) " {$ex->testerID},{$execTS}, {$ex->executionType}, '{$execNotes}')"; $this->db->exec_query($sql); - $execID = $this->db->insert_id($this->tables['executions']); + $execID = $this->db->insert_id($this->tables['executions']); + + webhook_notify('execution.created', + array('executionID' => $execID, + 'testPlanID' => $ex->testPlanID, + 'buildID' => $ex->buildID, + 'platformID' => $ex->platformID, + 'testCaseVersionID' => $ex->testCaseVersionID, + 'status' => $ex->statusCode, + 'testerID' => $ex->testerID, + 'source' => 'api')); // Do we have steps exec info? if (property_exists($ex,'steps')) { diff --git a/lib/functions/webhook_api.php b/lib/functions/webhook_api.php new file mode 100644 index 0000000000..cb30a0a320 --- /dev/null +++ b/lib/functions/webhook_api.php @@ -0,0 +1,81 @@ +webhooks = array( + * array('url' => 'https://hooks.example.com/testlink', + * 'events' => array('execution.created'), // or array('*') + * 'secret' => 'shared-secret-or-empty'), + * ); + * + * Each matching webhook receives a JSON POST: + * {"event": "...", "timestamp": "...", "data": {...}} + * + * When 'secret' is non-empty the request carries an + * X-TestLink-Signature header: sha256=, + * so receivers can authenticate the payload (GitHub-style). + * + * Delivery is best-effort fire-and-forget with a short timeout so a + * slow or dead receiver can not block the user-facing operation. + * + * @package TestLink + * @filesource webhook_api.php + */ + +/** + * Send $event with $data to every configured webhook subscribed to it. + * + * @param string $event dotted event name, e.g. 'execution.created' + * @param array $data event payload, must be JSON-encodable + * + * @return void errors are logged, never thrown + */ +function webhook_notify($event, $data) +{ + $hooks = config_get('webhooks'); + if (empty($hooks) || !is_array($hooks)) { + return; + } + + $body = json_encode( + array('event' => $event, + 'timestamp' => date('c'), + 'data' => $data)); + + foreach ($hooks as $hook) { + if (empty($hook['url'])) { + continue; + } + $events = isset($hook['events']) ? (array)$hook['events'] : array('*'); + if (!in_array('*', $events) && !in_array($event, $events)) { + continue; + } + + $headers = array('Content-Type: application/json', + 'User-Agent: TestLink-Webhook'); + if (!empty($hook['secret'])) { + $headers[] = 'X-TestLink-Signature: sha256=' . + hash_hmac('sha256', $body, $hook['secret']); + } + + $ch = curl_init($hook['url']); + curl_setopt_array($ch, array( + CURLOPT_POST => true, + CURLOPT_POSTFIELDS => $body, + CURLOPT_HTTPHEADER => $headers, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_CONNECTTIMEOUT => 2, + CURLOPT_TIMEOUT => 3, + )); + if (curl_exec($ch) === false) { + tLog('webhook_notify: delivery to ' . $hook['url'] . + ' failed: ' . curl_error($ch), 'WARNING'); + } + curl_close($ch); + } +} diff --git a/lib/results/neverRunByPP.php b/lib/results/neverRunByPP.php index d2969c183f..95caa3566a 100644 --- a/lib/results/neverRunByPP.php +++ b/lib/results/neverRunByPP.php @@ -323,7 +323,7 @@ function buildMailCfg(&$guiObj) { * return tlExtTable * */ -function buildMatrix($dataSet, &$args, $options = array(), $platforms,$customFieldColumns=null) { +function buildMatrix($dataSet, &$args, $options, $platforms,$customFieldColumns=null) { $default_options = array('show_platforms' => false,'format' => FORMAT_HTML); $options = array_merge($default_options, $options); diff --git a/lib/results/resultsTrend.php b/lib/results/resultsTrend.php new file mode 100644 index 0000000000..a382954105 --- /dev/null +++ b/lib/results/resultsTrend.php @@ -0,0 +1,196 @@ +tproject_id = $context->tplan_id = null; + $context->getAccessAttr = false; + } + return $user->hasRightOnProj($db, 'testplan_metrics', + $context->tproject_id, $context->tplan_id, $context->getAccessAttr); +} + +list($tplan_mgr, $args) = initArgsForReports($db); +if (null == $tplan_mgr) { + $tplan_mgr = new testplan($db); +} + +$gui = new stdClass(); +$gui->title = lang_get('title_results_trend'); + +$tprojectMgr = new testproject($db); +$dummy = $tprojectMgr->get_by_id($args->tproject_id); +$gui->tproject_name = $dummy['name']; + +$info = $tplan_mgr->get_by_id($args->tplan_id); +$gui->tplan_name = $info['name']; +$gui->tplan_id = intval($args->tplan_id); + +$tables = tlObjectWithDB::getDBTables(array('executions', 'nodes_hierarchy', 'tcversions')); + +// ----------------------------------------------------------------- +// Daily status counters +// ----------------------------------------------------------------- +$sql = " SELECT DATE(execution_ts) AS execday, status, COUNT(*) AS qty " . + " FROM {$tables['executions']} " . + " WHERE testplan_id = " . intval($args->tplan_id) . + " GROUP BY execday, status ORDER BY execday "; + +$rows = (array)$db->get_recordset($sql); +$daily = array(); +foreach ($rows as $row) { + $day = $row['execday']; + if (!isset($daily[$day])) { + $daily[$day] = array('p' => 0, 'f' => 0, 'b' => 0, 'other' => 0); + } + $statusKey = isset($daily[$day][$row['status']]) ? $row['status'] : 'other'; + $daily[$day][$statusKey] += $row['qty']; +} + +$gui->trendSVG = buildTrendSVG($daily); +$gui->trendDays = $daily; + +// ----------------------------------------------------------------- +// Flaky ranking: count pass<->fail flips per tcversion, newest execs +// ----------------------------------------------------------------- +$sql = " SELECT E.tcversion_id, E.status, NHTC.name, NHTC.id AS tcase_id " . + " FROM {$tables['executions']} E " . + " JOIN {$tables['nodes_hierarchy']} NHTCV ON NHTCV.id = E.tcversion_id " . + " JOIN {$tables['nodes_hierarchy']} NHTC ON NHTC.id = NHTCV.parent_id " . + " WHERE E.testplan_id = " . intval($args->tplan_id) . + " AND E.status IN ('p','f') " . + " ORDER BY E.tcversion_id, E.execution_ts "; + +$rows = (array)$db->get_recordset($sql); +$flips = array(); +$prev = array(); +foreach ($rows as $row) { + $key = $row['tcversion_id']; + if (!isset($flips[$key])) { + $flips[$key] = array('name' => $row['name'], + 'tcase_id' => $row['tcase_id'], + 'flips' => 0, 'total' => 0); + } + $flips[$key]['total']++; + if (isset($prev[$key]) && $prev[$key] != $row['status']) { + $flips[$key]['flips']++; + } + $prev[$key] = $row['status']; +} +$flips = array_filter($flips, function ($item) { + return $item['flips'] > 0; +}); +uasort($flips, function ($a, $b) { + return $b['flips'] <=> $a['flips']; +}); +$gui->flaky = array_slice($flips, 0, 50, true); +$gui->flakyAnalyzed = count($prev); + +$smarty = new TLSmarty(); +$smarty->assign('gui', $gui); +$smarty->display($templateCfg->template_dir . $templateCfg->default_template); + +/** + * Render daily status counters as a self-contained SVG stacked chart. + * + * @param array $daily map: 'YYYY-MM-DD' => array(p =>, f =>, b =>, other =>) + * @return string svg markup, '' when there is nothing to show + */ +function buildTrendSVG($daily) +{ + if (count($daily) == 0) { + return ''; + } + + $w = 900; + $h = 260; + $padL = 50; + $padB = 40; + $padT = 16; + $plotW = $w - $padL - 10; + $plotH = $h - $padT - $padB; + + $maxTotal = 1; + foreach ($daily as $counts) { + $maxTotal = max($maxTotal, array_sum($counts)); + } + + $barGap = 4; + $n = count($daily); + $barW = max(4, min(60, intval($plotW / $n) - $barGap)); + + $colors = array('p' => '#6bbf59', 'f' => '#d9534f', + 'b' => '#f0ad4e', 'other' => '#999999'); + + $svg = ''; + // y axis: 0 / mid / max + foreach (array(0, 0.5, 1) as $frac) { + $y = $padT + $plotH - intval($plotH * $frac); + $svg .= ''; + $svg .= '' . intval($maxTotal * $frac) . ''; + } + + $x = $padL + $barGap; + foreach ($daily as $day => $counts) { + $y = $padT + $plotH; + foreach ($colors as $statusKey => $color) { + $qty = $counts[$statusKey]; + if ($qty == 0) { + continue; + } + $hh = intval($plotH * $qty / $maxTotal); + $y -= $hh; + $svg .= '' . + '' . htmlspecialchars($day) . ' ' . $statusKey . ': ' . + $qty . ''; + } + // day label, rotated to survive dense charts + $svg .= '' . + htmlspecialchars(substr($day, 5)) . ''; + $x += $barW + $barGap; + if ($x > $padL + $plotW) { + break; + } + } + + // legend + $lx = $padL; + foreach ($colors as $statusKey => $color) { + $svg .= ''; + $svg .= '' . + $statusKey . ''; + $lx += 60; + } + + return $svg . ''; +} diff --git a/lib/testcases/tcCreateFromIssue.php b/lib/testcases/tcCreateFromIssue.php index fc3a226732..3fb04a3595 100644 --- a/lib/testcases/tcCreateFromIssue.php +++ b/lib/testcases/tcCreateFromIssue.php @@ -845,7 +845,7 @@ function: importTestSuite */ function importTestSuitesFromSimpleXML(&$dbHandler,&$xml,$parentID,$tproject_id, - $userID,$kwMap,$importIntoProject = 0,$duplicateLogic) + $userID,$kwMap,$importIntoProject,$duplicateLogic) { static $tsuiteXML; static $tsuiteMgr; diff --git a/lib/testcases/tcImport.php b/lib/testcases/tcImport.php index a15840e543..852f24ad4f 100644 --- a/lib/testcases/tcImport.php +++ b/lib/testcases/tcImport.php @@ -1016,7 +1016,7 @@ function: importTestSuite */ function importTestSuitesFromSimpleXML(&$dbHandler,&$xml,$parentID,$tproject_id, - $userID,$kwMap,$importIntoProject = 0,$duplicateLogic) + $userID,$kwMap,$importIntoProject,$duplicateLogic) { static $tsuiteXML; static $tsuiteMgr; diff --git a/locale/en_GB/strings.txt b/locale/en_GB/strings.txt index fed1ddc6ed..538c2e791b 100644 --- a/locale/en_GB/strings.txt +++ b/locale/en_GB/strings.txt @@ -4180,4 +4180,14 @@ $TLS_exec_stats_on_testproject = 'Execution Activity for Test project'; $TLS_link_report_exec_stats_on_testproject = 'Execution Activity'; $TLS_exec_qty = "Numer of Executions"; +$TLS_title_results_trend = "Execution Trend & Flaky Tests"; +$TLS_link_report_results_trend = "Execution Trend & Flaky Tests"; +$TLS_trend_daily_executions = "Daily executions by status"; +$TLS_trend_flaky_title = "Flaky test candidates"; +$TLS_trend_flaky_help = "Test case versions whose executions flip between pass and fail. Frequent flips usually mean an unstable test or an unstable feature."; +$TLS_trend_flaky_flips = "Status flips"; +$TLS_trend_flaky_execs = "Executions analyzed"; +$TLS_trend_analyzed_hint = "Test case versions analyzed"; +$TLS_trend_no_data = "No execution data available for this test plan."; + // ----- END ----------------------------------------------------- diff --git a/mcp/README.md b/mcp/README.md new file mode 100644 index 0000000000..156efdd7b8 --- /dev/null +++ b/mcp/README.md @@ -0,0 +1,161 @@ +# testlink-mcp + +An MCP (Model Context Protocol) server that exposes the TestLink REST v3 API +(`/lib/api/rest/v3`) as tools, so Claude Code and other MCP clients can operate +TestLink: browse and search test cases, author cases with steps, plan runs, +record verdicts, and report on plan health. + +Node.js + TypeScript, built on `@modelcontextprotocol/sdk` (`McpServer` + +`StdioServerTransport`), with zod input schemas on every tool. + +## Setup + +```sh +cd mcp +npm install +npm run build # compiles src/ -> dist/ with tsc +``` + +Requires Node.js 18+ (tested with Node 24). + +### Environment variables + +| Variable | Required | Default | Meaning | +| ----------------- | -------- | ----------------------- | ----------------------------------------- | +| `TESTLINK_URL` | no | `http://localhost:8090` | Base URL of the TestLink instance | +| `TESTLINK_APIKEY` | **yes** | — | API key sent as the `Apikey` HTTP header | + +The server fails fast at startup with a clear message if `TESTLINK_APIKEY` is +not set. + +### Getting an API key + +Either of: + +- **SPA login endpoint** — `POST {TESTLINK_URL}/lib/api/rest/v3/auth/login` + with `{"login": "...", "password": "..."}` returns + `{"status":"ok","apikey":"...", "user":{...}}` (a key is minted automatically + if the user has none yet): + + ```sh + curl -s -X POST http://localhost:8090/lib/api/rest/v3/auth/login \ + -H 'Content-Type: application/json' \ + -d '{"login":"admin","password":""}' + ``` + +- **User profile** — in the TestLink web UI: *My Settings → API interface → + Generate a new key*. + +## Registering with Claude Code + +The repository root contains a project-level `.mcp.json` that Claude Code +auto-detects (you will be asked to approve it on first use): + +```json +{ + "mcpServers": { + "testlink": { + "type": "stdio", + "command": "node", + "args": ["mcp/dist/index.js"], + "env": { + "TESTLINK_URL": "${TESTLINK_URL:-http://localhost:8090}", + "TESTLINK_APIKEY": "${TESTLINK_APIKEY}" + } + } + } +} +``` + +`.mcp.json` supports `${VAR}` / `${VAR:-default}` environment-variable +expansion in `command`, `args`, and `env`, so export the key in the shell you +launch Claude Code from: + +```sh +export TESTLINK_APIKEY= # e.g. deadbeefcafebabe0123456789abcde for the local dev instance +claude +``` + +Alternatively, register it manually (project scope shown): + +```sh +claude mcp add testlink --scope project \ + --env TESTLINK_URL='${TESTLINK_URL:-http://localhost:8090}' \ + --env TESTLINK_APIKEY='${TESTLINK_APIKEY}' \ + -- node mcp/dist/index.js +``` + +Check the connection inside Claude Code with `/mcp`. + +## Tools + +| Tool | What it does | +| -------------------- | ----------------------------------------------------------------------------- | +| `list_projects` | All test projects (id, name, prefix) | +| `list_plans` | Test plans of a project | +| `list_builds` | Builds of a test plan, newest first | +| `list_suites` | All suites of a project (flat list with `parent_id` links + case counts) | +| `list_suite_cases` | Case summaries inside one suite | +| `search_cases` | Full-text search of case names within a project | +| `get_case` | Full case detail: steps + recent executions (default 10, configurable) | +| `create_suite` | New suite (top-level or nested) | +| `create_case` | New case; optional `steps` are applied via an automatic follow-up update | +| `update_case` | Update name/summary/preconditions/importance/steps (`steps` replaces ALL) | +| `create_plan` | New active, public test plan | +| `create_build` | New active build under a plan | +| `link_cases_to_plan` | Link cases (internal ids) to a plan; duplicates are skipped | +| `record_execution` | Record a verdict (`p`/`f`/`b`) for a case (by external id) in a plan+build | +| `plan_status` | Combined plan health: totals by verdict + per-build breakdown | +| `plan_queue` | Paged execution work queue for a plan+build, filterable by assignee | +| `plan_flaky` | Flakiness ranking (verdict flips) over the last N days | +| `assign_cases` | Assign/unassign cases to testers for a plan+build (userId 0 unassigns) | +| `list_users` | TestLink users for assignments and queue filters | + +Every tool returns pretty-printed JSON as text content. HTTP errors (and +non-JSON responses such as PHP fatal-error pages) are returned as `isError` +results carrying the response body text, so the calling agent can see exactly +what the backend said. + +Notes: + +- Most tools take the **internal** case id; `record_execution` takes the + **external** id (`PREFIX-number`, e.g. `P8T-10006`). +- `plan_flaky` defaults to a 7-day window. On very large plans, wide windows + (e.g. 30 days over ~10k cases) can exhaust the backend's PHP memory limit; + the resulting error page is surfaced via `isError`. + +## Usage examples + +Inside Claude Code, with the server connected: + +- "List the TestLink projects, then show me the failing cases in the queue for + Plan1 / Build2." +- "Create a suite 'Checkout' under project php8test and add a test case + 'Guest checkout works' with 3 steps." +- "Create a plan 'Sprint 42' with a build 'RC1', link the cases matching + 'login', and assign them all to admin." +- "Record a pass for P8T-10006 in plan 20126 build 3 with note 'verified + manually on Chrome 126'." +- "How healthy is plan 5? Which cases were flaky in the last week?" + +### Smoke test from a shell + +```sh +cd mcp +( printf '%s\n' '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"smoke","version":"0"}}}' + sleep 0.4 + printf '%s\n' '{"jsonrpc":"2.0","method":"notifications/initialized"}' + printf '%s\n' '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"list_projects","arguments":{}}}' + sleep 1 +) | TESTLINK_APIKEY= node dist/index.js +``` + +## The testlink-qa subagent + +`.claude/agents/testlink-qa.md` defines a project-scoped Claude Code subagent +that uses this server as a QA operator: it browses the case repository, +authors cases with steps, plans runs, records verdicts with evidence notes, +and summarizes plan health. It confirms destructive/bulk operations (notably +`update_case` step replacement and bulk link/assign/execute) and reports the +ids of everything it creates. Invoke it by asking Claude Code to use the +`testlink-qa` agent, e.g. "Use the testlink-qa agent to triage plan 5". diff --git a/mcp/package-lock.json b/mcp/package-lock.json new file mode 100644 index 0000000000..69af19bf97 --- /dev/null +++ b/mcp/package-lock.json @@ -0,0 +1,1217 @@ +{ + "name": "testlink-mcp", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "testlink-mcp", + "version": "1.0.0", + "license": "GPL-2.0", + "dependencies": { + "@modelcontextprotocol/sdk": "^1.16.0", + "zod": "^3.25.0" + }, + "bin": { + "testlink-mcp": "dist/index.js" + }, + "devDependencies": { + "@types/node": "^24.0.0", + "typescript": "^5.5.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@hono/node-server": { + "version": "1.19.14", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz", + "integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==", + "license": "MIT", + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.29.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", + "integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==", + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.19.9", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", + "json-schema-typed": "^8.0.2", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } + } + }, + "node_modules/@types/node": { + "version": "24.13.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz", + "integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/body-parser": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^2.0.0", + "debug": "^4.4.3", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", + "on-finished": "^2.4.1", + "qs": "^6.15.2", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/body-parser/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.0.tgz", + "integrity": "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.6.0.tgz", + "integrity": "sha512-XKJXDsASUOo0LLtFwW5hCcQGH0N4WQc/Rn8/Pvoia+TJFOkkFPvrtW9lZOeeNcxQJspvOIERMwiRLsVFlhHEkA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "ip-address": "^10.2.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz", + "integrity": "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hono": { + "version": "4.12.31", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.31.tgz", + "integrity": "sha512-zJIHFrl6bq3RDd2YusFNCDlM8qUprxKswyi/OPzPyzKDdyBXDqWx8bZlZ7R+saTdSTatUmb3O7K4SspGPaEOQg==", + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ip-address": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", + "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/jose": { + "version": "6.2.4", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.4.tgz", + "integrity": "sha512-N8acGzVsQy6M/fjFcxtysNc4Q379TcM5dM/qKkNtsHFji88yANnXTr7BLeP75iPnFwBfQzM/jg2BZ9+HZrHCZA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "license": "BSD-2-Clause" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/pkce-challenge": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.15.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", + "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz", + "integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "license": "MIT", + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-to-json-schema": { + "version": "3.25.2", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", + "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.25.28 || ^4" + } + } + } +} diff --git a/mcp/package.json b/mcp/package.json new file mode 100644 index 0000000000..e037973fe1 --- /dev/null +++ b/mcp/package.json @@ -0,0 +1,29 @@ +{ + "name": "testlink-mcp", + "version": "1.0.0", + "description": "MCP server exposing the TestLink REST v3 API as agent-friendly tools", + "license": "GPL-2.0", + "type": "module", + "main": "dist/index.js", + "bin": { + "testlink-mcp": "dist/index.js" + }, + "files": [ + "dist" + ], + "scripts": { + "build": "tsc", + "start": "node dist/index.js" + }, + "engines": { + "node": ">=18" + }, + "dependencies": { + "@modelcontextprotocol/sdk": "^1.16.0", + "zod": "^3.25.0" + }, + "devDependencies": { + "@types/node": "^24.0.0", + "typescript": "^5.5.0" + } +} diff --git a/mcp/src/index.ts b/mcp/src/index.ts new file mode 100644 index 0000000000..4cb70c0d8b --- /dev/null +++ b/mcp/src/index.ts @@ -0,0 +1,696 @@ +#!/usr/bin/env node +/** + * testlink-mcp — MCP server exposing the TestLink REST v3 API as agent-friendly tools. + * + * Env: + * TESTLINK_URL Base URL of the TestLink instance (default http://localhost:8090) + * TESTLINK_APIKEY API key sent as the `Apikey` header (required) + */ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { z } from "zod"; + +// --------------------------------------------------------------------------- +// Configuration (fail fast on missing API key) +// --------------------------------------------------------------------------- + +const BASE_URL = (process.env.TESTLINK_URL ?? "http://localhost:8090").replace(/\/+$/, ""); +const API_KEY = process.env.TESTLINK_APIKEY; + +if (!API_KEY) { + console.error( + "testlink-mcp: TESTLINK_APIKEY environment variable is not set.\n" + + "Set it to a TestLink API key (user profile > API interface, or the SPA login endpoint) and restart.\n" + + "Optionally set TESTLINK_URL (default http://localhost:8090)." + ); + process.exit(1); +} + +const API = `${BASE_URL}/lib/api/rest/v3`; + +// --------------------------------------------------------------------------- +// HTTP helper +// --------------------------------------------------------------------------- + +type Query = Record; + +class TestLinkError extends Error { + constructor(message: string) { + super(message); + this.name = "TestLinkError"; + } +} + +async function tl( + method: "GET" | "POST" | "PUT", + path: string, + opts: { query?: Query; body?: unknown } = {} +): Promise { + const url = new URL(API + path); + for (const [k, v] of Object.entries(opts.query ?? {})) { + if (v !== undefined && v !== "") url.searchParams.set(k, String(v)); + } + + let res: Response; + try { + res = await fetch(url, { + method, + headers: { + Apikey: API_KEY!, + ...(opts.body !== undefined ? { "Content-Type": "application/json" } : {}), + }, + body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined, + }); + } catch (e) { + throw new TestLinkError( + `Cannot reach TestLink at ${url.origin} (${e instanceof Error ? e.message : String(e)}). ` + + "Check TESTLINK_URL and that the server is running." + ); + } + + const text = await res.text(); + if (!res.ok) { + throw new TestLinkError(`HTTP ${res.status} ${res.statusText} on ${method} ${path}: ${text.slice(0, 2000)}`); + } + try { + return JSON.parse(text); + } catch { + // e.g. a PHP fatal error page returned with HTTP 200 + throw new TestLinkError(`Non-JSON response from ${method} ${path}: ${text.slice(0, 2000)}`); + } +} + +// --------------------------------------------------------------------------- +// Tool result helpers +// --------------------------------------------------------------------------- + +type ToolResult = { + content: Array<{ type: "text"; text: string }>; + isError?: boolean; +}; + +function ok(data: unknown): ToolResult { + return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] }; +} + +function fail(message: string): ToolResult { + return { isError: true, content: [{ type: "text", text: message }] }; +} + +async function run(fn: () => Promise): Promise { + try { + return ok(await fn()); + } catch (e) { + return fail(e instanceof Error ? e.message : String(e)); + } +} + +/** TestLink sometimes returns `items` as an array, sometimes as an id-keyed map. */ +function itemsAsArray(payload: any): any[] { + const items = payload?.items; + if (Array.isArray(items)) return items; + if (items && typeof items === "object") return Object.values(items); + return []; +} + +const IMPORTANCE_CODE: Record = { low: 1, medium: 2, high: 3 }; + +const STATUS_LABEL: Record = { + p: "passed", + f: "failed", + b: "blocked", + n: "not_run", +}; + +function labelStatusCounts(byStatus: Record | undefined): Record { + const out: Record = {}; + for (const [code, count] of Object.entries(byStatus ?? {})) { + out[STATUS_LABEL[code] ?? code] = count; + } + return out; +} + +// --------------------------------------------------------------------------- +// Server + tools +// --------------------------------------------------------------------------- + +const server = new McpServer({ name: "testlink-mcp", version: "1.0.0" }); + +const stepSchema = z.object({ + actions: z.string().describe("What the tester does in this step"), + expected_results: z.string().describe("What should happen if the step passes"), + execution_type: z + .union([z.literal(1), z.literal(2)]) + .optional() + .describe("1 = manual (default), 2 = automated"), +}); + +// ---- Browse ----------------------------------------------------------------- + +server.registerTool( + "list_projects", + { + title: "List test projects", + description: + "List all TestLink test projects with their id, name and test-case ID prefix. " + + "Start here to find the projectId used by most other tools.", + inputSchema: {}, + }, + async () => + run(async () => { + const projects = (await tl("GET", "/testprojects")) as any[]; + return projects.map((p) => ({ + id: Number(p.id), + name: p.name, + prefix: p.prefix, + active: p.active === "1" || p.active === 1, + testCaseCount: Number(p.tc_counter ?? 0), + })); + }) +); + +server.registerTool( + "list_plans", + { + title: "List test plans", + description: "List the test plans of a project (id, name, notes, active).", + inputSchema: { + projectId: z.number().int().describe("Test project id (from list_projects)"), + }, + }, + async ({ projectId }) => + run(async () => { + const payload = await tl("GET", `/testprojects/${projectId}/testplans`); + return itemsAsArray(payload).map((p: any) => ({ + id: Number(p.id), + name: p.name, + notes: p.notes, + active: p.active === "1" || p.active === 1, + })); + }) +); + +server.registerTool( + "list_builds", + { + title: "List builds of a test plan", + description: "List the builds of a test plan, newest first (id, name, notes, active, created).", + inputSchema: { + planId: z.number().int().describe("Test plan id (from list_plans)"), + }, + }, + async ({ planId }) => + run(async () => { + const payload = await tl("GET", `/testplans/${planId}/buildsById`); + return itemsAsArray(payload).map((b: any) => ({ + id: Number(b.id), + name: b.name, + notes: b.notes, + active: b.active === "1" || b.active === 1, + open: b.is_open === "1" || b.is_open === 1, + created: b.creation_ts, + })); + }) +); + +server.registerTool( + "list_suites", + { + title: "List test suites", + description: + "List all test suites of a project as a flat list with parent_id links (build the tree from parent_id). " + + "Each row includes tcCount, the number of test cases in the suite.", + inputSchema: { + projectId: z.number().int().describe("Test project id"), + }, + }, + async ({ projectId }) => + run(async () => { + const payload = await tl("GET", `/testprojects/${projectId}/suites`); + return itemsAsArray(payload); + }) +); + +server.registerTool( + "list_suite_cases", + { + title: "List test cases in a suite", + description: + "List the test cases directly inside one test suite (id, name, external id, importance, latest version). " + + "Use get_case for full detail on a single case.", + inputSchema: { + suiteId: z.number().int().describe("Test suite id (from list_suites)"), + }, + }, + async ({ suiteId }) => + run(async () => { + const payload = await tl("GET", `/testsuites/${suiteId}/testcases`); + return itemsAsArray(payload); + }) +); + +server.registerTool( + "search_cases", + { + title: "Search test cases", + description: + "Full-text search for test cases by name within a project. Returns matching cases with their " + + "internal id (tcase_id), suite name and external id.", + inputSchema: { + projectId: z.number().int().describe("Test project id"), + query: z.string().min(1).describe("Search text, matched against test case names"), + }, + }, + async ({ projectId, query }) => + run(async () => { + const payload = await tl("GET", `/testprojects/${projectId}/search`, { query: { q: query } }); + return itemsAsArray(payload); + }) +); + +server.registerTool( + "get_case", + { + title: "Get test case detail", + description: + "Full detail of one test case: summary, preconditions, steps, and its most recent executions " + + "(verdict, build, tester, notes, attachments). Takes the internal case id (tcase_id), not the external 'PFX-123' id.", + inputSchema: { + caseId: z.number().int().describe("Internal test case id (e.g. tcase_id from search_cases / list_suite_cases)"), + maxExecutions: z + .number() + .int() + .min(0) + .max(100) + .optional() + .describe("How many recent executions to include (default 10)"), + }, + }, + async ({ caseId, maxExecutions }) => + run(async () => { + const payload = await tl("GET", `/testcases/${caseId}/detail`); + const item = payload?.item ?? payload; + const executions = Array.isArray(item?.executions) ? item.executions : []; + const limit = maxExecutions ?? 10; + return { + ...item, + totalExecutions: executions.length, + executions: executions.slice(0, limit).map((e: any) => ({ + ...e, + statusLabel: STATUS_LABEL[e.status] ?? e.status, + })), + }; + }) +); + +// ---- Author ----------------------------------------------------------------- + +server.registerTool( + "create_suite", + { + title: "Create a test suite", + description: + "Create a new test suite in a project. Omit parentSuiteId to create it at the top level; " + + "pass an existing suite id to nest it. Returns the new suite id.", + inputSchema: { + projectId: z.number().int().describe("Test project id"), + name: z.string().min(1).describe("Suite name"), + parentSuiteId: z + .number() + .int() + .optional() + .describe("Parent suite id; omit for a top-level suite"), + notes: z.string().optional().describe("Optional description of the suite"), + order: z.number().int().optional().describe("Sort order within the parent (default 0)"), + }, + }, + async ({ projectId, name, parentSuiteId, notes, order }) => + run(async () => { + const payload = await tl("POST", "/testsuites", { + body: { + name, + testProjectID: projectId, + parentID: parentSuiteId ?? projectId, + notes: notes ?? "", + order: order ?? 0, + }, + }); + return { id: Number(payload.id), name, projectId, parentSuiteId: parentSuiteId ?? null }; + }) +); + +server.registerTool( + "create_case", + { + title: "Create a test case", + description: + "Create a new test case in a suite, optionally with its steps in the same call " + + "(steps are applied via a follow-up update). Returns the new case id; fetch get_case for the " + + "assigned external id and version.", + inputSchema: { + projectId: z.number().int().describe("Test project id"), + suiteId: z.number().int().describe("Test suite the case goes into"), + name: z.string().min(1).describe("Test case title"), + summary: z.string().optional().describe("What the case verifies (HTML or plain text)"), + preconditions: z.string().optional().describe("Required state before executing"), + importance: z.enum(["low", "medium", "high"]).optional().describe("Priority (default medium)"), + executionType: z.enum(["manual", "automatic"]).optional().describe("Default manual"), + authorLogin: z.string().optional().describe("TestLink login of the author (default 'admin')"), + order: z.number().int().optional().describe("Sort order within the suite"), + steps: z + .array(stepSchema) + .optional() + .describe("Ordered execution steps; if given, they are set right after creation"), + }, + }, + async ({ projectId, suiteId, name, summary, preconditions, importance, executionType, authorLogin, order, steps }) => + run(async () => { + const created = await tl("POST", "/testcases", { + body: { + name, + testSuite: { id: suiteId }, + testProject: { id: projectId }, + summary: summary ?? "", + preconditions: preconditions ?? "", + order: order ?? 0, + authorLogin: authorLogin ?? "admin", + importance: { name: importance ?? "medium" }, + executionType: { name: executionType ?? "manual" }, + }, + }); + const id = Number(created.id); + let stepsSet = 0; + if (steps && steps.length > 0) { + const upd = await tl("PUT", `/testcases/${id}/update`, { + body: { + steps: steps.map((s) => ({ + actions: s.actions, + expected_results: s.expected_results, + execution_type: s.execution_type ?? 1, + })), + }, + }); + stepsSet = Number(upd.steps ?? steps.length); + } + return { id, name, suiteId, stepsSet }; + }) +); + +server.registerTool( + "update_case", + { + title: "Update a test case", + description: + "Update fields of an existing test case. Only the fields you pass are changed. " + + "WARNING: passing `steps` REPLACES the entire step list — include every step you want to keep " + + "(read the current steps with get_case first).", + inputSchema: { + caseId: z.number().int().describe("Internal test case id"), + name: z.string().optional().describe("New title"), + summary: z.string().optional().describe("New summary"), + preconditions: z.string().optional().describe("New preconditions"), + importance: z.enum(["low", "medium", "high"]).optional().describe("New priority"), + steps: z + .array(stepSchema) + .optional() + .describe("FULL replacement step list (replaces all existing steps)"), + }, + }, + async ({ caseId, name, summary, preconditions, importance, steps }) => + run(async () => { + const body: Record = {}; + if (name !== undefined) body.name = name; + if (summary !== undefined) body.summary = summary; + if (preconditions !== undefined) body.preconditions = preconditions; + if (importance !== undefined) body.importance = IMPORTANCE_CODE[importance]; + if (steps !== undefined) { + body.steps = steps.map((s) => ({ + actions: s.actions, + expected_results: s.expected_results, + execution_type: s.execution_type ?? 1, + })); + } + if (Object.keys(body).length === 0) { + throw new TestLinkError("Nothing to update: pass at least one of name/summary/preconditions/importance/steps."); + } + return await tl("PUT", `/testcases/${caseId}/update`, { body }); + }) +); + +// ---- Plan runs -------------------------------------------------------------- + +server.registerTool( + "create_plan", + { + title: "Create a test plan", + description: "Create a new active, public test plan in a project. Returns the new plan id.", + inputSchema: { + projectId: z.number().int().describe("Test project id"), + name: z.string().min(1).describe("Plan name"), + notes: z.string().optional().describe("Optional plan description"), + }, + }, + async ({ projectId, name, notes }) => + run(async () => { + const payload = await tl("POST", "/testplans", { + body: { name, testProjectID: projectId, notes: notes ?? "", active: 1, is_public: 1 }, + }); + return { id: Number(payload.id), name, projectId }; + }) +); + +server.registerTool( + "create_build", + { + title: "Create a build", + description: + "Create a new active build under a test plan. Executions are recorded against a build. Returns the build id.", + inputSchema: { + planId: z.number().int().describe("Test plan id"), + name: z.string().min(1).describe("Build name, e.g. a version or CI run id"), + notes: z.string().optional().describe("Optional build notes"), + }, + }, + async ({ planId, name, notes }) => + run(async () => { + const payload = await tl("POST", "/builds", { + body: { name, testplan: planId, notes: notes ?? "", active: 1, is_public: 1 }, + }); + return { id: Number(payload.id), name, planId }; + }) +); + +server.registerTool( + "link_cases_to_plan", + { + title: "Link test cases to a plan", + description: + "Add test cases (by internal case id) to a test plan so they can be executed. " + + "Already-linked cases are skipped, not duplicated. Returns linked/skipped counts.", + inputSchema: { + planId: z.number().int().describe("Test plan id"), + caseIds: z.array(z.number().int()).min(1).describe("Internal test case ids to link"), + }, + }, + async ({ planId, caseIds }) => + run(async () => await tl("POST", `/testplans/${planId}/link`, { body: { tcaseIDs: caseIds } })) +); + +server.registerTool( + "record_execution", + { + title: "Record a test execution", + description: + "Record a verdict for a test case in a plan+build. The case is addressed by its EXTERNAL id " + + "(e.g. 'P8T-123' — project prefix, dash, number; see tc_external_id from search/list tools). " + + "Verdicts: p = pass, f = fail, b = blocked. Include evidence in notes (what was observed, logs, env).", + inputSchema: { + planId: z.number().int().describe("Test plan id"), + buildId: z.number().int().describe("Build id (from list_builds / create_build)"), + caseExternalId: z + .string() + .min(1) + .describe("External test case id including project prefix, e.g. 'P8T-10006'"), + verdict: z.enum(["p", "f", "b"]).describe("p = pass, f = fail, b = blocked"), + notes: z.string().optional().describe("Evidence / observations for this run"), + }, + }, + async ({ planId, buildId, caseExternalId, verdict, notes }) => + run(async () => { + const payload = await tl("POST", "/executions", { + body: { + testPlanID: planId, + buildID: buildId, + platformID: 0, + testCaseExternalID: caseExternalId, + statusCode: verdict, + notes: notes ?? "", + executionType: "1", + }, + }); + return { + executionId: Number(payload.id), + caseExternalId, + verdict: STATUS_LABEL[verdict] ?? verdict, + planId, + buildId, + }; + }) +); + +// ---- Reporting -------------------------------------------------------------- + +server.registerTool( + "plan_status", + { + title: "Test plan status overview", + description: + "One readable health summary for a test plan: total linked cases, verdict totals " + + "(passed/failed/blocked/not_run) and a per-build breakdown.", + inputSchema: { + planId: z.number().int().describe("Test plan id"), + }, + }, + async ({ planId }) => + run(async () => { + const [summary, byBuild] = await Promise.all([ + tl("GET", `/testplans/${planId}/summary`), + tl("GET", `/testplans/${planId}/byBuild`), + ]); + return { + planId, + name: summary.name, + linkedCases: summary.linked, + totals: labelStatusCounts(summary.byStatus), + byBuild: itemsAsArray(byBuild).map((b: any) => ({ + buildId: Number(b.build_id), + name: b.name, + linked: b.linked, + passed: b.p ?? 0, + failed: b.f ?? 0, + blocked: b.b ?? 0, + other: b.other ?? 0, + })), + }; + }) +); + +server.registerTool( + "plan_queue", + { + title: "Execution work queue", + description: + "Paged list of test cases to execute in a plan for a given build, with current status and assignee. " + + "Use assignedTo (a TestLink user id) to see one tester's queue.", + inputSchema: { + planId: z.number().int().describe("Test plan id"), + buildId: z.number().int().describe("Build id"), + page: z.number().int().min(1).optional().describe("Page number (default 1)"), + limit: z.number().int().min(1).max(200).optional().describe("Rows per page (default 20)"), + assignedTo: z.number().int().optional().describe("Filter by assignee user id (see list_users)"), + }, + }, + async ({ planId, buildId, page, limit, assignedTo }) => + run(async () => { + const payload = await tl("GET", `/testplans/${planId}/queue`, { + query: { buildID: buildId, page: page ?? 1, limit: limit ?? 20, assignedTo }, + }); + return { + total: payload.total, + page: payload.page, + limit: payload.limit, + items: itemsAsArray(payload).map((r: any) => ({ + ...r, + exec_status_label: STATUS_LABEL[r.exec_status] ?? r.exec_status, + })), + }; + }) +); + +server.registerTool( + "plan_flaky", + { + title: "Flaky test ranking", + description: + "Rank test cases in a plan by verdict flips (pass<->fail transitions) over the last N days — " + + "the top entries are the flakiest. Keep `days` small (default 7) on very large plans: wide windows " + + "can exhaust server memory.", + inputSchema: { + planId: z.number().int().describe("Test plan id"), + days: z.number().int().min(1).max(365).optional().describe("Lookback window in days (default 7)"), + }, + }, + async ({ planId, days }) => + run(async () => { + const payload = await tl("GET", `/testplans/${planId}/flaky`, { query: { days: days ?? 7 } }); + return { analyzed: payload.analyzed, days: days ?? 7, items: itemsAsArray(payload) }; + }) +); + +// ---- People ----------------------------------------------------------------- + +server.registerTool( + "assign_cases", + { + title: "Assign test cases to testers", + description: + "Assign (or unassign) linked test cases to testers for a plan+build. Each assignment pairs an " + + "internal case id with a user id from list_users; userId 0 removes the assignment. " + + "Returns assigned/removed/skipped counts.", + inputSchema: { + planId: z.number().int().describe("Test plan id"), + buildId: z.number().int().describe("Build id"), + assignments: z + .array( + z.object({ + caseId: z.number().int().describe("Internal test case id"), + userId: z.number().int().describe("TestLink user id; 0 unassigns"), + }) + ) + .min(1) + .describe("Case/user pairs to apply"), + }, + }, + async ({ planId, buildId, assignments }) => + run(async () => + tl("POST", `/testplans/${planId}/assign`, { + body: { + buildID: buildId, + items: assignments.map((a) => ({ tcaseID: a.caseId, userID: a.userId })), + }, + }) + ) +); + +server.registerTool( + "list_users", + { + title: "List TestLink users", + description: "List TestLink users (id, login, first/last name) for assignments and queue filters.", + inputSchema: {}, + }, + async () => + run(async () => { + const payload = await tl("GET", "/users"); + return itemsAsArray(payload).map((u: any) => ({ + id: Number(u.id), + login: u.login, + name: [u.first, u.last].filter(Boolean).join(" "), + })); + }) +); + +// --------------------------------------------------------------------------- +// Start +// --------------------------------------------------------------------------- + +async function main() { + const transport = new StdioServerTransport(); + await server.connect(transport); + console.error(`testlink-mcp: connected (backend ${API})`); +} + +main().catch((e) => { + console.error("testlink-mcp: fatal:", e); + process.exit(1); +}); diff --git a/mcp/tsconfig.json b/mcp/tsconfig.json new file mode 100644 index 0000000000..942ce06a35 --- /dev/null +++ b/mcp/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "Node16", + "moduleResolution": "Node16", + "lib": ["ES2022"], + "rootDir": "src", + "outDir": "dist", + "strict": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true, + "declaration": false, + "sourceMap": false + }, + "include": ["src"] +} diff --git a/ui/.gitignore b/ui/.gitignore new file mode 100644 index 0000000000..a547bf36d8 --- /dev/null +++ b/ui/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/ui/.oxlintrc.json b/ui/.oxlintrc.json new file mode 100644 index 0000000000..6fa991dad2 --- /dev/null +++ b/ui/.oxlintrc.json @@ -0,0 +1,8 @@ +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "plugins": ["react", "typescript", "oxc"], + "rules": { + "react/rules-of-hooks": "error", + "react/only-export-components": ["warn", { "allowConstantExport": true }] + } +} diff --git a/ui/README.md b/ui/README.md new file mode 100644 index 0000000000..d6af7e39ff --- /dev/null +++ b/ui/README.md @@ -0,0 +1,32 @@ +# React + TypeScript + Vite + +This template provides a minimal setup to get React working in Vite with HMR and some Oxlint rules. + +Currently, two official plugins are available: + +- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs) +- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) + +## React Compiler + +The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation). + +## Expanding the Oxlint configuration + +If you are developing a production application, we recommend enabling type-aware lint rules by installing `oxlint-tsgolint` and editing `.oxlintrc.json`: + +```json +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "plugins": ["react", "typescript", "oxc"], + "options": { + "typeAware": true + }, + "rules": { + "react/rules-of-hooks": "error", + "react/only-export-components": ["warn", { "allowConstantExport": true }] + } +} +``` + +See the [Oxlint rules documentation](https://oxc.rs/docs/guide/usage/linter/rules) for the full list of rules and categories. diff --git a/ui/index.html b/ui/index.html new file mode 100644 index 0000000000..e2baffe303 --- /dev/null +++ b/ui/index.html @@ -0,0 +1,12 @@ + + + + + + TestLink + + +
+ + + diff --git a/ui/package-lock.json b/ui/package-lock.json new file mode 100644 index 0000000000..cac13e0e48 --- /dev/null +++ b/ui/package-lock.json @@ -0,0 +1,2202 @@ +{ + "name": "ui", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "ui", + "version": "0.0.0", + "dependencies": { + "@fontsource-variable/inter": "^5.3.0", + "@fontsource-variable/space-grotesk": "^5.3.0", + "@fontsource/ibm-plex-mono": "^5.3.0", + "@tailwindcss/vite": "^4.3.3", + "@tanstack/react-query": "^5.101.4", + "@tanstack/react-router": "^1.170.18", + "lucide-react": "^1.25.0", + "react": "^19.2.7", + "react-dom": "^19.2.7", + "tailwindcss": "^4.3.3" + }, + "devDependencies": { + "@types/node": "^24.13.2", + "@types/react": "^19.2.17", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.3", + "oxlint": "^1.71.0", + "typescript": "~6.0.2", + "vite": "^8.1.1" + } + }, + "node_modules/@emnapi/core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@fontsource-variable/inter": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource-variable/inter/-/inter-5.3.0.tgz", + "integrity": "sha512-OupL48va4JNofb97w6NYeF9S7W/kHNKM0Er8Dem5nqi4jeOLrVJDoE8tZEpnMJmtkvNbB1EIPPwHcdkF6b1oUA==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@fontsource-variable/space-grotesk": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource-variable/space-grotesk/-/space-grotesk-5.3.0.tgz", + "integrity": "sha512-2IxmvfB08i9vnGB3Ym/AXvhRE+8XOjWMXIyDum03c+tPwH0FUoMNQfGpU8NXPxjbws0Vvss3AH0Zqt4oJBBAdw==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@fontsource/ibm-plex-mono": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource/ibm-plex-mono/-/ibm-plex-mono-5.3.0.tgz", + "integrity": "sha512-eTgnZjZEGk1QtD3ZstF+Vclo2HLAni8YMy34/DxllwZvyz1lR/1RF/xTiAquOBO7MvqBx8D2Ig2WCPMVfdZu7Q==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", + "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.3" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.139.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz", + "integrity": "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@oxlint/binding-android-arm-eabi": { + "version": "1.75.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.75.0.tgz", + "integrity": "sha512-lutovtFzJqlRaqpZrCqSSGaHZzl9nIxxpjLzhSRLunN6dCLylj0uzlCyQGaQDIys7rrv8kVXiFO+R4Zpn0bX7g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-android-arm64": { + "version": "1.75.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.75.0.tgz", + "integrity": "sha512-hXI0hDgHkw4w5nfru72aG7y+2iQJmC4waH/KV6H/hbgA6yAP5jYNx0P9yug15Hs0tWl/+mda3Jjn/2gmDT48tw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-darwin-arm64": { + "version": "1.75.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.75.0.tgz", + "integrity": "sha512-D91BWbK/dMYfCcrghspPIuKs2D9LF4Z/OabVSQjw1AO6PWxArD7teDA48bm0ySFqWDaPVqmQRl5GMWNglTXyrQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-darwin-x64": { + "version": "1.75.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.75.0.tgz", + "integrity": "sha512-02mpwzf12BonZ6PT0TuQoomvEh2kVl2WGBIKWezCyToIS+rYkQZ6GXnARBAl9A4Ovm2V+Xe7M4KretyqmmcnJQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-freebsd-x64": { + "version": "1.75.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.75.0.tgz", + "integrity": "sha512-qZJgLnDaBsiL5YESx2t/TZ8eXkL9fEkKoXEdzegROhlz9A0lgyGnZ0dAzJrh7LJAHQl2K9RdRueN2s/9N7+odg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm-gnueabihf": { + "version": "1.75.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.75.0.tgz", + "integrity": "sha512-7XlaWA5BJD3XpCfrEqjEe6Zseeb14S7QGa304XfwKignRaKQ+eIj775BQ7nIslggWickl4IsPUFqJ+/gAyNHVg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm-musleabihf": { + "version": "1.75.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.75.0.tgz", + "integrity": "sha512-av6Tpv8yrcMMMOadOqENBhlsLRcGFXXwoQ0hzHhsmS9FJ4Wioy8we427GbcMe2XTxmL2e60T67H1Dyr3up+tAA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm64-gnu": { + "version": "1.75.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.75.0.tgz", + "integrity": "sha512-WcUhd8fHT5plrA14lANevl+hOl815mVI5t2hU21oFWrZKFXIVV/Sr4rWQV0NzSvzBupbMLNc5ErEA6Ehxh5jMg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm64-musl": { + "version": "1.75.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.75.0.tgz", + "integrity": "sha512-UWzp5wRHFe/ESO3+eEaxXsTkYTGLYjnTsi/I5neEacXSItQ6WNleapfOAeA4x2b8nyhJ4uQxqvtv9pHv8kWJtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-ppc64-gnu": { + "version": "1.75.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.75.0.tgz", + "integrity": "sha512-XEVRwGMLKCUKrvhLAz4F6AIh8MJrQVdSZtAmPpRZt9tGPsUnamPOcl3dS/ZQzJnar/Ymgc//+xho0L60Emzuxg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-riscv64-gnu": { + "version": "1.75.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.75.0.tgz", + "integrity": "sha512-mAG4DUXqfLC8cTjMD2kt3jDmVzFREYtDyeLNdLdsCcBc4Zbl2EMuiFektGBilQwkNjYnMvCqJs55U+Hyb+b+jw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-riscv64-musl": { + "version": "1.75.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.75.0.tgz", + "integrity": "sha512-95hrAvriAlI+pekSomTFIn0+bawMDlDwTNVmdjsFusTHyL2JWh7TWvRNG/Lkim72uN8OiCcO9wcaC6omLP5E3w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-s390x-gnu": { + "version": "1.75.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.75.0.tgz", + "integrity": "sha512-4b6f2+FrtruAESrCqIKcrarzfrSx+wk2QNcp+RT91/Prc+pMQMAfyZ1rG1c3tFQNl8Bc616tx40uNXyxNBRPbQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-x64-gnu": { + "version": "1.75.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.75.0.tgz", + "integrity": "sha512-nshAhrUvXFUWOvqQ2soIw7HFNWvpvEV4o0cYSqPtzLiPF5gKyYTDOOTJ6Rn8g8K/iGvPIrbDA4v8+5MvnjJrrg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-x64-musl": { + "version": "1.75.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.75.0.tgz", + "integrity": "sha512-e4jNxLKnxLC6sYBQRxrI2pgIIxnmMtF8U/VwNYcjTT/CLS+spH624cYVnj07bTKwaEWT37/e025isOs6j/0xqA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-openharmony-arm64": { + "version": "1.75.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.75.0.tgz", + "integrity": "sha512-hZ2lH+1qLf/DiEP9UWuQTK2JWj/BgvMB4jhIV4SmNU1wfEiYYX4TynQyAZXx0j9X4qRYizAL042SKaV+8ynh4w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-arm64-msvc": { + "version": "1.75.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.75.0.tgz", + "integrity": "sha512-Ilj6PNzGDS3bCU0MSJH7Msh0NhH+T/mRp2shwg+q+GHeVlPwP5LEboW96aW+3kVKFk6zYZy1Xi5pZkqZh6X8KQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-ia32-msvc": { + "version": "1.75.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.75.0.tgz", + "integrity": "sha512-QVit2nOEOiPhkmsrksPSkoGCdnZRNkspt8fwoYyP09te1VEbnSj4LAxua4rc8FKTmWkySVe05j8iz9GXYfF1AQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-x64-msvc": { + "version": "1.75.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.75.0.tgz", + "integrity": "sha512-DSxnNkBUAYARPwJtR12Ig3deWr8w0H997xP6jy33i+e0SyYJw8FKuz4+cZtpmPEhQmvlPJE3X/2vNxDmLkd/rA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz", + "integrity": "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.5.tgz", + "integrity": "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.5.tgz", + "integrity": "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.5.tgz", + "integrity": "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.5.tgz", + "integrity": "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.5.tgz", + "integrity": "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.5.tgz", + "integrity": "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.5.tgz", + "integrity": "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==", + "cpu": [ + "ppc64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.5.tgz", + "integrity": "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==", + "cpu": [ + "s390x" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.5.tgz", + "integrity": "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.5.tgz", + "integrity": "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.5.tgz", + "integrity": "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.5.tgz", + "integrity": "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==", + "cpu": [ + "wasm32" + ], + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "^1.1.6" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.5.tgz", + "integrity": "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.5.tgz", + "integrity": "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "license": "MIT" + }, + "node_modules/@tailwindcss/node": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz", + "integrity": "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==", + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.24.1", + "jiti": "^2.7.0", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.3.3" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.3.tgz", + "integrity": "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==", + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-x64": "4.3.3", + "@tailwindcss/oxide-freebsd-x64": "4.3.3", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.3", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-x64-musl": "4.3.3", + "@tailwindcss/oxide-wasm32-wasi": "4.3.3", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.3", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.3" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.3.tgz", + "integrity": "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.3.tgz", + "integrity": "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.3.tgz", + "integrity": "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.3.tgz", + "integrity": "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.3.tgz", + "integrity": "sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.3.tgz", + "integrity": "sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.3.tgz", + "integrity": "sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.3.tgz", + "integrity": "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.3.tgz", + "integrity": "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.3.tgz", + "integrity": "sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.11.1", + "@emnapi/runtime": "^1.11.1", + "@emnapi/wasi-threads": "^1.2.2", + "@napi-rs/wasm-runtime": "^1.1.4", + "@tybys/wasm-util": "^0.10.2", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz", + "integrity": "sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.3.tgz", + "integrity": "sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.3.tgz", + "integrity": "sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw==", + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.3.3", + "@tailwindcss/oxide": "4.3.3", + "tailwindcss": "4.3.3" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7 || ^8" + } + }, + "node_modules/@tanstack/history": { + "version": "1.162.0", + "resolved": "https://registry.npmjs.org/@tanstack/history/-/history-1.162.0.tgz", + "integrity": "sha512-79pf/RkhteYZTRgcR4F9kbk84P2N8rugQJswxfIqovlbRiT3yI7eBE+5QorIrZaOKktsgzRlXh1l/du/xpl4iA==", + "license": "MIT", + "engines": { + "node": ">=20.19" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/query-core": { + "version": "5.101.4", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.101.4.tgz", + "integrity": "sha512-gNwcvOJcRbLWPOLG/2OBm+zM+Yv+MKsXKEOWC57USuZDEsI71hEErQsiEGx5wX9rzWWkfwM0fVSPoiIFSsxfiw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.101.4", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.101.4.tgz", + "integrity": "sha512-yRg2pfOCxIs4ZJW3XYYHU/WgtD04FHSnfHlpRT7h7pR77hwkdRG4wxbKe4aq6P0RvXUTBSQpQeadS1SUYUe+KA==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.101.4" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, + "node_modules/@tanstack/react-router": { + "version": "1.170.18", + "resolved": "https://registry.npmjs.org/@tanstack/react-router/-/react-router-1.170.18.tgz", + "integrity": "sha512-wpbGYZEp/fmz1q4bn7BD8VZ+/VZ7GBqSJv5V969pU+chP8y7dquWDmKTFMohvUegb9lg12m1uPVvD6kB2wORvQ==", + "license": "MIT", + "dependencies": { + "@tanstack/history": "1.162.0", + "@tanstack/react-store": "^0.9.3", + "@tanstack/router-core": "1.171.15", + "isbot": "^5.1.22" + }, + "engines": { + "node": ">=20.19" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": ">=18.0.0 || >=19.0.0", + "react-dom": ">=18.0.0 || >=19.0.0" + } + }, + "node_modules/@tanstack/react-store": { + "version": "0.9.3", + "resolved": "https://registry.npmjs.org/@tanstack/react-store/-/react-store-0.9.3.tgz", + "integrity": "sha512-y2iHd/N9OkoQbFJLUX1T9vbc2O9tjH0pQRgTcx1/Nz4IlwLvkgpuglXUx+mXt0g5ZDFrEeDnONPqkbfxXJKwRg==", + "license": "MIT", + "dependencies": { + "@tanstack/store": "0.9.3", + "use-sync-external-store": "^1.6.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@tanstack/router-core": { + "version": "1.171.15", + "resolved": "https://registry.npmjs.org/@tanstack/router-core/-/router-core-1.171.15.tgz", + "integrity": "sha512-IILCDcLaItMZQ2jEmCABHY1Nhjjn5XUvwpQp3e4Nmu+vfg0BgYFuu/QASz2SwE2ZNbVMrvt8X/wxa+Gg5aErxA==", + "license": "MIT", + "dependencies": { + "@tanstack/history": "1.162.0", + "cookie-es": "^3.0.0", + "seroval": "^1.5.4", + "seroval-plugins": "^1.5.4" + }, + "engines": { + "node": ">=20.19" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/store": { + "version": "0.9.3", + "resolved": "https://registry.npmjs.org/@tanstack/store/-/store-0.9.3.tgz", + "integrity": "sha512-8reSzl/qGWGGVKhBoxXPMWzATSbZLZFWhwBAFO9NAyp0TxzfBP0mIrGb8CP8KrQTmvzXlR/vFPPUrHTLBGyFyw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/node": { + "version": "24.13.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz", + "integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.17", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", + "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.3.tgz", + "integrity": "sha512-vmFvco5/QuC2f9Oj+wTk0+9XeDFkHxSamwZKYc7MxYwKICfvUvlMhqKI0VuICPltGqh1neqBKDvO4kes1ya8vg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + } + } + }, + "node_modules/cookie-es": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/cookie-es/-/cookie-es-3.1.1.tgz", + "integrity": "sha512-UaXxwISYJPTr9hwQxMFYZ7kNhSXboMXP+Z3TRX6f1/NyaGPfuNUZOWP1pUEb75B2HjfklIYLVRfWiFZJyC6Npg==", + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/enhanced-resolve": { + "version": "5.24.3", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.3.tgz", + "integrity": "sha512-PwKooW9JUzh5chmYfHM3IQl5OkK2u2Nm011MgeZrss3JmFraUx/fqrf78kk8GUMYoibx/14MdwTl/1WKkG7TpQ==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, + "node_modules/isbot": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/isbot/-/isbot-5.2.1.tgz", + "integrity": "sha512-dJ+LpKyClQZ7NG+j3OensC/mAZkGpukE9YUrgPYvAZj2doVL0edfDgywTUh5CXa0o+nW9a1V9e5+CJTX8+SxRw==", + "license": "Unlicense", + "engines": { + "node": ">=18" + } + }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lucide-react": { + "version": "1.25.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.25.0.tgz", + "integrity": "sha512-/mdJTRbiwcLOQ1NZZK1amZF9rIZyvO18D6r9TngE6TG1NmqHgFuT4eE7Xrkm9UsXMbBJD1NlfwHVltCDWHrOTw==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/nanoid": { + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/oxlint": { + "version": "1.75.0", + "resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.75.0.tgz", + "integrity": "sha512-m9WzjRcRYA/uqIZDa9tclrieoPJ/ln1QYTKdFx6NUOs8uY5DiHlIwRQoCrHT6OM6O3ww3l2skY5gO7G7ZphE7g==", + "dev": true, + "license": "MIT", + "bin": { + "oxlint": "bin/oxlint" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/sponsors/Boshen" + }, + "optionalDependencies": { + "@oxlint/binding-android-arm-eabi": "1.75.0", + "@oxlint/binding-android-arm64": "1.75.0", + "@oxlint/binding-darwin-arm64": "1.75.0", + "@oxlint/binding-darwin-x64": "1.75.0", + "@oxlint/binding-freebsd-x64": "1.75.0", + "@oxlint/binding-linux-arm-gnueabihf": "1.75.0", + "@oxlint/binding-linux-arm-musleabihf": "1.75.0", + "@oxlint/binding-linux-arm64-gnu": "1.75.0", + "@oxlint/binding-linux-arm64-musl": "1.75.0", + "@oxlint/binding-linux-ppc64-gnu": "1.75.0", + "@oxlint/binding-linux-riscv64-gnu": "1.75.0", + "@oxlint/binding-linux-riscv64-musl": "1.75.0", + "@oxlint/binding-linux-s390x-gnu": "1.75.0", + "@oxlint/binding-linux-x64-gnu": "1.75.0", + "@oxlint/binding-linux-x64-musl": "1.75.0", + "@oxlint/binding-openharmony-arm64": "1.75.0", + "@oxlint/binding-win32-arm64-msvc": "1.75.0", + "@oxlint/binding-win32-ia32-msvc": "1.75.0", + "@oxlint/binding-win32-x64-msvc": "1.75.0" + }, + "peerDependencies": { + "oxlint-tsgolint": ">=7.0.2001", + "vite-plus": "*" + }, + "peerDependenciesMeta": { + "oxlint-tsgolint": { + "optional": true + }, + "vite-plus": { + "optional": true + } + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.21", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.21.tgz", + "integrity": "sha512-v4sDNP3fdNiWMfabO7OwOQdOX8TiQSztKyT1Wj0w+j7LDallJThJRBBBmzVGyYj0crMh7jlV4zepPkiNu9UwDQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.16", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", + "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz", + "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.8" + } + }, + "node_modules/rolldown": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz", + "integrity": "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==", + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.139.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.1.5", + "@rolldown/binding-darwin-arm64": "1.1.5", + "@rolldown/binding-darwin-x64": "1.1.5", + "@rolldown/binding-freebsd-x64": "1.1.5", + "@rolldown/binding-linux-arm-gnueabihf": "1.1.5", + "@rolldown/binding-linux-arm64-gnu": "1.1.5", + "@rolldown/binding-linux-arm64-musl": "1.1.5", + "@rolldown/binding-linux-ppc64-gnu": "1.1.5", + "@rolldown/binding-linux-s390x-gnu": "1.1.5", + "@rolldown/binding-linux-x64-gnu": "1.1.5", + "@rolldown/binding-linux-x64-musl": "1.1.5", + "@rolldown/binding-openharmony-arm64": "1.1.5", + "@rolldown/binding-wasm32-wasi": "1.1.5", + "@rolldown/binding-win32-arm64-msvc": "1.1.5", + "@rolldown/binding-win32-x64-msvc": "1.1.5" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/seroval": { + "version": "1.5.6", + "resolved": "https://registry.npmjs.org/seroval/-/seroval-1.5.6.tgz", + "integrity": "sha512-rVQVWjjSvlINzaQPZH5JFqsqEsIWdTxY3iJZCnTL/5gQbXIRooVZKI60tVCkOVfzcRPejboxO2t0P89dg5mQaA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/seroval-plugins": { + "version": "1.5.6", + "resolved": "https://registry.npmjs.org/seroval-plugins/-/seroval-plugins-1.5.6.tgz", + "integrity": "sha512-HXuLAX2pu/UByPpaeo/TaMfvMIi+1QqIoPJYCcAtU8QkVNwgR6MPlGuCQTErV1JwraaMbYaWVIBX7mppzGLATQ==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "seroval": "^1.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tailwindcss": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz", + "integrity": "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==", + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD", + "optional": true + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/vite": { + "version": "8.1.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.5.tgz", + "integrity": "sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw==", + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.17", + "rolldown": "~1.1.5", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.3.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + } + } +} diff --git a/ui/package.json b/ui/package.json new file mode 100644 index 0000000000..1b5b1e90ed --- /dev/null +++ b/ui/package.json @@ -0,0 +1,33 @@ +{ + "name": "ui", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "lint": "oxlint", + "preview": "vite preview" + }, + "dependencies": { + "@fontsource-variable/inter": "^5.3.0", + "@fontsource-variable/space-grotesk": "^5.3.0", + "@fontsource/ibm-plex-mono": "^5.3.0", + "@tailwindcss/vite": "^4.3.3", + "@tanstack/react-query": "^5.101.4", + "@tanstack/react-router": "^1.170.18", + "lucide-react": "^1.25.0", + "react": "^19.2.7", + "react-dom": "^19.2.7", + "tailwindcss": "^4.3.3" + }, + "devDependencies": { + "@types/node": "^24.13.2", + "@types/react": "^19.2.17", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.3", + "oxlint": "^1.71.0", + "typescript": "~6.0.2", + "vite": "^8.1.1" + } +} diff --git a/ui/public/favicon.svg b/ui/public/favicon.svg new file mode 100644 index 0000000000..6893eb1323 --- /dev/null +++ b/ui/public/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/ui/public/icons.svg b/ui/public/icons.svg new file mode 100644 index 0000000000..e9522193d9 --- /dev/null +++ b/ui/public/icons.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ui/src/assets/hero.png b/ui/src/assets/hero.png new file mode 100644 index 0000000000..02251f4b95 Binary files /dev/null and b/ui/src/assets/hero.png differ diff --git a/ui/src/assets/react.svg b/ui/src/assets/react.svg new file mode 100644 index 0000000000..6c87de9bb3 --- /dev/null +++ b/ui/src/assets/react.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/ui/src/assets/vite.svg b/ui/src/assets/vite.svg new file mode 100644 index 0000000000..5101b674df --- /dev/null +++ b/ui/src/assets/vite.svg @@ -0,0 +1 @@ +Vite diff --git a/ui/src/components/CaseEditor.tsx b/ui/src/components/CaseEditor.tsx new file mode 100644 index 0000000000..9d8304b7a0 --- /dev/null +++ b/ui/src/components/CaseEditor.tsx @@ -0,0 +1,148 @@ +import { useState } from 'react' +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { Plus, Trash2 } from 'lucide-react' +import { api, type CaseDetail } from '../lib/api' +import { useT } from '../lib/i18n' +import { Button, Panel, TextInput } from './ui' + +interface EditableStep { + actions: string + expected_results: string +} + +export function CaseEditor({ + caseId, + detail, + onDone, +}: { + caseId: string + detail: CaseDetail + onDone: () => void +}) { + const qc = useQueryClient() + const { t } = useT() + const [name, setName] = useState(detail.name) + const [summary, setSummary] = useState(detail.summary) + const [preconditions, setPreconditions] = useState(detail.preconditions) + const [steps, setSteps] = useState( + detail.steps.map((s) => ({ + actions: s.actions, + expected_results: s.expected_results, + })), + ) + + const save = useMutation({ + mutationFn: () => + api.updateCase(caseId, { + name, + summary, + preconditions, + steps: steps + .filter((s) => s.actions.trim() || s.expected_results.trim()) + .map((s) => ({ ...s, execution_type: 1 })), + }), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['case', caseId] }) + qc.invalidateQueries({ queryKey: ['suiteCases'] }) + onDone() + }, + }) + + const setStep = (i: number, patch: Partial) => + setSteps(steps.map((s, idx) => (idx === i ? { ...s, ...patch } : s))) + + return ( +
+
+ setName(e.target.value)} + className="font-display text-lg font-bold" + /> + + +
+ {save.isError && ( +
+ {t('saveError')} +
+ )} + + +