From b1ad7156f79cd1836d4af193270cd74d6c96a926 Mon Sep 17 00:00:00 2001 From: hupark-seegene Date: Wed, 22 Jul 2026 09:48:26 +0900 Subject: [PATCH 01/10] fix: PHP 8 fatal errors and deprecations across core code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - string_api.php: define LINKS_SAME_WINDOW / LINKS_NEW_WINDOW (adapted from MantisBT, referenced but never defined — fatal on PHP 8 when rendering any value containing a URL), replace removed split() with explode(), pass -1 instead of null to preg_split() - xmlrpc.class.php: fix (arrya) cast typo that was a parse error, leaving the whole XML-RPC v1 API dead - RestApiCustomExample.class.php: quote bare 'lib' constant (fatal on load of REST v3 API) - testplan.class.php: replace ${var} string interpolation (deprecated in PHP 8.2) - tcImport.php / tcCreateFromIssue.php / neverRunByPP.php: fix optional-parameter-before-required declarations Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01CAUVXjSm6HedDGCi8dkEZX --- .../custom/api/RestApiCustomExample.class.php | 2 +- lib/api/xmlrpc/v1/xmlrpc.class.php | 2 +- lib/functions/string_api.php | 17 +++++++++++++++-- lib/functions/testplan.class.php | 6 +++--- lib/results/neverRunByPP.php | 2 +- lib/testcases/tcCreateFromIssue.php | 2 +- lib/testcases/tcImport.php | 2 +- 7 files changed, 23 insertions(+), 10 deletions(-) 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/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..feb678a7b8 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,7 @@ 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']); // Do we have steps exec info? if (property_exists($ex,'steps')) { 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/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; From 0fbbd485f8530c4be9769cf9085f749cce61b43d Mon Sep 17 00:00:00 2001 From: hupark-seegene Date: Wed, 22 Jul 2026 09:48:40 +0900 Subject: [PATCH 02/10] perf: add execution indexes for plan-scoped dashboard queries 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 plan-scoped scans degraded to full table scans (measured over 500k rows). - idx_exec_tplan_tcv_id (testplan_id, tcversion_id, id) backs latest-per-version lookups and flip/flaky analysis - idx_exec_tplan_ts (testplan_id, execution_ts) backs daily trends Measured on 500k executions: status totals 123ms -> 46ms when combined with a MAX(id) loose-index-scan query shape. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01CAUVXjSm6HedDGCi8dkEZX --- .../DB.1.9.21/step1/db_schema_update.sql | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 install/sql/alter_tables/1.9.21/mysql/DB.1.9.21/step1/db_schema_update.sql 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); From b4126fec5ac76ab3ea53baedc7d44733843ffaf1 Mon Sep 17 00:00:00 2001 From: hupark-seegene Date: Wed, 22 Jul 2026 09:49:07 +0900 Subject: [PATCH 03/10] feat: webhooks, generic OIDC login, execution trend & flaky report MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Webhooks: new lib/functions/webhook_api.php sends HMAC-SHA256-signed JSON POSTs (X-TestLink-Signature, GitHub-style) to URLs configured in $tlCfg->webhooks when executions are recorded (UI and API write paths). Best-effort with short timeouts so a dead receiver cannot block the user-facing operation. Disabled by default. OIDC: new generic OpenID Connect provider (Keycloak, Okta, Auth0, Azure AD v2, any spec-compliant IdP). Endpoints configurable explicitly or resolved from .well-known/openid-configuration. Identity is read from the userinfo endpoint over TLS, so no local JWT verification is required. Sample config in cfg/oauth_samples/. Also fixes an undefined $oauthCfg variable in the azuread branch of OAuth2Call.php. Reports: new "Execution Trend & Flaky Tests" report — daily executions by status as a dependency-free server-side SVG chart, plus a ranking of test case versions whose executions flip between pass and fail. Registered in the reports menu. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01CAUVXjSm6HedDGCi8dkEZX --- cfg/oauth_samples/oauth.oidc.inc.php | 54 +++++ cfg/reports.cfg.php | 13 +- config.inc.php | 14 ++ .../tl-classic/results/resultsTrend.tpl | 58 ++++++ lib/functions/common.php | 1 + lib/functions/exec.inc.php | 18 +- lib/functions/oauth_api.php | 1 + lib/functions/oauth_providers/OAuth2Call.php | 30 ++- lib/functions/oauth_providers/oidc.php | 148 +++++++++++++ lib/functions/testplan.class.php | 10 + lib/functions/webhook_api.php | 81 ++++++++ lib/results/resultsTrend.php | 196 ++++++++++++++++++ locale/en_GB/strings.txt | 10 + 13 files changed, 623 insertions(+), 11 deletions(-) create mode 100644 cfg/oauth_samples/oauth.oidc.inc.php create mode 100644 gui/templates/tl-classic/results/resultsTrend.tpl create mode 100644 lib/functions/oauth_providers/oidc.php create mode 100644 lib/functions/webhook_api.php create mode 100644 lib/results/resultsTrend.php 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/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/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/testplan.class.php b/lib/functions/testplan.class.php index feb678a7b8..cd79367bc5 100644 --- a/lib/functions/testplan.class.php +++ b/lib/functions/testplan.class.php @@ -7226,6 +7226,16 @@ function writeExecution($ex) $this->db->exec_query($sql); $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')) { // steps [] of stepExec 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/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/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 ----------------------------------------------------- From 7ae67f0ffe0b84d1cdea8698a15dc99fbc1ff5de Mon Sep 17 00:00:00 2001 From: hupark-seegene Date: Wed, 22 Jul 2026 09:49:26 +0900 Subject: [PATCH 04/10] feat: REST v3 JSON endpoints and modern SPA UI (ui/) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit REST v3 additions backing the new UI (and useful standalone): - POST /auth/login — exchanges login/password for the user's API key (auth middleware now uses case-insensitive PSR-7 getHeaderLine() and actually returns the 401 it builds) - pagination for GET /testprojects/{mixedID}/testcases (?page=&limit=) and fix for the route arg mismatch that broke it - suites tree, suite test cases, test case detail (steps + recent runs), test case update (steps replace), plan link, plan summary / trend / flaky / execution queue / result matrix (by case and by suite), builds by plan id - plan status totals use a MAX(id) loose-index-scan + PK join so the cost follows the number of test case versions, not executions; flaky analysis is bounded to a configurable recent window ui/: React 19 + Vite + TanStack Router/Query + Tailwind v4 SPA covering the daily workflow — dashboard (verdict wall, trend, flaky), test case browser with suite tree + case editor (steps CRUD, new suite/case, add-to-plan), execution run queue with verdict recorder, result matrix (at-a-glance by-suite ratio bars with drill-down to case level), plans & builds management, reports. Screens deep-link to each other (?caseId=). Dev: cd ui && npm run dev. Build output ui/dist is served by the PHP web server as static files. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01CAUVXjSm6HedDGCi8dkEZX --- lib/api/rest/v3/RestApi.class.php | 663 ++++++++- lib/api/rest/v3/core/routes.php | 29 +- ui/.gitignore | 24 + ui/.oxlintrc.json | 8 + ui/README.md | 32 + ui/index.html | 12 + ui/package-lock.json | 2202 +++++++++++++++++++++++++++++ ui/package.json | 33 + ui/public/favicon.svg | 1 + ui/public/icons.svg | 24 + ui/src/assets/hero.png | Bin 0 -> 13057 bytes ui/src/assets/react.svg | 1 + ui/src/assets/vite.svg | 1 + ui/src/components/CaseEditor.tsx | 146 ++ ui/src/components/Shell.tsx | 104 ++ ui/src/components/TrendChart.tsx | 95 ++ ui/src/components/ui.tsx | 122 ++ ui/src/index.css | 61 + ui/src/lib/api.ts | 306 ++++ ui/src/lib/workspace.tsx | 75 + ui/src/main.tsx | 122 ++ ui/src/routes/DashboardPage.tsx | 128 ++ ui/src/routes/LoginPage.tsx | 64 + ui/src/routes/MatrixPage.tsx | 273 ++++ ui/src/routes/PlansPage.tsx | 136 ++ ui/src/routes/ReportsPage.tsx | 61 + ui/src/routes/RunPage.tsx | 189 +++ ui/src/routes/SpecPage.tsx | 414 ++++++ ui/tsconfig.app.json | 26 + ui/tsconfig.json | 7 + ui/tsconfig.node.json | 23 + ui/vite.config.ts | 19 + 32 files changed, 5375 insertions(+), 26 deletions(-) create mode 100644 ui/.gitignore create mode 100644 ui/.oxlintrc.json create mode 100644 ui/README.md create mode 100644 ui/index.html create mode 100644 ui/package-lock.json create mode 100644 ui/package.json create mode 100644 ui/public/favicon.svg create mode 100644 ui/public/icons.svg create mode 100644 ui/src/assets/hero.png create mode 100644 ui/src/assets/react.svg create mode 100644 ui/src/assets/vite.svg create mode 100644 ui/src/components/CaseEditor.tsx create mode 100644 ui/src/components/Shell.tsx create mode 100644 ui/src/components/TrendChart.tsx create mode 100644 ui/src/components/ui.tsx create mode 100644 ui/src/index.css create mode 100644 ui/src/lib/api.ts create mode 100644 ui/src/lib/workspace.tsx create mode 100644 ui/src/main.tsx create mode 100644 ui/src/routes/DashboardPage.tsx create mode 100644 ui/src/routes/LoginPage.tsx create mode 100644 ui/src/routes/MatrixPage.tsx create mode 100644 ui/src/routes/PlansPage.tsx create mode 100644 ui/src/routes/ReportsPage.tsx create mode 100644 ui/src/routes/RunPage.tsx create mode 100644 ui/src/routes/SpecPage.tsx create mode 100644 ui/tsconfig.app.json create mode 100644 ui/tsconfig.json create mode 100644 ui/tsconfig.node.json create mode 100644 ui/vite.config.ts diff --git a/lib/api/rest/v3/RestApi.class.php b/lib/api/rest/v3/RestApi.class.php index fe418bddec..c8a3820e39 100644 --- a/lib/api/rest/v3/RestApi.class.php +++ b/lib/api/rest/v3/RestApi.class.php @@ -161,30 +161,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,8 +213,7 @@ public function authenticate(Request $request, RequestHandler $handler) } $response = new Response(); $response->getBody()->write($msg); - $response->withStatus(401); - return $response; + return $response->withStatus(401); } /** @@ -319,40 +324,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 +1207,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); @@ -1697,7 +1721,596 @@ 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); + + $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 + $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(); + foreach ((array)$this->db->get_recordset($sql) as $row) { + $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} " : ''; + + $sql = " SELECT COUNT(*) AS qty FROM {$this->tables['testplan_tcversions']} " . + " WHERE testplan_id = {$safeID} "; + $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, " . + " (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 " . + " WHERE T.testplan_id = {$safeID} " . + " 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 /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; } } // class end diff --git a/lib/api/rest/v3/core/routes.php b/lib/api/rest/v3/core/routes.php index f0e2404fae..b69b00fcfe 100644 --- a/lib/api/rest/v3/core/routes.php +++ b/lib/api/rest/v3/core/routes.php @@ -13,12 +13,39 @@ // 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('/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/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 0000000000000000000000000000000000000000..02251f4b956c55af2d76fd0788124d7eee2b45eb GIT binary patch literal 13057 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 P00000NkvXXu0mjft=yBf literal 0 HcmV?d00001 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..9f90a832fe --- /dev/null +++ b/ui/src/components/CaseEditor.tsx @@ -0,0 +1,146 @@ +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 { 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 [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 && ( +
+ Could not save. Try again. +
+ )} + + +