@devosurf/tesser 0.1.0-alpha.0
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.
- package/LICENSE +202 -0
- package/README.md +41 -0
- package/bin/tesser.mjs +2 -0
- package/dist/index.js +2361 -0
- package/dist/index.js.map +7 -0
- package/package.json +34 -0
- package/src/client.ts +63 -0
- package/src/commands/auth.test.ts +19 -0
- package/src/commands/auth.ts +154 -0
- package/src/commands/deploy.ts +80 -0
- package/src/commands/dev.ts +119 -0
- package/src/commands/init.ts +101 -0
- package/src/commands/replay.ts +81 -0
- package/src/commands/test.ts +149 -0
- package/src/config.ts +87 -0
- package/src/exit-codes.ts +24 -0
- package/src/index.ts +508 -0
- package/src/output.ts +47 -0
- package/src/project.ts +51 -0
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
// `tesser test` (ADR-0008): the agent's fast pass/fail loop. Colocated tests run via the
|
|
2
|
+
// project's own vitest (subprocess, JSON reporter — never named in our surface); every
|
|
3
|
+
// automation WITHOUT a colocated test gets the auto-generated smoke test. Failures are
|
|
4
|
+
// machine-actionable on stdout with --json; exit 3 on red.
|
|
5
|
+
|
|
6
|
+
import { execFile } from "node:child_process";
|
|
7
|
+
import { existsSync } from "node:fs";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
import { promisify } from "node:util";
|
|
10
|
+
import { smokeTest } from "@devosurf/tesser-testing";
|
|
11
|
+
import { EXIT } from "../exit-codes.js";
|
|
12
|
+
import { CliError, Output } from "../output.js";
|
|
13
|
+
import { discoverLocalAutomations, loadAutomationDef } from "../project.js";
|
|
14
|
+
|
|
15
|
+
const exec = promisify(execFile);
|
|
16
|
+
|
|
17
|
+
interface TestReport {
|
|
18
|
+
passed: boolean;
|
|
19
|
+
colocated: { ran: number; passed: number; failed: number; skippedNoRunner?: boolean };
|
|
20
|
+
smoke: Array<{ automation: string; passed: boolean; reason?: string; failure?: unknown }>;
|
|
21
|
+
failures: Array<{
|
|
22
|
+
kind: "test" | "smoke";
|
|
23
|
+
automation?: string;
|
|
24
|
+
file?: string;
|
|
25
|
+
name?: string;
|
|
26
|
+
message: string;
|
|
27
|
+
}>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function findVitest(projectRoot: string): string | null {
|
|
31
|
+
let dir = projectRoot;
|
|
32
|
+
for (;;) {
|
|
33
|
+
const bin = join(dir, "node_modules", ".bin", process.platform === "win32" ? "vitest.cmd" : "vitest");
|
|
34
|
+
if (existsSync(bin)) return bin;
|
|
35
|
+
const parent = join(dir, "..");
|
|
36
|
+
if (parent === dir) return null;
|
|
37
|
+
dir = parent;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export async function runTests(
|
|
42
|
+
out: Output,
|
|
43
|
+
projectRoot: string,
|
|
44
|
+
opts: { smokeOnly?: boolean; filter?: string | undefined },
|
|
45
|
+
): Promise<never> {
|
|
46
|
+
const automations = discoverLocalAutomations(projectRoot).filter(
|
|
47
|
+
(a) => opts.filter === undefined || a.automationId === opts.filter,
|
|
48
|
+
);
|
|
49
|
+
if (automations.length === 0) {
|
|
50
|
+
throw new CliError(EXIT.USAGE, `no automations found under ${join(projectRoot, "automations")}`);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const report: TestReport = {
|
|
54
|
+
passed: true,
|
|
55
|
+
colocated: { ran: 0, passed: 0, failed: 0 },
|
|
56
|
+
smoke: [],
|
|
57
|
+
failures: [],
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
// ---- colocated tests through the project's test runner ----
|
|
61
|
+
const withTests = automations.filter((a) => a.hasTests);
|
|
62
|
+
if (!opts.smokeOnly && withTests.length > 0) {
|
|
63
|
+
const vitestBin = findVitest(projectRoot);
|
|
64
|
+
if (!vitestBin) {
|
|
65
|
+
report.colocated.skippedNoRunner = true;
|
|
66
|
+
out.log("note: no test runner installed in the project — running smoke tests only");
|
|
67
|
+
} else {
|
|
68
|
+
const args = ["run", "--reporter=json", ...withTests.map((a) => a.dir)];
|
|
69
|
+
const res = await exec(vitestBin, args, {
|
|
70
|
+
cwd: projectRoot,
|
|
71
|
+
env: { ...process.env, CI: "1" },
|
|
72
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
73
|
+
}).catch((err: { stdout?: string; stderr?: string }) => ({
|
|
74
|
+
stdout: err.stdout ?? "",
|
|
75
|
+
stderr: err.stderr ?? "",
|
|
76
|
+
}));
|
|
77
|
+
const jsonLine = (res.stdout ?? "").split("\n").find((l) => l.trimStart().startsWith("{"));
|
|
78
|
+
if (!jsonLine) {
|
|
79
|
+
report.passed = false;
|
|
80
|
+
report.failures.push({ kind: "test", message: `test runner produced no JSON report: ${(res.stderr ?? "").slice(0, 800)}` });
|
|
81
|
+
} else {
|
|
82
|
+
const parsed = JSON.parse(jsonLine) as {
|
|
83
|
+
numTotalTests: number;
|
|
84
|
+
numPassedTests: number;
|
|
85
|
+
numFailedTests: number;
|
|
86
|
+
testResults: Array<{
|
|
87
|
+
name: string;
|
|
88
|
+
assertionResults: Array<{ status: string; fullName: string; failureMessages: string[] }>;
|
|
89
|
+
}>;
|
|
90
|
+
};
|
|
91
|
+
report.colocated.ran = parsed.numTotalTests;
|
|
92
|
+
report.colocated.passed = parsed.numPassedTests;
|
|
93
|
+
report.colocated.failed = parsed.numFailedTests;
|
|
94
|
+
if (parsed.numFailedTests > 0) {
|
|
95
|
+
report.passed = false;
|
|
96
|
+
for (const file of parsed.testResults) {
|
|
97
|
+
for (const t of file.assertionResults) {
|
|
98
|
+
if (t.status === "failed") {
|
|
99
|
+
report.failures.push({
|
|
100
|
+
kind: "test",
|
|
101
|
+
file: file.name,
|
|
102
|
+
name: t.fullName,
|
|
103
|
+
message: t.failureMessages.join("\n").slice(0, 2000),
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// ---- auto smoke for automations without colocated tests (and all, with --smoke-only) ----
|
|
114
|
+
const smokeTargets = opts.smokeOnly ? automations : automations.filter((a) => !a.hasTests);
|
|
115
|
+
for (const auto of smokeTargets) {
|
|
116
|
+
try {
|
|
117
|
+
const def = await loadAutomationDef(auto.entry);
|
|
118
|
+
const outcome = await smokeTest(def);
|
|
119
|
+
report.smoke.push({
|
|
120
|
+
automation: auto.automationId,
|
|
121
|
+
passed: outcome.passed,
|
|
122
|
+
...(outcome.reason !== undefined ? { reason: outcome.reason } : {}),
|
|
123
|
+
...(outcome.passed ? {} : { failure: outcome.result.failure() }),
|
|
124
|
+
});
|
|
125
|
+
if (!outcome.passed) {
|
|
126
|
+
report.passed = false;
|
|
127
|
+
report.failures.push({
|
|
128
|
+
kind: "smoke",
|
|
129
|
+
automation: auto.automationId,
|
|
130
|
+
message: outcome.reason ?? "smoke test failed",
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
} catch (err) {
|
|
134
|
+
report.passed = false;
|
|
135
|
+
report.smoke.push({ automation: auto.automationId, passed: false, reason: String(err) });
|
|
136
|
+
report.failures.push({ kind: "smoke", automation: auto.automationId, message: String(err).slice(0, 2000) });
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
out.data(report, (r: TestReport) => {
|
|
141
|
+
const lines = [
|
|
142
|
+
`colocated: ${r.colocated.passed}/${r.colocated.ran} passed${r.colocated.skippedNoRunner ? " (runner missing)" : ""}`,
|
|
143
|
+
...r.smoke.map((s) => `smoke ${s.automation}: ${s.passed ? "✓" : `✗ ${s.reason ?? ""}`}`),
|
|
144
|
+
r.passed ? "PASS" : `FAIL (${r.failures.length} failure${r.failures.length === 1 ? "" : "s"})`,
|
|
145
|
+
];
|
|
146
|
+
return lines.join("\n");
|
|
147
|
+
});
|
|
148
|
+
process.exit(report.passed ? EXIT.OK : EXIT.TESTS_FAILED);
|
|
149
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// Profiles + the link manifest. Token resolution: --token > TESSER_TOKEN > profile.
|
|
2
|
+
// Instance URL: --url > tesser.json > TESSER_URL > profile > localhost default.
|
|
3
|
+
|
|
4
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { homedir } from "node:os";
|
|
6
|
+
import { dirname, join } from "node:path";
|
|
7
|
+
|
|
8
|
+
export interface Profile {
|
|
9
|
+
url?: string;
|
|
10
|
+
token?: string;
|
|
11
|
+
}
|
|
12
|
+
export interface CliConfig {
|
|
13
|
+
current?: string;
|
|
14
|
+
profiles?: Record<string, Profile>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** tesser.json — the link manifest: ties this repo (Project) to an instance (ADR-0006). */
|
|
18
|
+
export interface LinkManifest {
|
|
19
|
+
project: string;
|
|
20
|
+
instance?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const CONFIG_PATH = join(
|
|
24
|
+
process.env["TESSER_CONFIG_DIR"] ?? join(homedir(), ".config", "tesser"),
|
|
25
|
+
"config.json",
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
export function readConfig(): CliConfig {
|
|
29
|
+
try {
|
|
30
|
+
return JSON.parse(readFileSync(CONFIG_PATH, "utf8")) as CliConfig;
|
|
31
|
+
} catch {
|
|
32
|
+
return {};
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function writeConfig(config: CliConfig): void {
|
|
37
|
+
mkdirSync(dirname(CONFIG_PATH), { recursive: true });
|
|
38
|
+
writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2) + "\n", { mode: 0o600 });
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function activeProfile(config: CliConfig, name?: string): Profile {
|
|
42
|
+
const profileName = name ?? config.current ?? "default";
|
|
43
|
+
return config.profiles?.[profileName] ?? {};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function findProjectRoot(start = process.cwd()): string | null {
|
|
47
|
+
let dir = start;
|
|
48
|
+
for (;;) {
|
|
49
|
+
if (existsSync(join(dir, "tesser.json"))) return dir;
|
|
50
|
+
const parent = dirname(dir);
|
|
51
|
+
if (parent === dir) return null;
|
|
52
|
+
dir = parent;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function readLinkManifest(root: string): LinkManifest | null {
|
|
57
|
+
try {
|
|
58
|
+
return JSON.parse(readFileSync(join(root, "tesser.json"), "utf8")) as LinkManifest;
|
|
59
|
+
} catch {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface ResolvedTarget {
|
|
65
|
+
url: string;
|
|
66
|
+
token: string | undefined;
|
|
67
|
+
project: string | undefined;
|
|
68
|
+
projectRoot: string | null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function resolveTarget(opts: { url?: string; token?: string; profile?: string }): ResolvedTarget {
|
|
72
|
+
const config = readConfig();
|
|
73
|
+
const profile = activeProfile(config, opts.profile);
|
|
74
|
+
const projectRoot = findProjectRoot();
|
|
75
|
+
const manifest = projectRoot ? readLinkManifest(projectRoot) : null;
|
|
76
|
+
return {
|
|
77
|
+
url:
|
|
78
|
+
opts.url ??
|
|
79
|
+
manifest?.instance ??
|
|
80
|
+
process.env["TESSER_URL"] ??
|
|
81
|
+
profile.url ??
|
|
82
|
+
"http://localhost:8377",
|
|
83
|
+
token: opts.token ?? process.env["TESSER_TOKEN"] ?? profile.token,
|
|
84
|
+
project: manifest?.project,
|
|
85
|
+
projectRoot,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// The deterministic exit-code taxonomy (ADR-0007). Agents branch on these; never reuse
|
|
2
|
+
// a number for a different meaning.
|
|
3
|
+
|
|
4
|
+
export const EXIT = {
|
|
5
|
+
OK: 0,
|
|
6
|
+
/** Unexpected/internal error. */
|
|
7
|
+
ERROR: 1,
|
|
8
|
+
/** Usage error — bad arguments or no linked project. */
|
|
9
|
+
USAGE: 2,
|
|
10
|
+
/** Tests failed (machine-actionable detail on stdout with --json). */
|
|
11
|
+
TESTS_FAILED: 3,
|
|
12
|
+
/** Deploy halted: credentials needed — a connect link is on stdout. */
|
|
13
|
+
HALTED_CREDENTIALS: 4,
|
|
14
|
+
/** Could not reach or authenticate against the instance. */
|
|
15
|
+
AUTH: 5,
|
|
16
|
+
/** Resource not found. */
|
|
17
|
+
NOT_FOUND: 6,
|
|
18
|
+
/** Conflict / invalid state for the requested operation. */
|
|
19
|
+
CONFLICT: 7,
|
|
20
|
+
/** Deploy failed (build error or red test gate). */
|
|
21
|
+
DEPLOY_FAILED: 8,
|
|
22
|
+
} as const;
|
|
23
|
+
|
|
24
|
+
export type ExitCode = (typeof EXIT)[keyof typeof EXIT];
|