@xera-ai/core 0.1.4 → 0.1.6

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 (41) hide show
  1. package/dist/bin/internal.js +31 -31
  2. package/dist/bin-internal/exec.d.ts.map +1 -1
  3. package/package.json +3 -3
  4. package/src/adapter/types.ts +62 -0
  5. package/src/artifact/hash.ts +15 -0
  6. package/src/artifact/meta.ts +40 -0
  7. package/src/artifact/paths.ts +62 -0
  8. package/src/artifact/status.ts +55 -0
  9. package/src/auth/encrypt.ts +40 -0
  10. package/src/auth/key.ts +15 -0
  11. package/src/auth/refresh.ts +31 -0
  12. package/src/auth/state.ts +32 -0
  13. package/src/bin-internal/exec.ts +107 -0
  14. package/src/bin-internal/fetch.ts +84 -0
  15. package/src/bin-internal/index.ts +39 -0
  16. package/src/bin-internal/lint.ts +12 -0
  17. package/src/bin-internal/normalize.ts +20 -0
  18. package/src/bin-internal/post.ts +35 -0
  19. package/src/bin-internal/promote.ts +12 -0
  20. package/src/bin-internal/report.ts +47 -0
  21. package/src/bin-internal/status-cmd.ts +13 -0
  22. package/src/bin-internal/typecheck.ts +12 -0
  23. package/src/bin-internal/unlock.ts +18 -0
  24. package/src/bin-internal/validate-feature.ts +14 -0
  25. package/src/classifier/aggregate.ts +26 -0
  26. package/src/classifier/history.ts +27 -0
  27. package/src/classifier/types.ts +25 -0
  28. package/src/config/define.ts +2 -0
  29. package/src/config/load.ts +14 -0
  30. package/src/config/schema.ts +74 -0
  31. package/src/index.ts +19 -0
  32. package/src/jira/client.ts +20 -0
  33. package/src/jira/fields.ts +21 -0
  34. package/src/jira/mcp-backend.ts +40 -0
  35. package/src/jira/rest-backend.ts +79 -0
  36. package/src/jira/retry.ts +24 -0
  37. package/src/jira/types.ts +21 -0
  38. package/src/lock/file-lock.ts +57 -0
  39. package/src/logging/ndjson-logger.ts +25 -0
  40. package/src/reporter/jira-comment.ts +31 -0
  41. package/src/reporter/status-writer.ts +37 -0
@@ -335,12 +335,22 @@ async function fetchCmd(argv, opts = {}) {
335
335
  function renderStory(t) {
336
336
  const lines = [];
337
337
  lines.push(`# ${t.key}: ${t.summary}`, "");
338
- lines.push(`## Story`, "", t.story.trim(), "");
338
+ const story = t.story.trim();
339
+ if (/^##\s+story\b/i.test(story)) {
340
+ lines.push(story, "");
341
+ } else {
342
+ lines.push("## Story", "", story, "");
343
+ }
339
344
  if (t.acceptanceCriteria && t.acceptanceCriteria.trim()) {
340
- lines.push(`## Acceptance Criteria`, "", t.acceptanceCriteria.trim(), "");
345
+ const ac = t.acceptanceCriteria.trim();
346
+ if (/^##\s+acceptance\s+criteria\b/i.test(ac)) {
347
+ lines.push(ac, "");
348
+ } else {
349
+ lines.push("## Acceptance Criteria", "", ac, "");
350
+ }
341
351
  }
342
352
  if (t.attachments.length > 0) {
343
- lines.push(`## Attachments`, "", ...t.attachments.map((a) => `- [${a.filename}](${a.url})`), "");
353
+ lines.push("## Attachments", "", ...t.attachments.map((a) => `- [${a.filename}](${a.url})`), "");
344
354
  }
345
355
  return lines.join(`
346
356
  `);
@@ -597,7 +607,7 @@ function needsRefresh(entry, policy, now = new Date) {
597
607
  // src/bin-internal/exec.ts
598
608
  import { stagePlaywrightState, runAuthSetup, runPlaywright } from "@xera-ai/web";
599
609
  import { chromium } from "@playwright/test";
600
- import { writeFileSync as writeFileSync6, mkdirSync as mkdirSync7, existsSync as existsSync9 } from "fs";
610
+ import { mkdirSync as mkdirSync7, existsSync as existsSync9 } from "fs";
601
611
  import { join as join5 } from "path";
602
612
  async function execCmd(argv) {
603
613
  const ticket = argv[0];
@@ -649,25 +659,29 @@ async function execCmd(argv) {
649
659
  await browser.close();
650
660
  }
651
661
  }
652
- const stagedRoles = {};
653
662
  if (config.web.auth.strategy === "storageState") {
654
663
  for (const roleName of Object.keys(config.web.auth.roles)) {
655
664
  if (readAuthState(paths.authDir, roleName)) {
656
- stagedRoles[roleName] = stagePlaywrightState(paths.authDir, roleName);
665
+ stagePlaywrightState(paths.authDir, roleName);
657
666
  }
658
667
  }
659
668
  }
660
- const cfgPath = join5(paths.ticketDir, "playwright.config.ts");
669
+ const cfgPath = join5(cwd, "playwright.config.ts");
661
670
  if (!existsSync9(cfgPath)) {
662
- writeFileSync6(cfgPath, renderPlaywrightConfig({
663
- baseUrl: config.web.baseUrl[config.web.defaultEnv],
664
- storageStatePathPerRole: stagedRoles
665
- }));
671
+ console.error(`[xera:exec] missing ${cfgPath}. Run \`xera init\` to scaffold it, then re-run.`);
672
+ return 1;
666
673
  }
667
674
  const runDir = paths.runPath(runId).runDir;
668
675
  mkdirSync7(runDir, { recursive: true });
669
- log.log({ step: "exec.start", runId });
670
- const r = await runPlaywright({ specPath: paths.specPath, configPath: cfgPath, outputDir: runDir });
676
+ const envName = process.env.XERA_ENV ?? config.web.defaultEnv;
677
+ const baseURL = config.web.baseUrl[envName] ?? config.web.baseUrl[config.web.defaultEnv];
678
+ log.log({ step: "exec.start", runId, env: envName, baseURL });
679
+ const r = await runPlaywright({
680
+ specPath: paths.specPath,
681
+ configPath: cfgPath,
682
+ outputDir: runDir,
683
+ env: { XERA_BASE_URL: baseURL, XERA_ENV: envName }
684
+ });
671
685
  log.log({ step: "exec.done", runId, exit: r.exitCode, ms: Date.now() - t0 });
672
686
  console.log(`[xera:exec] runId=${runId} outcome=${r.outcome}`);
673
687
  return r.outcome === "PASS" ? 0 : 3;
@@ -675,20 +689,6 @@ async function execCmd(argv) {
675
689
  releaseLock(paths.lockPath);
676
690
  }
677
691
  }
678
- function renderPlaywrightConfig(opts) {
679
- const projects = Object.entries(opts.storageStatePathPerRole).map(([role, path]) => ` { name: '${role}', use: { ...devices['Desktop Chromium'], storageState: '${path}' } }`);
680
- if (projects.length === 0)
681
- projects.push(` { name: 'default', use: { ...devices['Desktop Chromium'] } }`);
682
- return `import { defineConfig, devices } from '@playwright/test';
683
- export default defineConfig({
684
- use: { baseURL: '${opts.baseUrl}', trace: 'on' },
685
- projects: [
686
- ${projects.join(`,
687
- `)}
688
- ],
689
- });
690
- `;
691
- }
692
692
 
693
693
  // src/bin-internal/normalize.ts
694
694
  import { normalizeRun } from "@xera-ai/web";
@@ -718,7 +718,7 @@ async function normalizeCmd(argv) {
718
718
  }
719
719
 
720
720
  // src/bin-internal/report.ts
721
- import { readFileSync as readFileSync9, writeFileSync as writeFileSync8 } from "fs";
721
+ import { readFileSync as readFileSync9, writeFileSync as writeFileSync7 } from "fs";
722
722
  import { join as join7 } from "path";
723
723
 
724
724
  // src/classifier/aggregate.ts
@@ -753,7 +753,7 @@ function aggregateScenarios(scenarios) {
753
753
  import { existsSync as existsSync12 } from "fs";
754
754
 
755
755
  // src/artifact/status.ts
756
- import { existsSync as existsSync11, readFileSync as readFileSync8, writeFileSync as writeFileSync7, mkdirSync as mkdirSync8 } from "fs";
756
+ import { existsSync as existsSync11, readFileSync as readFileSync8, writeFileSync as writeFileSync6, mkdirSync as mkdirSync8 } from "fs";
757
757
  import { dirname as dirname5 } from "path";
758
758
  import { z as z4 } from "zod";
759
759
  var ClassificationEnum = z4.enum(["PASS", "REAL_BUG", "SELECTOR_DRIFT", "FLAKY", "TEST_BUG"]);
@@ -787,7 +787,7 @@ function readStatus(path) {
787
787
  }
788
788
  function writeStatus(path, status) {
789
789
  mkdirSync8(dirname5(path), { recursive: true });
790
- writeFileSync7(path, JSON.stringify(status, null, 2));
790
+ writeFileSync6(path, JSON.stringify(status, null, 2));
791
791
  }
792
792
  function appendHistory(path, entry) {
793
793
  const s = readStatus(path);
@@ -884,7 +884,7 @@ async function reportCmd(argv) {
884
884
  promptsVersion: "1.0.0"
885
885
  });
886
886
  const draftPath = join7(paths.ticketDir, "jira-comment.draft.md");
887
- writeFileSync8(draftPath, md);
887
+ writeFileSync7(draftPath, md);
888
888
  console.log(`[xera:report] wrote status.json and ${draftPath}`);
889
889
  return 0;
890
890
  }
@@ -1 +1 @@
1
- {"version":3,"file":"exec.d.ts","sourceRoot":"","sources":["../../src/bin-internal/exec.ts"],"names":[],"mappings":"AAWA,wBAAsB,OAAO,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,MAAM,CAAC,CAoF7D"}
1
+ {"version":3,"file":"exec.d.ts","sourceRoot":"","sources":["../../src/bin-internal/exec.ts"],"names":[],"mappings":"AAWA,wBAAsB,OAAO,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,MAAM,CAAC,CA8F7D"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xera-ai/core",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",
@@ -19,14 +19,14 @@
19
19
  "bin": {
20
20
  "xera-internal": "./dist/bin/internal.js"
21
21
  },
22
- "files": ["dist", "bin"],
22
+ "files": ["dist", "src", "bin"],
23
23
  "scripts": {
24
24
  "build": "bun build ./src/index.ts ./bin/internal.ts --outdir ./dist --target bun --external @playwright/test --external @xera-ai/web --external zod",
25
25
  "typecheck": "tsc --noEmit"
26
26
  },
27
27
  "dependencies": {
28
28
  "zod": "3.23.8",
29
- "@xera-ai/web": "^0.1.4",
29
+ "@xera-ai/web": "^0.1.5",
30
30
  "@playwright/test": "1.48.0"
31
31
  }
32
32
  }
@@ -0,0 +1,62 @@
1
+ import type { XeraConfig } from '../config/schema';
2
+ import type { Classification } from '../artifact/status';
3
+
4
+ export interface GenerateInput {
5
+ ticketDir: string;
6
+ feature: string;
7
+ story: string;
8
+ config: XeraConfig;
9
+ }
10
+
11
+ export interface GenerateResult {
12
+ artifacts: string[];
13
+ warnings: string[];
14
+ }
15
+
16
+ export interface ExecuteInput {
17
+ ticketDir: string;
18
+ config: XeraConfig;
19
+ runId: string;
20
+ env: string;
21
+ }
22
+
23
+ export interface ScenarioResult {
24
+ name: string;
25
+ outcome: 'PASS' | 'FAIL' | 'SKIPPED';
26
+ failure?: {
27
+ step?: string;
28
+ errorMessage?: string;
29
+ domSnapshotAtFailure?: string;
30
+ networkAtFailure?: Array<{ method: string; url: string; status: number }>;
31
+ consoleAtFailure?: string[];
32
+ screenshotPath?: string;
33
+ };
34
+ }
35
+
36
+ export interface RunResult {
37
+ runId: string;
38
+ outcome: 'PASS' | 'FAIL';
39
+ scenarios: ScenarioResult[];
40
+ artifactsDir: string;
41
+ rawReportPath: string;
42
+ normalizedReportPath: string;
43
+ }
44
+
45
+ export interface ClassifyContext {
46
+ history: Array<{ ts: string; result: 'PASS' | 'FAIL'; class: Classification }>;
47
+ storyHashChanged: boolean;
48
+ specHashChanged: boolean;
49
+ }
50
+
51
+ export interface DoctorReport {
52
+ ok: boolean;
53
+ checks: Array<{ name: string; ok: boolean; message?: string }>;
54
+ }
55
+
56
+ export interface TestAdapter {
57
+ readonly id: string;
58
+ generate(input: GenerateInput): Promise<GenerateResult>;
59
+ execute(input: ExecuteInput): Promise<RunResult>;
60
+ classify?(run: RunResult, ctx: ClassifyContext): Partial<{ class: Classification; rationale: string }>;
61
+ doctor(): Promise<DoctorReport>;
62
+ }
@@ -0,0 +1,15 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { existsSync, readFileSync } from 'node:fs';
3
+
4
+ export function hashString(s: string): string {
5
+ return `sha256:${createHash('sha256').update(s).digest('hex')}`;
6
+ }
7
+
8
+ export function hashFile(path: string): string {
9
+ return hashString(readFileSync(path, 'utf8'));
10
+ }
11
+
12
+ export function hashFileIfExists(path: string): string | null {
13
+ if (!existsSync(path)) return null;
14
+ return hashFile(path);
15
+ }
@@ -0,0 +1,40 @@
1
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
2
+ import { dirname } from 'node:path';
3
+ import { z } from 'zod';
4
+
5
+ export const MetaJsonSchema = z.object({
6
+ ticket: z.string(),
7
+ adapter: z.string(),
8
+ xera_version: z.string(),
9
+ prompts_version: z.string(),
10
+ fetched_at: z.string().optional(),
11
+ story_hash: z.string().optional(),
12
+ feature_generated_at: z.string().optional(),
13
+ feature_generated_from_story_hash: z.string().optional(),
14
+ feature_hash: z.string().optional(),
15
+ script_generated_at: z.string().optional(),
16
+ script_generated_from_feature_hash: z.string().optional(),
17
+ script_warnings: z.array(z.string()).optional(),
18
+ });
19
+
20
+ export type MetaJson = z.infer<typeof MetaJsonSchema>;
21
+
22
+ export function readMeta(path: string): MetaJson | null {
23
+ if (!existsSync(path)) return null;
24
+ return MetaJsonSchema.parse(JSON.parse(readFileSync(path, 'utf8')));
25
+ }
26
+
27
+ export function writeMeta(path: string, meta: MetaJson): void {
28
+ mkdirSync(dirname(path), { recursive: true });
29
+ writeFileSync(path, JSON.stringify(meta, null, 2));
30
+ }
31
+
32
+ export function updateMeta(path: string, patch: Partial<MetaJson>): MetaJson {
33
+ const existing = readMeta(path);
34
+ if (!existing) {
35
+ throw new Error(`meta.json not found at ${path}; cannot update`);
36
+ }
37
+ const next = { ...existing, ...patch };
38
+ writeMeta(path, next);
39
+ return next;
40
+ }
@@ -0,0 +1,62 @@
1
+ import { join } from 'node:path';
2
+
3
+ const TICKET_RE = /^[A-Z][A-Z0-9_]*-\d+$|^SAMPLE-\d+$/;
4
+
5
+ export interface RunPaths {
6
+ runDir: string;
7
+ reportJsonPath: string;
8
+ tracePath: string;
9
+ normalizedPath: string;
10
+ screenshotsDir: string;
11
+ videoDir: string;
12
+ }
13
+
14
+ export interface ArtifactPaths {
15
+ ticketDir: string;
16
+ storyPath: string;
17
+ featurePath: string;
18
+ specPath: string;
19
+ pageObjectsDir: string;
20
+ runsDir: string;
21
+ metaPath: string;
22
+ statusPath: string;
23
+ logPath: string;
24
+ lockPath: string;
25
+ authDir: string;
26
+ runPath: (runId: string) => RunPaths;
27
+ }
28
+
29
+ export function resolveArtifactPaths(repoRoot: string, ticket: string): ArtifactPaths {
30
+ if (!TICKET_RE.test(ticket)) {
31
+ throw new Error(`Invalid ticket key: "${ticket}" (expected e.g. JIRA-123 or SAMPLE-001)`);
32
+ }
33
+ const ticketDir = join(repoRoot, '.xera', ticket);
34
+ return {
35
+ ticketDir,
36
+ storyPath: join(ticketDir, 'story.md'),
37
+ featurePath: join(ticketDir, 'test.feature'),
38
+ specPath: join(ticketDir, 'spec.ts'),
39
+ pageObjectsDir: join(ticketDir, 'page-objects'),
40
+ runsDir: join(ticketDir, 'runs'),
41
+ metaPath: join(ticketDir, 'meta.json'),
42
+ statusPath: join(ticketDir, 'status.json'),
43
+ logPath: join(ticketDir, 'xera.log'),
44
+ lockPath: join(ticketDir, '.lock'),
45
+ authDir: join(repoRoot, '.xera', '.auth'),
46
+ runPath: (runId: string) => {
47
+ const runDir = join(ticketDir, 'runs', runId);
48
+ return {
49
+ runDir,
50
+ reportJsonPath: join(runDir, 'report.json'),
51
+ tracePath: join(runDir, 'trace.zip'),
52
+ normalizedPath: join(runDir, 'normalized.json'),
53
+ screenshotsDir: join(runDir, 'screenshots'),
54
+ videoDir: join(runDir, 'videos'),
55
+ };
56
+ },
57
+ };
58
+ }
59
+
60
+ export function generateRunId(now: Date = new Date()): string {
61
+ return now.toISOString().replace(/[:.]/g, '-').replace('Z', '');
62
+ }
@@ -0,0 +1,55 @@
1
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
2
+ import { dirname } from 'node:path';
3
+ import { z } from 'zod';
4
+
5
+ const ClassificationEnum = z.enum(['PASS', 'REAL_BUG', 'SELECTOR_DRIFT', 'FLAKY', 'TEST_BUG']);
6
+ const ResultEnum = z.enum(['PASS', 'FAIL']);
7
+ const ConfidenceEnum = z.enum(['low', 'medium', 'high']);
8
+
9
+ export const HistoryEntrySchema = z.object({
10
+ ts: z.string(),
11
+ result: ResultEnum,
12
+ class: ClassificationEnum,
13
+ });
14
+
15
+ export const StatusJsonSchema = z.object({
16
+ ticket: z.string(),
17
+ lastRun: z.string(),
18
+ result: ResultEnum,
19
+ classification: ClassificationEnum,
20
+ confidence: ConfidenceEnum,
21
+ scenarios: z.object({
22
+ total: z.number().int().nonnegative(),
23
+ passed: z.number().int().nonnegative(),
24
+ failed: z.number().int().nonnegative(),
25
+ skipped: z.number().int().nonnegative(),
26
+ }),
27
+ history: z.array(HistoryEntrySchema).default([]),
28
+ last_jira_comment_id: z.string().optional(),
29
+ });
30
+
31
+ export type StatusJson = z.infer<typeof StatusJsonSchema>;
32
+ export type HistoryEntry = z.infer<typeof HistoryEntrySchema>;
33
+ export type Classification = z.infer<typeof ClassificationEnum>;
34
+
35
+ const HISTORY_CAP = 20;
36
+
37
+ export function readStatus(path: string): StatusJson | null {
38
+ if (!existsSync(path)) return null;
39
+ return StatusJsonSchema.parse(JSON.parse(readFileSync(path, 'utf8')));
40
+ }
41
+
42
+ export function writeStatus(path: string, status: StatusJson): void {
43
+ mkdirSync(dirname(path), { recursive: true });
44
+ writeFileSync(path, JSON.stringify(status, null, 2));
45
+ }
46
+
47
+ export function appendHistory(path: string, entry: HistoryEntry): StatusJson {
48
+ const s = readStatus(path);
49
+ if (!s) {
50
+ throw new Error(`status.json not found at ${path}`);
51
+ }
52
+ s.history = [entry, ...s.history].slice(0, HISTORY_CAP);
53
+ writeStatus(path, s);
54
+ return s;
55
+ }
@@ -0,0 +1,40 @@
1
+ import { randomBytes, createCipheriv, createDecipheriv } from 'node:crypto';
2
+
3
+ const ALGO = 'aes-256-gcm';
4
+ const KEY_LEN = 32; // bytes (256 bits)
5
+ const IV_LEN = 12; // recommended for GCM
6
+ const TAG_LEN = 16;
7
+ const VERSION = 'v1';
8
+
9
+ export function generateKey(): string {
10
+ return randomBytes(KEY_LEN).toString('hex');
11
+ }
12
+
13
+ function keyToBuf(key: string): Buffer {
14
+ const buf = Buffer.from(key, 'hex');
15
+ if (buf.length !== KEY_LEN) throw new Error(`Key must be ${KEY_LEN} bytes (got ${buf.length})`);
16
+ return buf;
17
+ }
18
+
19
+ export function encrypt(plaintext: string, keyHex: string): string {
20
+ const key = keyToBuf(keyHex);
21
+ const iv = randomBytes(IV_LEN);
22
+ const cipher = createCipheriv(ALGO, key, iv);
23
+ const ct = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
24
+ const tag = cipher.getAuthTag();
25
+ return `${VERSION}:${iv.toString('base64')}:${tag.toString('base64')}:${ct.toString('base64')}`;
26
+ }
27
+
28
+ export function decrypt(ciphertext: string, keyHex: string): string {
29
+ const [version, ivB64, tagB64, ctB64] = ciphertext.split(':');
30
+ if (version !== VERSION) throw new Error(`Unsupported ciphertext version: ${version}`);
31
+ if (!ivB64 || !tagB64 || !ctB64) throw new Error('Malformed ciphertext');
32
+ const key = keyToBuf(keyHex);
33
+ const iv = Buffer.from(ivB64, 'base64');
34
+ const tag = Buffer.from(tagB64, 'base64');
35
+ const ct = Buffer.from(ctB64, 'base64');
36
+ if (tag.length !== TAG_LEN) throw new Error('Bad auth tag length');
37
+ const decipher = createDecipheriv(ALGO, key, iv);
38
+ decipher.setAuthTag(tag);
39
+ return Buffer.concat([decipher.update(ct), decipher.final()]).toString('utf8');
40
+ }
@@ -0,0 +1,15 @@
1
+ export const AUTH_KEY_ENV = 'XERA_AUTH_KEY';
2
+
3
+ export function resolveAuthKey(): string {
4
+ const key = process.env[AUTH_KEY_ENV];
5
+ if (!key) {
6
+ throw new Error(
7
+ `${AUTH_KEY_ENV} not set. It is auto-generated by \`xera init\` and saved to .env. ` +
8
+ `If you deleted .env, regenerate it by running \`xera init --update\` — note that any cached auth state will be invalidated.`,
9
+ );
10
+ }
11
+ if (!/^[0-9a-f]{64}$/i.test(key)) {
12
+ throw new Error(`${AUTH_KEY_ENV} must be a 64-character hex string (32 bytes).`);
13
+ }
14
+ return key;
15
+ }
@@ -0,0 +1,31 @@
1
+ import type { AuthStateEntry } from './state';
2
+ export type { AuthStateEntry } from './state';
3
+
4
+ const RE = /^(\d+)([hms])$/;
5
+
6
+ export function parseDuration(d: string): number {
7
+ const m = RE.exec(d);
8
+ if (!m) throw new Error(`Bad duration "${d}" — expected e.g. "8h", "30m", "45s"`);
9
+ const n = Number(m[1]);
10
+ const unit = m[2]!;
11
+ if (unit === 'h') return n * 3600 * 1000;
12
+ if (unit === 'm') return n * 60 * 1000;
13
+ return n * 1000;
14
+ }
15
+
16
+ export interface RefreshPolicy { ttl: string; refreshBuffer: string; }
17
+
18
+ export function needsRefresh(
19
+ entry: AuthStateEntry | null,
20
+ policy: RefreshPolicy,
21
+ now: Date = new Date(),
22
+ ): boolean {
23
+ if (!entry) return true;
24
+ const ttlMs = parseDuration(policy.ttl);
25
+ const bufMs = parseDuration(policy.refreshBuffer);
26
+ const createdAt = new Date(entry.created_at).getTime();
27
+ if (now.getTime() - createdAt > ttlMs) return true;
28
+ const expiresAt = new Date(entry.expires_at).getTime();
29
+ if (expiresAt - now.getTime() < bufMs) return true;
30
+ return false;
31
+ }
@@ -0,0 +1,32 @@
1
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { z } from 'zod';
4
+ import { encrypt, decrypt } from './encrypt';
5
+ import { resolveAuthKey } from './key';
6
+
7
+ export const AuthStateEntrySchema = z.object({
8
+ role: z.string(),
9
+ strategy: z.enum(['storageState', 'apiToken']),
10
+ created_at: z.string(),
11
+ expires_at: z.string(),
12
+ payload: z.record(z.string(), z.unknown()),
13
+ });
14
+ export type AuthStateEntry = z.infer<typeof AuthStateEntrySchema>;
15
+
16
+ function pathFor(authDir: string, role: string): string {
17
+ return join(authDir, `${role}.json`);
18
+ }
19
+
20
+ export function writeAuthState(authDir: string, entry: AuthStateEntry): void {
21
+ mkdirSync(authDir, { recursive: true });
22
+ const ct = encrypt(JSON.stringify(entry), resolveAuthKey());
23
+ writeFileSync(pathFor(authDir, entry.role), ct);
24
+ }
25
+
26
+ export function readAuthState(authDir: string, role: string): AuthStateEntry | null {
27
+ const p = pathFor(authDir, role);
28
+ if (!existsSync(p)) return null;
29
+ const txt = readFileSync(p, 'utf8');
30
+ const plain = decrypt(txt, resolveAuthKey());
31
+ return AuthStateEntrySchema.parse(JSON.parse(plain));
32
+ }
@@ -0,0 +1,107 @@
1
+ import { resolveArtifactPaths, generateRunId } from '../artifact/paths';
2
+ import { acquireLock, releaseLock, isLockStale, readLock, forceUnlock } from '../lock/file-lock';
3
+ import { NdjsonLogger } from '../logging/ndjson-logger';
4
+ import { loadConfig } from '../config/load';
5
+ import { readAuthState } from '../auth/state';
6
+ import { needsRefresh } from '../auth/refresh';
7
+ import { stagePlaywrightState, runAuthSetup, runPlaywright } from '@xera-ai/web';
8
+ import { chromium } from '@playwright/test';
9
+ import { mkdirSync, existsSync } from 'node:fs';
10
+ import { join } from 'node:path';
11
+
12
+ export async function execCmd(argv: string[]): Promise<number> {
13
+ const ticket = argv[0];
14
+ if (!ticket) { console.error('[xera:exec] usage: exec <TICKET>'); return 1; }
15
+ const cwd = process.cwd();
16
+ const config = await loadConfig(cwd);
17
+ const paths = resolveArtifactPaths(cwd, ticket);
18
+ const runId = generateRunId();
19
+ const log = new NdjsonLogger(paths.logPath);
20
+
21
+ // Acquire lock
22
+ if (!acquireLock(paths.lockPath, runId)) {
23
+ if (isLockStale(paths.lockPath)) {
24
+ console.error(`[xera:exec] stale lock detected; force unlocking. Run \`xera-internal unlock ${ticket}\` to clear manually.`);
25
+ forceUnlock(paths.lockPath);
26
+ acquireLock(paths.lockPath, runId);
27
+ } else {
28
+ const existing = readLock(paths.lockPath);
29
+ console.error(`[xera:exec] another run in progress (PID ${existing?.pid} on ${existing?.hostname}, started ${existing?.started_at}). Wait or run \`xera-internal unlock ${ticket}\`.`);
30
+ return 1;
31
+ }
32
+ }
33
+
34
+ const t0 = Date.now();
35
+ try {
36
+ // Auth refresh per role declared in xera.config.ts
37
+ if (config.web.auth.strategy === 'storageState' && config.web.auth.setupScript) {
38
+ const browser = await chromium.launch();
39
+ try {
40
+ for (const [roleName, roleCreds] of Object.entries(config.web.auth.roles)) {
41
+ const entry = readAuthState(paths.authDir, roleName);
42
+ if (needsRefresh(entry, { ttl: config.web.auth.ttl, refreshBuffer: config.web.auth.refreshBuffer })) {
43
+ const email = process.env[roleCreds.envEmail];
44
+ const password = process.env[roleCreds.envPassword];
45
+ if (!email || !password) {
46
+ console.error(`[xera:exec] missing env ${roleCreds.envEmail} or ${roleCreds.envPassword} for role "${roleName}"`);
47
+ return 1;
48
+ }
49
+ await runAuthSetup({
50
+ role: roleName,
51
+ creds: { email, password },
52
+ setupScriptPath: join(cwd, config.web.auth.setupScript),
53
+ authDir: paths.authDir,
54
+ browser,
55
+ });
56
+ log.log({ step: 'auth-refresh', role: roleName });
57
+ }
58
+ }
59
+ } finally {
60
+ await browser.close();
61
+ }
62
+ }
63
+
64
+ // Stage Playwright storageState files at predictable paths
65
+ // (.xera/.auth/.cache/<role>.json) — generated spec.ts references these
66
+ // via test.use({ storageState }) when an authenticated session is needed.
67
+ if (config.web.auth.strategy === 'storageState') {
68
+ for (const roleName of Object.keys(config.web.auth.roles)) {
69
+ if (readAuthState(paths.authDir, roleName)) {
70
+ stagePlaywrightState(paths.authDir, roleName);
71
+ }
72
+ }
73
+ }
74
+
75
+ // Use the root playwright.config.ts (generated by `xera init`). We never
76
+ // emit a per-ticket config — that path duplicates the root and bit-rots.
77
+ const cfgPath = join(cwd, 'playwright.config.ts');
78
+ if (!existsSync(cfgPath)) {
79
+ console.error(
80
+ `[xera:exec] missing ${cfgPath}. Run \`xera init\` to scaffold it, then re-run.`,
81
+ );
82
+ return 1;
83
+ }
84
+
85
+ const runDir = paths.runPath(runId).runDir;
86
+ mkdirSync(runDir, { recursive: true });
87
+
88
+ const envName = process.env.XERA_ENV ?? config.web.defaultEnv;
89
+ const baseURL = config.web.baseUrl[envName] ?? config.web.baseUrl[config.web.defaultEnv]!;
90
+
91
+ log.log({ step: 'exec.start', runId, env: envName, baseURL });
92
+ const r = await runPlaywright({
93
+ specPath: paths.specPath,
94
+ configPath: cfgPath,
95
+ outputDir: runDir,
96
+ env: { XERA_BASE_URL: baseURL, XERA_ENV: envName },
97
+ });
98
+ log.log({ step: 'exec.done', runId, exit: r.exitCode, ms: Date.now() - t0 });
99
+
100
+ console.log(`[xera:exec] runId=${runId} outcome=${r.outcome}`);
101
+ // Exit 3 means "test failed" (expected vs infra error)
102
+ return r.outcome === 'PASS' ? 0 : 3;
103
+ } finally {
104
+ releaseLock(paths.lockPath);
105
+ }
106
+ }
107
+