@elench/testkit 0.1.108 → 0.1.110

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/README.md +9 -9
  2. package/lib/app/doctor.mjs +5 -5
  3. package/lib/app/typecheck.mjs +6 -5
  4. package/lib/bundler/index.mjs +134 -7
  5. package/lib/cli/args.mjs +3 -2
  6. package/lib/cli/assistant/app.mjs +19 -5
  7. package/lib/cli/assistant/command-observer.mjs +2 -1
  8. package/lib/cli/assistant/command-results.mjs +2 -1
  9. package/lib/cli/assistant/context-pack.mjs +2 -2
  10. package/lib/cli/assistant/prompt-builder.mjs +2 -2
  11. package/lib/cli/assistant/quality-signal-strip.mjs +103 -0
  12. package/lib/cli/assistant/transcript-text.mjs +2 -1
  13. package/lib/cli/assistant/view-model.mjs +79 -0
  14. package/lib/cli/command-flags.mjs +2 -1
  15. package/lib/cli/commands/cleanup.mjs +13 -2
  16. package/lib/cli/commands/discover.mjs +2 -1
  17. package/lib/cli/commands/run.mjs +3 -2
  18. package/lib/cli/entrypoint.mjs +3 -1
  19. package/lib/cli/operations/cleanup/operation.mjs +6 -1
  20. package/lib/cli/operations/status/operation.mjs +2 -2
  21. package/lib/cli/renderers/discover/report.mjs +6 -8
  22. package/lib/cli/renderers/run/failure.mjs +1 -1
  23. package/lib/cli/renderers/run/text-reporter.mjs +1 -1
  24. package/lib/cli/renderers/status/text.mjs +101 -1
  25. package/lib/config/discovery.mjs +10 -1
  26. package/lib/config-api/index.mjs +2 -2
  27. package/lib/config-api/next-runtime-tsconfig.mjs +2 -1
  28. package/lib/coverage/graph-builder.mjs +2 -4
  29. package/lib/coverage/routing.mjs +1 -1
  30. package/lib/coverage/shared.mjs +1 -2
  31. package/lib/discovery/index.d.ts +5 -8
  32. package/lib/discovery/index.mjs +15 -24
  33. package/lib/domain/test-types.mjs +44 -0
  34. package/lib/history/index.d.ts +3 -4
  35. package/lib/history/index.mjs +6 -14
  36. package/lib/runner/formatting.mjs +2 -3
  37. package/lib/runner/maintenance.mjs +136 -35
  38. package/lib/runner/planning.mjs +1 -1
  39. package/lib/runner/results.mjs +0 -6
  40. package/lib/runner/status-model.mjs +520 -0
  41. package/lib/runner/suite-selection.mjs +20 -11
  42. package/lib/runner/template-steps.mjs +2 -2
  43. package/lib/runner/template.mjs +4 -0
  44. package/lib/ui/index.d.ts +1 -0
  45. package/lib/ui/index.mjs +1 -0
  46. package/lib/vitest/index.mjs +2 -1
  47. package/node_modules/@elench/next-analysis/package.json +1 -1
  48. package/node_modules/@elench/testkit-bridge/dist/index.js +9 -11
  49. package/node_modules/@elench/testkit-bridge/dist/index.js.map +1 -1
  50. package/node_modules/@elench/testkit-bridge/package.json +2 -2
  51. package/node_modules/@elench/testkit-protocol/dist/index.d.ts +1 -3
  52. package/node_modules/@elench/testkit-protocol/dist/index.d.ts.map +1 -1
  53. package/node_modules/@elench/testkit-protocol/dist/index.js +3 -6
  54. package/node_modules/@elench/testkit-protocol/dist/index.js.map +1 -1
  55. package/node_modules/@elench/testkit-protocol/package.json +1 -1
  56. package/node_modules/@elench/ts-analysis/dist/requests.js +1 -1
  57. package/node_modules/@elench/ts-analysis/package.json +1 -1
  58. package/package.json +9 -9
  59. package/packages/testkit-bridge/node_modules/@elench/testkit-protocol/dist/index.d.ts +188 -0
  60. package/packages/testkit-bridge/node_modules/@elench/testkit-protocol/dist/index.d.ts.map +1 -0
  61. package/packages/testkit-bridge/node_modules/@elench/testkit-protocol/dist/index.js +293 -0
  62. package/packages/testkit-bridge/node_modules/@elench/testkit-protocol/dist/index.js.map +1 -0
  63. package/packages/testkit-bridge/node_modules/@elench/testkit-protocol/package.json +25 -0
  64. package/node_modules/es-toolkit/CHANGELOG.md +0 -801
  65. package/node_modules/es-toolkit/src/compat/_internal/Equals.d.ts +0 -1
  66. package/node_modules/es-toolkit/src/compat/_internal/IsWritable.d.ts +0 -3
  67. package/node_modules/es-toolkit/src/compat/_internal/MutableList.d.ts +0 -4
  68. package/node_modules/es-toolkit/src/compat/_internal/RejectReadonly.d.ts +0 -4
  69. package/node_modules/esprima/ChangeLog +0 -235
package/README.md CHANGED
@@ -35,7 +35,7 @@ npx @elench/testkit --type int
35
35
  npx @elench/testkit --type dal
36
36
  npx @elench/testkit --type e2e
37
37
  npx @elench/testkit --type int,e2e,dal
38
- npx @elench/testkit --type pw
38
+ npx @elench/testkit --type ui
39
39
 
40
40
  # Parallel file execution
41
41
  npx @elench/testkit --workers 8
@@ -44,7 +44,7 @@ npx @elench/testkit --workers 8
44
44
  npx @elench/testkit --file-timeout-seconds 60
45
45
 
46
46
  # Specific service / suite
47
- npx @elench/testkit --service frontend --type pw -s navigation
47
+ npx @elench/testkit --service frontend --type ui -s navigation
48
48
  npx @elench/testkit --service api --type int -s health
49
49
  npx @elench/testkit --type int,e2e,dal -s dal:queries
50
50
 
@@ -183,9 +183,8 @@ export default defineConfig({
183
183
  ```
184
184
 
185
185
  ```ts
186
- // playwright.config.ts
187
- import { defineConfig } from "@elench/testkit/playwright";
188
- import { devices } from "@playwright/test";
186
+ // ui.config.ts
187
+ import { defineConfig, devices } from "@elench/testkit/ui";
189
188
 
190
189
  export default defineConfig({
191
190
  testDir: "./__testkit__",
@@ -589,7 +588,7 @@ Example layouts:
589
588
 
590
589
  - `src/api/routes/__testkit__/auth/me.int.testkit.ts`
591
590
  - `src/db/__testkit__/sessions/count-type.dal.testkit.ts`
592
- - `frontend/__testkit__/navigation/navigation.pw.testkit.ts`
591
+ - `frontend/__testkit__/navigation/navigation.ui.testkit.ts`
593
592
  - `src/internal/handler/__testkit__/repos/crud.int.testkit.ts`
594
593
 
595
594
  `testkit` uses these suffixes automatically:
@@ -599,7 +598,9 @@ Example layouts:
599
598
  - `*.scenario.testkit.ts`
600
599
  - `*.dal.testkit.ts`
601
600
  - `*.load.testkit.ts`
602
- - `*.pw.testkit.ts`
601
+ - `*.ui.testkit.ts`
602
+
603
+ See [docs/test-types.md](docs/test-types.md) for the canonical type model.
603
604
 
604
605
  Ownership is inferred from:
605
606
 
@@ -645,9 +646,8 @@ identifier plus canonical metadata such as:
645
646
  - `path`
646
647
  - `service`
647
648
  - `suiteName`
648
- - `selectionType`
649
+ - `type`
649
650
  - `internalType`
650
- - `framework`
651
651
  - `skipped`
652
652
  - `skipReason`
653
653
  - `locks`
@@ -31,12 +31,12 @@ export async function runDoctor(options = {}) {
31
31
 
32
32
  const playwrightViolations = findPlaywrightRuntimeImportViolations(productDir);
33
33
  checks.push({
34
- code: "playwright-runtime-imports",
34
+ code: "ui-runtime-imports",
35
35
  level: playwrightViolations.length === 0 ? "pass" : "fail",
36
36
  message:
37
37
  playwrightViolations.length === 0
38
- ? "No runtime @playwright/test imports found in testkit Playwright suites"
39
- : `Found ${playwrightViolations.length} Playwright runtime import violation(s)`,
38
+ ? "No runtime @playwright/test imports found in testkit UI suites"
39
+ : `Found ${playwrightViolations.length} UI runtime import violation(s); import from @elench/testkit/ui instead`,
40
40
  details: playwrightViolations,
41
41
  });
42
42
 
@@ -51,7 +51,7 @@ export async function runDoctor(options = {}) {
51
51
  details: configImportViolations,
52
52
  });
53
53
 
54
- const hasBrowserOrNextWork = discovery.files.some((entry) => entry.selectionType === "pw");
54
+ const hasBrowserOrNextWork = discovery.files.some((entry) => entry.type === "ui");
55
55
  if (hasBrowserOrNextWork) {
56
56
  const nodeCount = discovery.coverageGraph?.nodes?.length || 0;
57
57
  checks.push({
@@ -159,7 +159,7 @@ function collectFiles(rootDir, out = []) {
159
159
  collectFiles(absolutePath, out);
160
160
  continue;
161
161
  }
162
- if (entry.isFile() && entry.name.endsWith(".pw.testkit.ts")) {
162
+ if (entry.isFile() && (entry.name.endsWith(".ui.testkit.ts") || entry.name.endsWith(".ui.testkit.ts"))) {
163
163
  out.push(absolutePath);
164
164
  }
165
165
  }
@@ -83,7 +83,7 @@ function writeRootTypecheckConfig({ productDir, configFile, outputDir }) {
83
83
  "**/*.load.testkit.ts",
84
84
  "**/__testkit__/helpers/**/*.ts",
85
85
  ],
86
- exclude: ["node_modules", "dist", ".testkit", "**/*.pw.testkit.ts"],
86
+ exclude: ["node_modules", "dist", ".testkit", "**/*.ui.testkit.ts", "**/*.ui.testkit.ts"],
87
87
  };
88
88
  fs.writeFileSync(tsconfigPath, `${JSON.stringify(config, null, 2)}\n`);
89
89
  return tsconfigPath;
@@ -104,14 +104,15 @@ function writeNextServiceTypecheckConfig({ productDir, cwd, outputDir, serviceNa
104
104
  }),
105
105
  include: [
106
106
  serviceExists(serviceDir, "next-env.d.ts") ? relativeJsonPath(tsconfigPath, path.join(serviceDir, "next-env.d.ts")) : undefined,
107
- relativeGlob(tsconfigPath, serviceDir, "src/**/*.pw.testkit.ts"),
107
+ relativeGlob(tsconfigPath, serviceDir, "src/**/*.ui.testkit.ts"),
108
+ relativeGlob(tsconfigPath, serviceDir, "src/**/*.ui.testkit.ts"),
108
109
  relativeGlob(tsconfigPath, serviceDir, "tests/playwright-fixtures/**/*.ts"),
109
110
  relativeGlob(tsconfigPath, serviceDir, "tests/playwright-fixtures/**/*.tsx"),
110
111
  relativeGlob(tsconfigPath, serviceDir, "tests/playwright-fixtures/**/*.mts"),
111
112
  relativeGlob(tsconfigPath, serviceDir, ".next/types/**/*.ts"),
112
113
  relativeGlob(tsconfigPath, serviceDir, ".next/dev/types/**/*.ts"),
113
- relativeGlob(tsconfigPath, serviceDir, ".next-testkit/**/dist/types/**/*.ts"),
114
- relativeGlob(tsconfigPath, serviceDir, ".next-testkit/**/dist/dev/types/**/*.ts"),
114
+ relativeGlob(tsconfigPath, productDir, ".next-testkit/**/dist/types/**/*.ts"),
115
+ relativeGlob(tsconfigPath, productDir, ".next-testkit/**/dist/dev/types/**/*.ts"),
115
116
  ].filter(Boolean),
116
117
  exclude: ["node_modules", ".next/cache", ".next/dev"],
117
118
  };
@@ -195,7 +196,7 @@ function packageTypePaths(tsconfigPath) {
195
196
  "@elench/testkit/config": [relativeJsonPath(tsconfigPath, path.join(PACKAGE_ROOT, "lib", "config-api", "index.d.ts"))],
196
197
  "@elench/testkit/drizzle": [relativeJsonPath(tsconfigPath, path.join(PACKAGE_ROOT, "lib", "drizzle", "index.d.ts"))],
197
198
  "@elench/testkit/env": [relativeJsonPath(tsconfigPath, path.join(PACKAGE_ROOT, "lib", "env", "index.d.ts"))],
198
- "@elench/testkit/playwright": [relativeJsonPath(tsconfigPath, path.join(PACKAGE_ROOT, "lib", "playwright", "index.d.ts"))],
199
+ "@elench/testkit/ui": [relativeJsonPath(tsconfigPath, path.join(PACKAGE_ROOT, "lib", "ui", "index.d.ts"))],
199
200
  "@elench/testkit/runtime": [relativeJsonPath(tsconfigPath, path.join(PACKAGE_ROOT, "lib", "runtime", "index.d.ts"))],
200
201
  "@elench/testkit/vitest": [relativeJsonPath(tsconfigPath, path.join(PACKAGE_ROOT, "lib", "vitest", "index.d.ts"))],
201
202
  };
@@ -10,6 +10,7 @@ const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)),
10
10
  const ROOT_ENTRY = path.join(PACKAGE_ROOT, "lib", "index.mjs");
11
11
  const CONFIG_ENTRY = path.join(PACKAGE_ROOT, "lib", "config-api", "index.mjs");
12
12
  const RUNTIME_ENTRY = path.join(PACKAGE_ROOT, "lib", "runtime", "index.mjs");
13
+ const MANIFEST_FILE = "manifest.json";
13
14
  const bundleCache = new Map();
14
15
 
15
16
  export async function bundleK6File({
@@ -22,9 +23,18 @@ export async function bundleK6File({
22
23
  fs.mkdirSync(bundleDir, { recursive: true });
23
24
 
24
25
  const configFile = findConfigFile(productDir);
25
- const cacheKey = await buildCacheKey(absoluteSource, configFile);
26
+ const metadata = await buildCacheMetadata(absoluteSource, configFile);
27
+ const { cacheKey } = metadata;
26
28
  const cached = bundleCache.get(cacheKey);
27
29
  if (cached && fs.existsSync(cached)) {
30
+ updateBundleManifest(bundleDir, {
31
+ ...metadata,
32
+ serviceName: serviceName || "shared",
33
+ sourceFile: absoluteSource,
34
+ outputFile: cached,
35
+ entryFile: inferEntryFile(cached),
36
+ hasSourcemap: fileHasInlineSourcemap(cached),
37
+ });
28
38
  return cached;
29
39
  }
30
40
 
@@ -36,6 +46,20 @@ export async function bundleK6File({
36
46
  bundleDir,
37
47
  `${path.basename(sourceFile, path.extname(sourceFile))}-${cacheKey.slice(0, 12)}.entry.mjs`
38
48
  );
49
+
50
+ if (fs.existsSync(outputFile) && fs.existsSync(entryFile)) {
51
+ bundleCache.set(cacheKey, outputFile);
52
+ updateBundleManifest(bundleDir, {
53
+ ...metadata,
54
+ serviceName: serviceName || "shared",
55
+ sourceFile: absoluteSource,
56
+ outputFile,
57
+ entryFile,
58
+ hasSourcemap: fileHasInlineSourcemap(outputFile),
59
+ });
60
+ return outputFile;
61
+ }
62
+
39
63
  fs.writeFileSync(entryFile, buildBundleEntryModule({
40
64
  sourceFile: absoluteSource,
41
65
  configFile,
@@ -49,7 +73,7 @@ export async function bundleK6File({
49
73
  legalComments: "none",
50
74
  outfile: outputFile,
51
75
  platform: "neutral",
52
- sourcemap: "inline",
76
+ sourcemap: bundleSourcemapEnabled() ? "inline" : false,
53
77
  target: "es2020",
54
78
  plugins: [testkitPackageAliasPlugin()],
55
79
  external: [
@@ -59,26 +83,44 @@ export async function bundleK6File({
59
83
  });
60
84
 
61
85
  bundleCache.set(cacheKey, outputFile);
86
+ updateBundleManifest(bundleDir, {
87
+ ...metadata,
88
+ serviceName: serviceName || "shared",
89
+ sourceFile: absoluteSource,
90
+ outputFile,
91
+ entryFile,
92
+ hasSourcemap: bundleSourcemapEnabled(),
93
+ });
62
94
  return outputFile;
63
95
  }
64
96
 
65
- async function buildCacheKey(sourceFile, configFile = null) {
97
+ async function buildCacheMetadata(sourceFile, configFile = null) {
66
98
  const source = await fs.promises.readFile(sourceFile, "utf8");
67
- const packageJson = await fs.promises.readFile(path.join(PACKAGE_ROOT, "package.json"), "utf8");
99
+ const packageJsonText = await fs.promises.readFile(path.join(PACKAGE_ROOT, "package.json"), "utf8");
100
+ const packageJson = JSON.parse(packageJsonText);
101
+ const sourceHash = crypto.createHash("sha256").update(source).digest("hex");
68
102
  const hash = crypto
69
103
  .createHash("sha256")
70
104
  .update(sourceFile)
71
105
  .update("\0")
72
106
  .update(source)
73
107
  .update("\0")
74
- .update(packageJson);
108
+ .update(packageJsonText);
109
+ let configHash = null;
75
110
 
76
111
  if (configFile && fs.existsSync(configFile)) {
112
+ const configText = await fs.promises.readFile(configFile, "utf8");
113
+ configHash = crypto.createHash("sha256").update(configFile).update("\0").update(configText).digest("hex");
77
114
  hash.update("\0").update(configFile).update("\0");
78
- hash.update(await fs.promises.readFile(configFile, "utf8"));
115
+ hash.update(configText);
79
116
  }
80
117
 
81
- return hash.digest("hex");
118
+ return {
119
+ cacheKey: hash.digest("hex"),
120
+ sourceHash,
121
+ configHash,
122
+ testkitVersion: packageJson.version || null,
123
+ };
82
124
  }
83
125
 
84
126
  function testkitPackageAliasPlugin() {
@@ -153,3 +195,88 @@ function normalizeTestkitSuite(module) {
153
195
  }
154
196
  `;
155
197
  }
198
+
199
+ function updateBundleManifest(bundleDir, entry) {
200
+ const manifestPath = path.join(bundleDir, MANIFEST_FILE);
201
+ const manifest = readBundleManifest(manifestPath);
202
+ const now = new Date().toISOString();
203
+ const existingIndex = manifest.entries.findIndex((candidate) => candidate.cacheKey === entry.cacheKey);
204
+ const existing = existingIndex >= 0 ? manifest.entries[existingIndex] : null;
205
+ const document = {
206
+ sourceFile: entry.sourceFile,
207
+ cacheKey: entry.cacheKey,
208
+ outputFile: entry.outputFile,
209
+ entryFile: entry.entryFile,
210
+ createdAt: existing?.createdAt || now,
211
+ lastUsedAt: now,
212
+ sizeBytes: safeSize(entry.outputFile) + safeSize(entry.entryFile),
213
+ testkitVersion: entry.testkitVersion,
214
+ configHash: entry.configHash,
215
+ sourceHash: entry.sourceHash,
216
+ hasSourcemap: Boolean(entry.hasSourcemap),
217
+ };
218
+
219
+ if (existingIndex >= 0) {
220
+ manifest.entries[existingIndex] = document;
221
+ } else {
222
+ manifest.entries.push(document);
223
+ }
224
+ manifest.entries.sort((left, right) => String(left.sourceFile).localeCompare(String(right.sourceFile)) || String(left.cacheKey).localeCompare(String(right.cacheKey)));
225
+ writeBundleManifest(manifestPath, manifest);
226
+ }
227
+
228
+ function readBundleManifest(manifestPath) {
229
+ if (!fs.existsSync(manifestPath)) {
230
+ return {
231
+ schemaVersion: 1,
232
+ source: "testkit-bundle-cache",
233
+ entries: [],
234
+ };
235
+ }
236
+ try {
237
+ const parsed = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
238
+ return {
239
+ schemaVersion: 1,
240
+ source: "testkit-bundle-cache",
241
+ ...parsed,
242
+ entries: Array.isArray(parsed.entries) ? parsed.entries : [],
243
+ };
244
+ } catch {
245
+ return {
246
+ schemaVersion: 1,
247
+ source: "testkit-bundle-cache",
248
+ entries: [],
249
+ };
250
+ }
251
+ }
252
+
253
+ function writeBundleManifest(manifestPath, manifest) {
254
+ const tempPath = `${manifestPath}.${process.pid}.${Date.now()}.tmp`;
255
+ fs.writeFileSync(tempPath, `${JSON.stringify(manifest, null, 2)}\n`, "utf8");
256
+ fs.renameSync(tempPath, manifestPath);
257
+ }
258
+
259
+ function bundleSourcemapEnabled() {
260
+ return process.env.TESTKIT_BUNDLE_SOURCEMAP === "1";
261
+ }
262
+
263
+ function inferEntryFile(outputFile) {
264
+ return outputFile.replace(/\.js$/, ".entry.mjs");
265
+ }
266
+
267
+ function safeSize(filePath) {
268
+ try {
269
+ return fs.statSync(filePath).size;
270
+ } catch {
271
+ return 0;
272
+ }
273
+ }
274
+
275
+ function fileHasInlineSourcemap(filePath) {
276
+ try {
277
+ const text = fs.readFileSync(filePath, "utf8");
278
+ return /sourceMappingURL=data:application\/json/.test(text);
279
+ } catch {
280
+ return false;
281
+ }
282
+ }
package/lib/cli/args.mjs CHANGED
@@ -1,11 +1,12 @@
1
1
  import path from "path";
2
2
  import { normalizeTypeValues, parseSuiteSelectors } from "../runner/suite-selection.mjs";
3
+ import { publicTestTypeList, publicTestTypeListText } from "../domain/test-types.mjs";
3
4
  import {
4
5
  parseFileTimeoutOption,
5
6
  parseWorkersOption,
6
7
  } from "../runner/execution-config.mjs";
7
8
 
8
- export const POSITIONAL_TYPES = new Set(["int", "e2e", "scenario", "dal", "load", "pw", "all"]);
9
+ export const POSITIONAL_TYPES = new Set(publicTestTypeList({ includeAll: true, includeLegacy: true }));
9
10
  export const LIFECYCLE = new Set(["status", "destroy", "cleanup"]);
10
11
  export const RESERVED = new Set([...POSITIONAL_TYPES, ...LIFECYCLE]);
11
12
 
@@ -33,7 +34,7 @@ export function resolveCliSelection({ first, second, third }) {
33
34
  } else if (first) {
34
35
  throw new Error(
35
36
  `Unknown argument "${first}". Expected a lifecycle command (status, destroy, cleanup) ` +
36
- `or suite type (int, e2e, scenario, dal, load, pw, all).`
37
+ `or suite type (${publicTestTypeListText({ includeAll: true })}).`
37
38
  );
38
39
  }
39
40
 
@@ -5,6 +5,7 @@ import { RunTreeView } from "../components/blocks/run-tree.mjs";
5
5
  import { CodeBlock } from "./code-block.mjs";
6
6
  import { getComposerDisplayModel } from "./composer.mjs";
7
7
  import { MarkdownBlock } from "./markdown-block.mjs";
8
+ import { QualitySignalStrip } from "./quality-signal-strip.mjs";
8
9
  import { buildAssistantViewModel } from "./view-model.mjs";
9
10
  import { truncateText, wrapText } from "../terminal/layout.mjs";
10
11
 
@@ -68,6 +69,7 @@ export function AssistantApp({
68
69
  onRequestClose,
69
70
  })
70
71
  : null,
72
+ createElement(HeaderChrome, { view }),
71
73
  view.blocks.length === 0
72
74
  ? createElement(WelcomePanel, { view })
73
75
  : createElement(Transcript, { view }),
@@ -103,6 +105,21 @@ export function AssistantApp({
103
105
  );
104
106
  }
105
107
 
108
+ function HeaderChrome({ view }) {
109
+ const provider = view.welcome.rows.find(([label]) => label === "Provider")?.[1] || "";
110
+ return createElement(
111
+ Box,
112
+ { flexDirection: "column" },
113
+ createElement(Text, null, bold(view.title)),
114
+ provider ? createElement(Text, null, dim(provider)) : null,
115
+ createElement(QualitySignalStrip, {
116
+ signal: view.qualitySignal,
117
+ width: view.terminalWidth,
118
+ }),
119
+ createElement(Text, null, "")
120
+ );
121
+ }
122
+
106
123
  function AssistantInputHandler({ assistantState, snapshot, onRequestClose }) {
107
124
  const { exit } = useApp();
108
125
 
@@ -154,6 +171,7 @@ function AssistantInputHandler({ assistantState, snapshot, onRequestClose }) {
154
171
  }
155
172
 
156
173
  function WelcomePanel({ view }) {
174
+ const rows = view.welcome.rows.filter(([label]) => label !== "Provider");
157
175
  return createElement(
158
176
  Box,
159
177
  {
@@ -162,10 +180,9 @@ function WelcomePanel({ view }) {
162
180
  paddingLeft: 1,
163
181
  paddingRight: 1,
164
182
  },
165
- createElement(Text, null, bold(view.title)),
166
183
  createElement(Text, null, dim(view.welcome.subtitle)),
167
184
  createElement(Text, null, ""),
168
- ...view.welcome.rows.map(([label, value]) => (
185
+ ...rows.map(([label, value]) => (
169
186
  createElement(Text, { key: label }, `${padLabel(label)} ${colorWelcomeValue(label, value)}`)
170
187
  )),
171
188
  createElement(Text, null, ""),
@@ -180,9 +197,6 @@ function Transcript({ view }) {
180
197
  return createElement(
181
198
  Box,
182
199
  { flexDirection: "column" },
183
- createElement(Text, null, bold(view.title)),
184
- createElement(Text, null, dim(view.welcome.rows.find(([label]) => label === "Provider")?.[1] || "")),
185
- createElement(Text, null, ""),
186
200
  view.notice ? createElement(Text, null, yellow(view.notice)) : null,
187
201
  ...view.blocks.flatMap((block) => renderBlock(block, view))
188
202
  );
@@ -1,9 +1,10 @@
1
1
  import fs from "fs";
2
2
  import path from "path";
3
+ import { publicTestTypeList } from "../../domain/test-types.mjs";
3
4
 
4
5
  const POLL_INTERVAL_MS = 150;
5
6
  const OBSERVED_KINDS = new Set(["run", "discover", "status", "doctor", "typecheck"]);
6
- const RUN_KINDS = new Set(["run", "int", "e2e", "scenario", "dal", "load", "pw", "all"]);
7
+ const RUN_KINDS = new Set(["run", ...publicTestTypeList({ includeAll: true, includeLegacy: true })]);
7
8
 
8
9
  export function createAssistantCommandObserver({
9
10
  productDir,
@@ -1,5 +1,6 @@
1
1
  import fs from "fs";
2
2
  import path from "path";
3
+ import { publicTestTypeList } from "../../domain/test-types.mjs";
3
4
 
4
5
  export const ASSISTANT_SESSION_ENV = "TESTKIT_ASSISTANT_SESSION_ID";
5
6
  export const ASSISTANT_RESULT_DIR_ENV = "TESTKIT_ASSISTANT_RESULT_DIR";
@@ -7,7 +8,7 @@ export const ASSISTANT_COMMAND_LOG_ENV = "TESTKIT_ASSISTANT_COMMAND_LOG";
7
8
  export const ASSISTANT_COMMAND_ID_ENV = "TESTKIT_ASSISTANT_COMMAND_ID";
8
9
  export const ASSISTANT_WRAPPER_LOGGED_ENV = "TESTKIT_ASSISTANT_WRAPPER_LOGGED";
9
10
 
10
- const RUN_SHORTCUTS = new Set(["int", "e2e", "scenario", "dal", "load", "pw", "all"]);
11
+ const RUN_SHORTCUTS = new Set(publicTestTypeList({ includeAll: true, includeLegacy: true }));
11
12
  const FLAGS_WITH_VALUES = new Set([
12
13
  "--dir",
13
14
  "--service",
@@ -178,7 +178,7 @@ function appendCommandLog(event) {
178
178
  }
179
179
 
180
180
  function inferKind(args) {
181
- const runShortcuts = new Set(["int", "e2e", "scenario", "dal", "load", "pw", "all"]);
181
+ const runShortcuts = new Set(["ui", "e2e", "scenario", "int", "dal", "load", "all"]);
182
182
  const flagsWithValues = new Set([
183
183
  "--dir",
184
184
  "--service",
@@ -248,7 +248,7 @@ function buildContextMarkdown(productDir, snapshot, paths) {
248
248
  "## Guidance",
249
249
  "- Work normally in the repository and run real commands. Testkit observes recognized Testkit commands and renders them in the assistant UI.",
250
250
  "- Prefer `testkit ...` commands when using Testkit directly; `npx testkit ...` and project scripts are also valid.",
251
- "- `testkit run e2e` selects e2e suites. `testkit run pw` selects Playwright/browser suites.",
251
+ "- `testkit run ui` selects UI suites. `testkit run e2e` selects e2e suites.",
252
252
  "- Do not reinterpret CLI syntax after an execution failure unless `testkit run --help` confirms a syntax problem.",
253
253
  "- Use the command log and focused context files before rereading artifacts manually.",
254
254
  "- Prefer repo-local commands over guessing project-specific wrappers.",
@@ -15,8 +15,8 @@ export function buildAssistantPrompt({
15
15
  "You are Testkit Assistant.",
16
16
  "You are running as a coding agent inside the user's repository.",
17
17
  "Work normally: inspect files, edit files, run real shell commands, and iterate until the user's request is handled.",
18
- "When using Testkit, run real commands such as `testkit discover`, `testkit run e2e`, `testkit run pw`, `npx testkit run int`, or the repository's package scripts.",
19
- "`testkit run e2e` selects e2e suites. `testkit run pw` selects Playwright/browser suites. Choose commands from the user's request and the command reference, not from Testkit prompt routing.",
18
+ "When using Testkit, run real commands such as `testkit discover`, `testkit run ui`, `testkit run e2e`, `npx testkit run int`, or the repository's package scripts.",
19
+ "`testkit run ui` selects UI suites. `testkit run e2e` selects e2e suites. Choose commands from the user's request and the command reference, not from Testkit prompt routing.",
20
20
  "Testkit observes recognized Testkit commands and renders rich assistant UI from the real command output, sidecars, and artifacts.",
21
21
  "Do not respond with a JSON tool envelope. Give the user a normal final answer when you are done.",
22
22
  "",
@@ -0,0 +1,103 @@
1
+ import React, { createElement } from "react";
2
+ import { Box, Text } from "ink";
3
+ import { cyan, dim, green, red, yellow } from "../terminal/colors.mjs";
4
+ import { measureWidth, truncateText } from "../terminal/layout.mjs";
5
+
6
+ const LABEL_ITEM_SEPARATOR = " ";
7
+ const ITEM_SEPARATOR = " · ";
8
+ const MIN_TRUNCATED_ITEM_WIDTH = 4;
9
+
10
+ export function fitQualitySignal(signal, { width = 100 } = {}) {
11
+ const availableWidth = Math.max(0, Number(width) || 0);
12
+ const label = normalizeText(signal?.label) || "Quality signal";
13
+ const items = normalizeItems(signal?.items);
14
+ if (availableWidth <= 0) return { label: "", items: [] };
15
+ if (items.length === 0) {
16
+ return {
17
+ label: truncateText(label, availableWidth),
18
+ items: [],
19
+ };
20
+ }
21
+
22
+ for (let count = items.length; count >= 1; count -= 1) {
23
+ const candidate = {
24
+ label,
25
+ items: items.slice(0, count),
26
+ };
27
+ if (measureWidth(formatQualitySignalText(candidate)) <= availableWidth) return candidate;
28
+ }
29
+
30
+ const firstItemWidth = availableWidth - measureWidth(label) - measureWidth(LABEL_ITEM_SEPARATOR);
31
+ if (firstItemWidth >= MIN_TRUNCATED_ITEM_WIDTH) {
32
+ return {
33
+ label,
34
+ items: [
35
+ {
36
+ ...items[0],
37
+ text: truncateText(items[0].text, firstItemWidth),
38
+ },
39
+ ],
40
+ };
41
+ }
42
+
43
+ return {
44
+ label: truncateText(label, availableWidth),
45
+ items: [],
46
+ };
47
+ }
48
+
49
+ export function formatQualitySignalText(signal) {
50
+ const label = normalizeText(signal?.label) || "Quality signal";
51
+ const items = normalizeItems(signal?.items);
52
+ if (items.length === 0) return label;
53
+ return `${label}${LABEL_ITEM_SEPARATOR}${items.map((item) => item.text).join(ITEM_SEPARATOR)}`;
54
+ }
55
+
56
+ export function QualitySignalStrip({ signal, width = 100 } = {}) {
57
+ const fitted = fitQualitySignal(signal, { width });
58
+ if (!fitted.label) return null;
59
+ return createElement(
60
+ Box,
61
+ {
62
+ height: 1,
63
+ flexDirection: "row",
64
+ },
65
+ createElement(
66
+ Text,
67
+ null,
68
+ dim(fitted.label),
69
+ fitted.items.length > 0 ? LABEL_ITEM_SEPARATOR : "",
70
+ ...fitted.items.flatMap((item, index) => [
71
+ index > 0 ? dim(ITEM_SEPARATOR) : "",
72
+ colorQualitySignalItem(item),
73
+ ])
74
+ )
75
+ );
76
+ }
77
+
78
+ function normalizeItems(items) {
79
+ return (Array.isArray(items) ? items : [])
80
+ .map((item) => ({
81
+ id: normalizeText(item?.id),
82
+ text: normalizeText(item?.text),
83
+ tone: normalizeTone(item?.tone),
84
+ }))
85
+ .filter((item) => item.text);
86
+ }
87
+
88
+ function normalizeText(value) {
89
+ return String(value ?? "").replace(/\s+/g, " ").trim();
90
+ }
91
+
92
+ function normalizeTone(value) {
93
+ if (value === "good" || value === "warning" || value === "danger" || value === "progress") return value;
94
+ return "neutral";
95
+ }
96
+
97
+ function colorQualitySignalItem(item) {
98
+ if (item.tone === "good") return green(item.text);
99
+ if (item.tone === "warning") return yellow(item.text);
100
+ if (item.tone === "danger") return red(item.text);
101
+ if (item.tone === "progress") return cyan(item.text);
102
+ return item.text;
103
+ }
@@ -3,10 +3,11 @@ import { truncateText, wrapText } from "../terminal/layout.mjs";
3
3
  import { buildAssistantViewModel } from "./view-model.mjs";
4
4
  import { renderCodeBlockText } from "./code-block.mjs";
5
5
  import { renderMarkdownToAnsi } from "./markdown-block.mjs";
6
+ import { formatQualitySignalText, fitQualitySignal } from "./quality-signal-strip.mjs";
6
7
 
7
8
  export function renderAssistantSnapshotText(snapshot, { cwd = process.cwd(), ansi = false, width = 100 } = {}) {
8
9
  const view = buildAssistantViewModel(snapshot || {}, { cwd, terminalWidth: width });
9
- const lines = [view.title, view.welcome.rows.find(([label]) => label === "Provider")?.[1] || ""]
10
+ const lines = [view.title, view.welcome.rows.find(([label]) => label === "Provider")?.[1] || "", formatQualitySignalText(fitQualitySignal(view.qualitySignal, { width }))]
10
11
  .filter(Boolean);
11
12
  for (const block of view.blocks || []) {
12
13
  lines.push("");