@weapp-vite/acceptance 0.0.0-stage → 0.0.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 +21 -0
- package/dist/index.d.mts +288 -0
- package/dist/index.mjs +1017 -0
- package/package.json +42 -3
- package/README.md +0 -3
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 WeappJS contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
//#region ../agent-core/dist/security-DIrnqlSa.d.mts
|
|
3
|
+
//#region src/config.d.ts
|
|
4
|
+
declare const verificationSchema: z.ZodObject<{
|
|
5
|
+
kind: z.ZodEnum<{
|
|
6
|
+
typecheck: "typecheck";
|
|
7
|
+
build: "build";
|
|
8
|
+
test: "test";
|
|
9
|
+
devtools: "devtools";
|
|
10
|
+
}>;
|
|
11
|
+
command: z.ZodString;
|
|
12
|
+
args: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
|
13
|
+
timeoutMs: z.ZodDefault<z.ZodNumber>;
|
|
14
|
+
}, z.core.$strict>;
|
|
15
|
+
declare const mcpSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|
16
|
+
name: z.ZodString;
|
|
17
|
+
transport: z.ZodLiteral<"stdio">;
|
|
18
|
+
command: z.ZodString;
|
|
19
|
+
args: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
|
20
|
+
}, z.core.$strict>, z.ZodObject<{
|
|
21
|
+
name: z.ZodString;
|
|
22
|
+
transport: z.ZodLiteral<"http">;
|
|
23
|
+
url: z.ZodURL;
|
|
24
|
+
tokenEnv: z.ZodOptional<z.ZodString>;
|
|
25
|
+
}, z.core.$strict>], "transport">;
|
|
26
|
+
declare const projectConfigSchema: z.ZodObject<{
|
|
27
|
+
version: z.ZodDefault<z.ZodLiteral<1>>;
|
|
28
|
+
model: z.ZodOptional<z.ZodObject<{
|
|
29
|
+
provider: z.ZodEnum<{
|
|
30
|
+
openai: "openai";
|
|
31
|
+
anthropic: "anthropic";
|
|
32
|
+
"openai-compatible": "openai-compatible";
|
|
33
|
+
}>;
|
|
34
|
+
name: z.ZodString;
|
|
35
|
+
baseURL: z.ZodOptional<z.ZodURL>;
|
|
36
|
+
apiKeyEnv: z.ZodOptional<z.ZodString>;
|
|
37
|
+
}, z.core.$strict>>;
|
|
38
|
+
maxSteps: z.ZodDefault<z.ZodNumber>;
|
|
39
|
+
timeoutMs: z.ZodDefault<z.ZodNumber>;
|
|
40
|
+
contextCharacters: z.ZodDefault<z.ZodNumber>;
|
|
41
|
+
verification: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
|
42
|
+
kind: z.ZodEnum<{
|
|
43
|
+
typecheck: "typecheck";
|
|
44
|
+
build: "build";
|
|
45
|
+
test: "test";
|
|
46
|
+
devtools: "devtools";
|
|
47
|
+
}>;
|
|
48
|
+
command: z.ZodString;
|
|
49
|
+
args: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
|
50
|
+
timeoutMs: z.ZodDefault<z.ZodNumber>;
|
|
51
|
+
}, z.core.$strict>>>;
|
|
52
|
+
mcp: z.ZodDefault<z.ZodArray<z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|
53
|
+
name: z.ZodString;
|
|
54
|
+
transport: z.ZodLiteral<"stdio">;
|
|
55
|
+
command: z.ZodString;
|
|
56
|
+
args: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
|
57
|
+
}, z.core.$strict>, z.ZodObject<{
|
|
58
|
+
name: z.ZodString;
|
|
59
|
+
transport: z.ZodLiteral<"http">;
|
|
60
|
+
url: z.ZodURL;
|
|
61
|
+
tokenEnv: z.ZodOptional<z.ZodString>;
|
|
62
|
+
}, z.core.$strict>], "transport">>>;
|
|
63
|
+
acceptance: z.ZodDefault<z.ZodObject<{
|
|
64
|
+
requiredChecks: z.ZodDefault<z.ZodArray<z.ZodEnum<{
|
|
65
|
+
typecheck: "typecheck";
|
|
66
|
+
build: "build";
|
|
67
|
+
test: "test";
|
|
68
|
+
devtools: "devtools";
|
|
69
|
+
}>>>;
|
|
70
|
+
scenarios: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
|
71
|
+
timeoutMs: z.ZodDefault<z.ZodNumber>;
|
|
72
|
+
}, z.core.$strict>>;
|
|
73
|
+
}, z.core.$strict>;
|
|
74
|
+
type ProjectConfig = z.infer<typeof projectConfigSchema>;
|
|
75
|
+
type VerificationCommand = z.infer<typeof verificationSchema>;
|
|
76
|
+
type McpConfig = z.infer<typeof mcpSchema>;
|
|
77
|
+
declare function configurationSource(config: ProjectConfig): string | undefined;
|
|
78
|
+
declare function loadAcceptanceConfig(root: string, explicit?: string): Promise<ProjectConfig | undefined>;
|
|
79
|
+
declare function stateRoot(): string;
|
|
80
|
+
declare function projectFingerprint(root: string, config: ProjectConfig): Promise<string>;
|
|
81
|
+
//#endregion
|
|
82
|
+
//#region src/types.d.ts
|
|
83
|
+
interface ImageInput {
|
|
84
|
+
type: 'image';
|
|
85
|
+
data: string;
|
|
86
|
+
mediaType: string;
|
|
87
|
+
}
|
|
88
|
+
interface ToolResult {
|
|
89
|
+
text: string;
|
|
90
|
+
images?: ImageInput[];
|
|
91
|
+
data?: unknown;
|
|
92
|
+
}
|
|
93
|
+
interface Approval {
|
|
94
|
+
kind: 'trust' | 'command' | 'external' | 'mcp' | 'publish';
|
|
95
|
+
summary: string;
|
|
96
|
+
fingerprint: string;
|
|
97
|
+
}
|
|
98
|
+
type Approver = (request: Approval) => Promise<boolean>;
|
|
99
|
+
interface ToolContext {
|
|
100
|
+
root: string;
|
|
101
|
+
signal: AbortSignal;
|
|
102
|
+
trusted: boolean;
|
|
103
|
+
approve: Approver;
|
|
104
|
+
}
|
|
105
|
+
interface Tool {
|
|
106
|
+
name: string;
|
|
107
|
+
description: string;
|
|
108
|
+
schema: z.ZodType;
|
|
109
|
+
/** Pure metadata. Every side effect still passes through execute's permission checks. */
|
|
110
|
+
mutates: boolean;
|
|
111
|
+
execute: (input: unknown, context: ToolContext) => Promise<ToolResult>;
|
|
112
|
+
}
|
|
113
|
+
interface ProjectInfo {
|
|
114
|
+
root: string;
|
|
115
|
+
kind: 'wevu' | 'native' | 'unknown';
|
|
116
|
+
packageManager: 'pnpm' | 'npm' | 'yarn' | 'bun';
|
|
117
|
+
sourceRoot: string;
|
|
118
|
+
outputRoot: string;
|
|
119
|
+
pages: string[];
|
|
120
|
+
subPackages: string[];
|
|
121
|
+
scripts: Record<string, string>;
|
|
122
|
+
weappViteVersion?: string;
|
|
123
|
+
warnings: string[];
|
|
124
|
+
}
|
|
125
|
+
declare function redactor(environment?: NodeJS.ProcessEnv): (text: string) => string;
|
|
126
|
+
/** Canonicalize ancestors even for new files: symlink escapes cannot bypass the boundary. */
|
|
127
|
+
declare function safePath(root: string, file: string): Promise<string>;
|
|
128
|
+
declare function redactValue<T>(value: T, clean?: (text: string) => string): T;
|
|
129
|
+
//#endregion
|
|
130
|
+
//#region src/runtime.d.ts
|
|
131
|
+
export interface RuntimeConnection {
|
|
132
|
+
tools: Tool[];
|
|
133
|
+
close: () => Promise<void>;
|
|
134
|
+
}
|
|
135
|
+
export type RuntimeConnector = (server: McpConfig, context: ToolContext, options: {
|
|
136
|
+
config: ProjectConfig;
|
|
137
|
+
fingerprint: string;
|
|
138
|
+
builtin: boolean;
|
|
139
|
+
}) => Promise<RuntimeConnection>;
|
|
140
|
+
//#endregion
|
|
141
|
+
//#region src/scenario.d.ts
|
|
142
|
+
export declare const scenarioSchema: z.ZodObject<{
|
|
143
|
+
version: z.ZodLiteral<1>;
|
|
144
|
+
name: z.ZodString;
|
|
145
|
+
steps: z.ZodArray<z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|
146
|
+
action: z.ZodLiteral<"route">;
|
|
147
|
+
path: z.ZodString;
|
|
148
|
+
}, z.core.$strict>, z.ZodObject<{
|
|
149
|
+
action: z.ZodLiteral<"find">;
|
|
150
|
+
selector: z.ZodString;
|
|
151
|
+
}, z.core.$strict>, z.ZodObject<{
|
|
152
|
+
action: z.ZodLiteral<"tap">;
|
|
153
|
+
selector: z.ZodString;
|
|
154
|
+
}, z.core.$strict>, z.ZodObject<{
|
|
155
|
+
action: z.ZodLiteral<"input">;
|
|
156
|
+
selector: z.ZodString;
|
|
157
|
+
value: z.ZodString;
|
|
158
|
+
}, z.core.$strict>, z.ZodObject<{
|
|
159
|
+
action: z.ZodLiteral<"wait">;
|
|
160
|
+
selector: z.ZodString;
|
|
161
|
+
timeoutMs: z.ZodDefault<z.ZodNumber>;
|
|
162
|
+
}, z.core.$strict>, z.ZodObject<{
|
|
163
|
+
action: z.ZodLiteral<"assert">;
|
|
164
|
+
selector: z.ZodString;
|
|
165
|
+
text: z.ZodString;
|
|
166
|
+
timeoutMs: z.ZodDefault<z.ZodNumber>;
|
|
167
|
+
}, z.core.$strict>, z.ZodObject<{
|
|
168
|
+
action: z.ZodLiteral<"screenshot">;
|
|
169
|
+
}, z.core.$strict>], "action">>;
|
|
170
|
+
}, z.core.$strict>;
|
|
171
|
+
export type Scenario = z.infer<typeof scenarioSchema>;
|
|
172
|
+
export interface ScenarioStepResult {
|
|
173
|
+
scenario: string;
|
|
174
|
+
index: number;
|
|
175
|
+
action: string;
|
|
176
|
+
status: 'running' | 'passed' | 'failed';
|
|
177
|
+
startedAt: string;
|
|
178
|
+
finishedAt?: string;
|
|
179
|
+
input: unknown;
|
|
180
|
+
actual?: unknown;
|
|
181
|
+
error?: string;
|
|
182
|
+
}
|
|
183
|
+
/** A task owns one connection; all operations use the same scoped runtime. */
|
|
184
|
+
export declare function runtimeInvoker(connection: RuntimeConnection, context: ToolContext): (name: string, args: Record<string, unknown>) => Promise<Record<string, any>>;
|
|
185
|
+
export declare function runScenario(scenario: Scenario, invoke: ReturnType<typeof runtimeInvoker>, context: ToolContext, onStep: (step: ScenarioStepResult) => Promise<void>, capture: () => Promise<string>): Promise<void>;
|
|
186
|
+
//#endregion
|
|
187
|
+
//#region src/verify.d.ts
|
|
188
|
+
export interface CheckResult {
|
|
189
|
+
kind: VerificationCommand['kind'];
|
|
190
|
+
status: 'passed' | 'failed' | 'unverified';
|
|
191
|
+
command?: string;
|
|
192
|
+
exitCode?: number;
|
|
193
|
+
timedOut?: boolean;
|
|
194
|
+
output: string;
|
|
195
|
+
}
|
|
196
|
+
export interface VerificationReport {
|
|
197
|
+
passed: boolean;
|
|
198
|
+
checks: CheckResult[];
|
|
199
|
+
}
|
|
200
|
+
export declare function verifyProject(config: ProjectConfig, context: ToolContext, originalFingerprint: string, onCheck?: (check: CheckResult) => Promise<void>, skipKinds?: CheckResult['kind'][]): Promise<VerificationReport>;
|
|
201
|
+
export declare function verificationTool(config: ProjectConfig, fingerprint: string): Tool;
|
|
202
|
+
//#endregion
|
|
203
|
+
//#region src/acceptance.d.ts
|
|
204
|
+
export type AcceptanceStatus = 'running' | 'passed' | 'failed' | 'unverified' | 'action_required' | 'cancelled' | 'timed_out' | 'interrupted';
|
|
205
|
+
export interface AcceptanceReport {
|
|
206
|
+
version: 2;
|
|
207
|
+
jobId: string;
|
|
208
|
+
root: string;
|
|
209
|
+
ownerPid: number;
|
|
210
|
+
status: AcceptanceStatus;
|
|
211
|
+
passed: boolean;
|
|
212
|
+
startedAt: string;
|
|
213
|
+
finishedAt?: string;
|
|
214
|
+
project: ProjectInfo;
|
|
215
|
+
requiredChecks: ProjectConfig['acceptance']['requiredChecks'];
|
|
216
|
+
checks: CheckResult[];
|
|
217
|
+
steps: ScenarioStepResult[];
|
|
218
|
+
artifacts: Array<{
|
|
219
|
+
name: string;
|
|
220
|
+
mediaType: string;
|
|
221
|
+
}>;
|
|
222
|
+
runtime: 'unverified' | 'wechat-devtools';
|
|
223
|
+
snapshot: {
|
|
224
|
+
before?: string;
|
|
225
|
+
after?: string;
|
|
226
|
+
current?: string;
|
|
227
|
+
stale: boolean;
|
|
228
|
+
};
|
|
229
|
+
reason?: string;
|
|
230
|
+
warnings: string[];
|
|
231
|
+
}
|
|
232
|
+
export declare function resolveProjectConfig(root: string, file?: string): Promise<ProjectConfig>;
|
|
233
|
+
export interface AcceptanceOptions {
|
|
234
|
+
/** Explicit CLI startup authorization only; never accepted as an MCP tool argument. */
|
|
235
|
+
trust?: boolean;
|
|
236
|
+
connect?: RuntimeConnector;
|
|
237
|
+
configFile?: string;
|
|
238
|
+
}
|
|
239
|
+
export declare class AcceptanceService {
|
|
240
|
+
readonly root: string;
|
|
241
|
+
private directory;
|
|
242
|
+
private options;
|
|
243
|
+
private jobs;
|
|
244
|
+
private starting;
|
|
245
|
+
private closed;
|
|
246
|
+
private constructor();
|
|
247
|
+
static create(root: string, options?: AcceptanceOptions): Promise<AcceptanceService>;
|
|
248
|
+
private jobDirectory;
|
|
249
|
+
inspect(): Promise<{
|
|
250
|
+
version: number;
|
|
251
|
+
project: ProjectInfo;
|
|
252
|
+
supported: boolean;
|
|
253
|
+
modelRequired: boolean;
|
|
254
|
+
configurationSource: string;
|
|
255
|
+
trusted: boolean;
|
|
256
|
+
localMcp: boolean;
|
|
257
|
+
requiredChecks: ("typecheck" | "build" | "test" | "devtools")[];
|
|
258
|
+
configuredChecks: ("typecheck" | "build" | "test" | "devtools")[];
|
|
259
|
+
scenarios: string[];
|
|
260
|
+
scenarioProblems: string[];
|
|
261
|
+
hasTestAppId: boolean;
|
|
262
|
+
prerequisites: string[];
|
|
263
|
+
}>;
|
|
264
|
+
start(): Promise<AcceptanceReport>;
|
|
265
|
+
private startTask;
|
|
266
|
+
private execute;
|
|
267
|
+
report(jobId: string): Promise<AcceptanceReport>;
|
|
268
|
+
wait(jobId: string): Promise<AcceptanceReport>;
|
|
269
|
+
cancel(jobId: string): Promise<AcceptanceReport>;
|
|
270
|
+
artifact(jobId: string, name: string): Promise<{
|
|
271
|
+
mediaType: string;
|
|
272
|
+
data: string;
|
|
273
|
+
}>;
|
|
274
|
+
close(): Promise<void>;
|
|
275
|
+
}
|
|
276
|
+
//#endregion
|
|
277
|
+
//#region src/project.d.ts
|
|
278
|
+
export declare function exists(file: string): Promise<boolean>;
|
|
279
|
+
export declare function findProjectRoot(cwd: string): Promise<string>;
|
|
280
|
+
export declare function detectProject(root: string): Promise<ProjectInfo>;
|
|
281
|
+
export declare function defaultVerification(project: ProjectInfo): VerificationCommand[];
|
|
282
|
+
export declare function projectInstructions(project: ProjectInfo): Promise<string>;
|
|
283
|
+
//#endregion
|
|
284
|
+
//#region src/snapshot.d.ts
|
|
285
|
+
/** Include untracked source/config files, without executing Git hooks or Vite config. */
|
|
286
|
+
export declare function sourceSnapshot(project: ProjectInfo): Promise<string>;
|
|
287
|
+
//#endregion
|
|
288
|
+
export { type ProjectConfig, type ToolContext, configurationSource, loadAcceptanceConfig, projectConfigSchema, projectFingerprint, redactValue, redactor, safePath, stateRoot };
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,1017 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { access, lstat, mkdir, readFile, readdir, readlink, realpath, rename, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import process from "node:process";
|
|
5
|
+
import { homedir } from "node:os";
|
|
6
|
+
import { z } from "zod";
|
|
7
|
+
import { setTimeout } from "node:timers/promises";
|
|
8
|
+
import { execa } from "execa";
|
|
9
|
+
//#region ../agent-core/dist/text-EVMG4Jiq.mjs
|
|
10
|
+
var ApprovalRequired = class extends Error {
|
|
11
|
+
constructor(message) {
|
|
12
|
+
super(message);
|
|
13
|
+
this.name = "ApprovalRequired";
|
|
14
|
+
}
|
|
15
|
+
};
|
|
16
|
+
function redactor(environment = process.env) {
|
|
17
|
+
const secrets = Object.entries(environment).filter(([k, v]) => /KEY|TOKEN|SECRET|PASSWORD/i.test(k) && v && v.length >= 8).map(([, v]) => v).sort((a, b) => b.length - a.length);
|
|
18
|
+
return (text) => {
|
|
19
|
+
for (const secret of secrets) text = text.split(secret).join("[REDACTED]");
|
|
20
|
+
return text.replace(/\b(sk-[\w-]{12,}|gh[pousr]_\w{16,})\b/g, "[REDACTED]").replace(/(Bearer\s+)[\w.\-/+=]{10,}/gi, "$1[REDACTED]");
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
function inside(root, target) {
|
|
24
|
+
const relative = path.relative(root, target);
|
|
25
|
+
return relative === "" || !relative.startsWith(`..${path.sep}`) && relative !== ".." && !path.isAbsolute(relative);
|
|
26
|
+
}
|
|
27
|
+
function sensitive(file) {
|
|
28
|
+
return file.split(/[\\/]/).some((part) => /^(?:\.git|\.weapp-agent|\.npmrc|\.env(?:\..+)?|.*\.(?:pem|key|p12))$/i.test(part) && part !== ".env.example");
|
|
29
|
+
}
|
|
30
|
+
/** Canonicalize ancestors even for new files: symlink escapes cannot bypass the boundary. */
|
|
31
|
+
async function safePath(root, file) {
|
|
32
|
+
const canonicalRoot = await realpath(root);
|
|
33
|
+
const target = path.resolve(canonicalRoot, file);
|
|
34
|
+
if (!inside(canonicalRoot, target) || sensitive(path.relative(canonicalRoot, target))) throw new Error(`Path is outside the permitted workspace or is sensitive: ${file}`);
|
|
35
|
+
let ancestor = target;
|
|
36
|
+
for (;;) try {
|
|
37
|
+
const resolved = await realpath(ancestor);
|
|
38
|
+
if (!inside(canonicalRoot, resolved) || sensitive(path.relative(canonicalRoot, resolved))) throw new Error(`Symlink crosses the workspace boundary: ${file}`);
|
|
39
|
+
break;
|
|
40
|
+
} catch (error) {
|
|
41
|
+
if (error.code !== "ENOENT") throw error;
|
|
42
|
+
const parent = path.dirname(ancestor);
|
|
43
|
+
if (parent === ancestor) throw error;
|
|
44
|
+
ancestor = parent;
|
|
45
|
+
}
|
|
46
|
+
try {
|
|
47
|
+
if ((await lstat(target)).isSymbolicLink()) throw new Error(`Editing or reading a symlink is not supported: ${file}`);
|
|
48
|
+
} catch (error) {
|
|
49
|
+
if (error.code !== "ENOENT") throw error;
|
|
50
|
+
}
|
|
51
|
+
return target;
|
|
52
|
+
}
|
|
53
|
+
async function requireTrust(context) {
|
|
54
|
+
if (!context.trusted) throw new ApprovalRequired("Workspace is not trusted. Review its configuration, then run with --trust.");
|
|
55
|
+
}
|
|
56
|
+
async function authorize(context, kind, summary, payload) {
|
|
57
|
+
if (!await context.approve({
|
|
58
|
+
kind,
|
|
59
|
+
summary,
|
|
60
|
+
fingerprint: hash(JSON.stringify(payload))
|
|
61
|
+
})) throw new ApprovalRequired(`Approval required: ${summary}`);
|
|
62
|
+
}
|
|
63
|
+
function redactValue(value, clean = redactor()) {
|
|
64
|
+
if (typeof value === "string") return clean(value);
|
|
65
|
+
if (Array.isArray(value)) return value.map((v) => redactValue(v, clean));
|
|
66
|
+
if (value && typeof value === "object") return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, redactValue(v, clean)]));
|
|
67
|
+
return value;
|
|
68
|
+
}
|
|
69
|
+
const verificationSchema = z.strictObject({
|
|
70
|
+
kind: z.enum([
|
|
71
|
+
"typecheck",
|
|
72
|
+
"build",
|
|
73
|
+
"test",
|
|
74
|
+
"devtools"
|
|
75
|
+
]),
|
|
76
|
+
command: z.string().min(1),
|
|
77
|
+
args: z.array(z.string()).default([]),
|
|
78
|
+
timeoutMs: z.number().int().positive().max(18e5).default(12e4)
|
|
79
|
+
});
|
|
80
|
+
const mcpSchema = z.discriminatedUnion("transport", [z.strictObject({
|
|
81
|
+
name: z.string().regex(/^[\w-]+$/),
|
|
82
|
+
transport: z.literal("stdio"),
|
|
83
|
+
command: z.string(),
|
|
84
|
+
args: z.array(z.string()).default([])
|
|
85
|
+
}), z.strictObject({
|
|
86
|
+
name: z.string().regex(/^[\w-]+$/),
|
|
87
|
+
transport: z.literal("http"),
|
|
88
|
+
url: z.url(),
|
|
89
|
+
tokenEnv: z.string().optional()
|
|
90
|
+
})]);
|
|
91
|
+
const modelSchema = z.strictObject({
|
|
92
|
+
provider: z.enum([
|
|
93
|
+
"openai",
|
|
94
|
+
"anthropic",
|
|
95
|
+
"openai-compatible"
|
|
96
|
+
]),
|
|
97
|
+
name: z.string().min(1),
|
|
98
|
+
baseURL: z.url().optional(),
|
|
99
|
+
apiKeyEnv: z.string().regex(/^[A-Z_][A-Z0-9_]*$/).optional()
|
|
100
|
+
});
|
|
101
|
+
const projectConfigSchema = z.strictObject({
|
|
102
|
+
version: z.literal(1).default(1),
|
|
103
|
+
model: modelSchema.optional(),
|
|
104
|
+
maxSteps: z.number().int().min(1).max(500).default(40),
|
|
105
|
+
timeoutMs: z.number().int().positive().default(6e5),
|
|
106
|
+
contextCharacters: z.number().int().min(8e3).default(1e5),
|
|
107
|
+
verification: z.array(verificationSchema).default([]),
|
|
108
|
+
mcp: z.array(mcpSchema).default([]),
|
|
109
|
+
acceptance: z.strictObject({
|
|
110
|
+
requiredChecks: z.array(z.enum([
|
|
111
|
+
"typecheck",
|
|
112
|
+
"build",
|
|
113
|
+
"test",
|
|
114
|
+
"devtools"
|
|
115
|
+
])).min(1).default(["build", "devtools"]),
|
|
116
|
+
scenarios: z.array(z.string().min(1)).max(100).default([]),
|
|
117
|
+
timeoutMs: z.number().int().min(100).max(18e5).default(6e5)
|
|
118
|
+
}).default({
|
|
119
|
+
requiredChecks: ["build", "devtools"],
|
|
120
|
+
scenarios: [],
|
|
121
|
+
timeoutMs: 6e5
|
|
122
|
+
})
|
|
123
|
+
});
|
|
124
|
+
projectConfigSchema.extend({ model: modelSchema });
|
|
125
|
+
const configFilename = "weapp-agent.config.json";
|
|
126
|
+
const acceptanceConfigFilename = "weapp-acceptance.config.json";
|
|
127
|
+
const selectedConfigs = /* @__PURE__ */ new WeakMap();
|
|
128
|
+
function configurationSource(config) {
|
|
129
|
+
return selectedConfigs.get(config);
|
|
130
|
+
}
|
|
131
|
+
async function loadAcceptanceConfig(root, explicit) {
|
|
132
|
+
for (const file of explicit ? [explicit] : [acceptanceConfigFilename, configFilename]) {
|
|
133
|
+
const target = await safePath(root, file);
|
|
134
|
+
let raw;
|
|
135
|
+
try {
|
|
136
|
+
raw = await readFile(target, "utf8");
|
|
137
|
+
} catch (error) {
|
|
138
|
+
if (!explicit && error.code === "ENOENT") continue;
|
|
139
|
+
throw error;
|
|
140
|
+
}
|
|
141
|
+
const schema = file === "weapp-agent.config.json" ? projectConfigSchema : projectConfigSchema.pick({
|
|
142
|
+
version: true,
|
|
143
|
+
verification: true,
|
|
144
|
+
acceptance: true
|
|
145
|
+
});
|
|
146
|
+
const config = projectConfigSchema.parse(schema.parse(JSON.parse(raw)));
|
|
147
|
+
selectedConfigs.set(config, path.relative(await realpath(root), target));
|
|
148
|
+
return config;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
function hash(value) {
|
|
152
|
+
return createHash("sha256").update(value).digest("hex");
|
|
153
|
+
}
|
|
154
|
+
function stateRoot() {
|
|
155
|
+
return path.resolve(process.env.WEAPP_AGENT_STATE_DIR ?? path.join(homedir(), ".local", "state", "weapp-agent"));
|
|
156
|
+
}
|
|
157
|
+
async function projectFingerprint(root, config) {
|
|
158
|
+
let pkg = "";
|
|
159
|
+
try {
|
|
160
|
+
pkg = await readFile(path.join(root, "package.json"), "utf8");
|
|
161
|
+
} catch {}
|
|
162
|
+
let diskConfig = "";
|
|
163
|
+
try {
|
|
164
|
+
diskConfig = await readFile(await safePath(root, configurationSource(config) ?? "weapp-agent.config.json"), "utf8");
|
|
165
|
+
} catch {}
|
|
166
|
+
const scenarios = [];
|
|
167
|
+
for (const file of config.acceptance.scenarios) {
|
|
168
|
+
const target = await safePath(root, file);
|
|
169
|
+
try {
|
|
170
|
+
scenarios.push([file, await readFile(target, "utf8")]);
|
|
171
|
+
} catch (error) {
|
|
172
|
+
if (error.code !== "ENOENT") throw error;
|
|
173
|
+
scenarios.push([file, "<missing>"]);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
const projectFiles = [];
|
|
177
|
+
for (const file of [
|
|
178
|
+
"project.config.json",
|
|
179
|
+
"vite.config.ts",
|
|
180
|
+
"vite.config.js",
|
|
181
|
+
"vite.config.mts",
|
|
182
|
+
"vite.config.mjs",
|
|
183
|
+
"vite.config.cts",
|
|
184
|
+
"vite.config.cjs",
|
|
185
|
+
"pnpm-workspace.yaml",
|
|
186
|
+
"pnpm-lock.yaml",
|
|
187
|
+
"package-lock.json",
|
|
188
|
+
"yarn.lock",
|
|
189
|
+
"bun.lock"
|
|
190
|
+
]) try {
|
|
191
|
+
projectFiles.push([file, hash(await readFile(await safePath(root, file), "utf8"))]);
|
|
192
|
+
} catch (error) {
|
|
193
|
+
if (error.code !== "ENOENT") throw error;
|
|
194
|
+
}
|
|
195
|
+
return hash(JSON.stringify({
|
|
196
|
+
root,
|
|
197
|
+
config,
|
|
198
|
+
pkg,
|
|
199
|
+
diskConfig,
|
|
200
|
+
scenarios,
|
|
201
|
+
projectFiles,
|
|
202
|
+
...configurationSource(config) && configurationSource(config) !== "weapp-agent.config.json" ? { configurationSource: configurationSource(config) } : {}
|
|
203
|
+
}));
|
|
204
|
+
}
|
|
205
|
+
async function isTrusted(root, fingerprint) {
|
|
206
|
+
try {
|
|
207
|
+
return JSON.parse(await readFile(path.join(stateRoot(), "trust", `${hash(root)}.json`), "utf8")).fingerprint === fingerprint;
|
|
208
|
+
} catch {
|
|
209
|
+
return false;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
async function trustProject(root, fingerprint) {
|
|
213
|
+
const dir = path.join(stateRoot(), "trust");
|
|
214
|
+
await mkdir(dir, {
|
|
215
|
+
recursive: true,
|
|
216
|
+
mode: 448
|
|
217
|
+
});
|
|
218
|
+
await writeFile(path.join(dir, `${hash(root)}.json`), JSON.stringify({
|
|
219
|
+
root,
|
|
220
|
+
fingerprint
|
|
221
|
+
}), { mode: 384 });
|
|
222
|
+
}
|
|
223
|
+
function bounded(text, max = 4e4) {
|
|
224
|
+
return text.length > max ? `${text.slice(0, max)}\n[truncated: ${text.length - max} more characters]` : text;
|
|
225
|
+
}
|
|
226
|
+
//#endregion
|
|
227
|
+
//#region src/persistence.ts
|
|
228
|
+
/** 通过一次临时文件写入和原子替换发布完整、已脱敏的报告。 */
|
|
229
|
+
async function atomicJson(file, data, platform = process.platform) {
|
|
230
|
+
const temp = `${file}.${randomUUID()}.tmp`;
|
|
231
|
+
await writeFile(temp, JSON.stringify(redactValue(data), null, 2), { mode: 384 });
|
|
232
|
+
for (let retry = 0;; retry++) try {
|
|
233
|
+
await rename(temp, file);
|
|
234
|
+
return;
|
|
235
|
+
} catch (error) {
|
|
236
|
+
if (platform !== "win32" || retry >= 20 || ![
|
|
237
|
+
"EPERM",
|
|
238
|
+
"EACCES",
|
|
239
|
+
"EBUSY"
|
|
240
|
+
].includes(error.code ?? "")) throw error;
|
|
241
|
+
await setTimeout(Math.min((retry + 1) * 10, 100));
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
//#endregion
|
|
245
|
+
//#region src/project.ts
|
|
246
|
+
async function json(file) {
|
|
247
|
+
try {
|
|
248
|
+
return JSON.parse(await readFile(file, "utf8"));
|
|
249
|
+
} catch {
|
|
250
|
+
return {};
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
async function exists(file) {
|
|
254
|
+
try {
|
|
255
|
+
await access(file);
|
|
256
|
+
return true;
|
|
257
|
+
} catch {
|
|
258
|
+
return false;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
async function findProjectRoot(cwd) {
|
|
262
|
+
const start = await realpath(cwd);
|
|
263
|
+
let dir = start;
|
|
264
|
+
for (;;) {
|
|
265
|
+
const pkg = await json(path.join(dir, "package.json"));
|
|
266
|
+
if (await exists(path.join(dir, "weapp-agent.config.json")) || await exists(path.join(dir, "weapp-acceptance.config.json")) || pkg.dependencies?.["weapp-vite"] || pkg.devDependencies?.["weapp-vite"] || await exists(path.join(dir, "project.config.json"))) return dir;
|
|
267
|
+
const parent = path.dirname(dir);
|
|
268
|
+
if (parent === dir) return start;
|
|
269
|
+
dir = parent;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
async function detectProject(root) {
|
|
273
|
+
root = await realpath(root);
|
|
274
|
+
const pkg = await json(path.join(root, "package.json"));
|
|
275
|
+
const config = await json(path.join(root, "project.config.json"));
|
|
276
|
+
const dependencies = {
|
|
277
|
+
...pkg.dependencies,
|
|
278
|
+
...pkg.devDependencies
|
|
279
|
+
};
|
|
280
|
+
let vite = "";
|
|
281
|
+
for (const file of [
|
|
282
|
+
"vite.config.ts",
|
|
283
|
+
"vite.config.js",
|
|
284
|
+
"vite.config.mts"
|
|
285
|
+
]) if (await exists(path.join(root, file))) vite = await readFile(path.join(root, file), "utf8");
|
|
286
|
+
const sourceRoot = vite.match(/\bsrcRoot\s*:\s*['"]([^'"]+)['"]/)?.[1] ?? (await exists(path.join(root, "src")) ? "src" : ".");
|
|
287
|
+
const outputRoot = vite.match(/\boutDir\s*:\s*['"]([^'"]+)['"]/)?.[1] ?? config.miniprogramRoot ?? (dependencies["weapp-vite"] ? "dist" : ".");
|
|
288
|
+
await safePath(root, sourceRoot);
|
|
289
|
+
await safePath(root, outputRoot);
|
|
290
|
+
const app = await json(path.join(root, sourceRoot, "app.json"));
|
|
291
|
+
const sources = [];
|
|
292
|
+
const scan = async (directory, depth = 0) => {
|
|
293
|
+
if (depth > 8 || sources.length > 2e3) return;
|
|
294
|
+
const entries = await readdir(await safePath(root, directory), { withFileTypes: true }).catch(() => []);
|
|
295
|
+
for (const entry of entries) {
|
|
296
|
+
if (entry.isSymbolicLink() || entry.name.startsWith(".") || ["node_modules", "dist"].includes(entry.name)) continue;
|
|
297
|
+
const relative = path.join(directory, entry.name);
|
|
298
|
+
if (entry.isDirectory()) await scan(relative, depth + 1);
|
|
299
|
+
else if (/\.(?:vue|wxml)$/.test(entry.name)) sources.push(path.relative(sourceRoot, relative).split(path.sep).join("/"));
|
|
300
|
+
}
|
|
301
|
+
};
|
|
302
|
+
await scan(sourceRoot);
|
|
303
|
+
const hasVue = sources.some((file) => file.endsWith(".vue"));
|
|
304
|
+
const hasNative = sources.some((file) => file.endsWith(".wxml"));
|
|
305
|
+
const warnings = [];
|
|
306
|
+
if (vite && !/\bsrcRoot\s*:\s*['"]/.test(vite) && !await exists(path.join(root, sourceRoot, "app.json"))) warnings.push("Dynamic Vite configuration: source/output paths are inferred. Check project_info before editing.");
|
|
307
|
+
return {
|
|
308
|
+
root,
|
|
309
|
+
kind: hasVue && Boolean(dependencies.wevu) ? "wevu" : hasNative || dependencies["weapp-vite"] || config.appid || app.pages ? "native" : "unknown",
|
|
310
|
+
packageManager: String(pkg.packageManager ?? "").startsWith("yarn") ? "yarn" : String(pkg.packageManager ?? "").startsWith("bun") ? "bun" : await exists(path.join(root, "pnpm-lock.yaml")) || await exists(path.join(root, "pnpm-workspace.yaml")) || String(pkg.packageManager ?? "").startsWith("pnpm") ? "pnpm" : "npm",
|
|
311
|
+
sourceRoot,
|
|
312
|
+
outputRoot,
|
|
313
|
+
pages: Array.isArray(app.pages) ? app.pages : sources.filter((file) => file.startsWith("pages/")).map((file) => file.replace(/\.(vue|wxml)$/, "")),
|
|
314
|
+
subPackages: (app.subPackages ?? app.subpackages ?? []).map((p) => p.root),
|
|
315
|
+
scripts: pkg.scripts ?? {},
|
|
316
|
+
weappViteVersion: dependencies["weapp-vite"],
|
|
317
|
+
warnings
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
function defaultVerification(project) {
|
|
321
|
+
return [
|
|
322
|
+
"typecheck",
|
|
323
|
+
"build",
|
|
324
|
+
"test"
|
|
325
|
+
].filter((kind) => project.scripts[kind]).map((kind) => ({
|
|
326
|
+
kind,
|
|
327
|
+
command: project.packageManager,
|
|
328
|
+
args: ["run", kind],
|
|
329
|
+
timeoutMs: 12e4
|
|
330
|
+
}));
|
|
331
|
+
}
|
|
332
|
+
async function projectInstructions(project) {
|
|
333
|
+
const snippets = [];
|
|
334
|
+
const add = async (file) => {
|
|
335
|
+
try {
|
|
336
|
+
const target = await safePath(project.root, file);
|
|
337
|
+
snippets.push(`Instructions scoped to ${path.dirname(file)} (${file}):\n${bounded(await readFile(target, "utf8"), 12e3)}`);
|
|
338
|
+
} catch {}
|
|
339
|
+
};
|
|
340
|
+
await add("AGENTS.md");
|
|
341
|
+
await add("AGENTS.local.md");
|
|
342
|
+
const visit = async (directory, depth) => {
|
|
343
|
+
if (depth > 6 || snippets.join("").length > 32e3) return;
|
|
344
|
+
const entries = await readdir(await safePath(project.root, directory), { withFileTypes: true }).catch(() => []);
|
|
345
|
+
if (directory !== ".") {
|
|
346
|
+
if (entries.some((e) => e.name === "AGENTS.md")) await add(path.join(directory, "AGENTS.md"));
|
|
347
|
+
if (entries.some((e) => e.name === "AGENTS.local.md")) await add(path.join(directory, "AGENTS.local.md"));
|
|
348
|
+
}
|
|
349
|
+
for (const e of entries) if (e.isDirectory() && !e.isSymbolicLink() && !e.name.startsWith(".") && ![
|
|
350
|
+
"node_modules",
|
|
351
|
+
"dist",
|
|
352
|
+
"coverage"
|
|
353
|
+
].includes(e.name)) await visit(path.join(directory, e.name), depth + 1);
|
|
354
|
+
};
|
|
355
|
+
await visit(".", 0);
|
|
356
|
+
const docs = path.join(project.root, "node_modules/weapp-vite/dist/docs");
|
|
357
|
+
const names = await readdir(docs).catch(() => []);
|
|
358
|
+
for (const name of names.filter((n) => /^(?:README|ai|cli|getting-started|configuration).*\.md$/i.test(n)).slice(0, 4)) snippets.push(`Installed weapp-vite documentation (${name}):\n${bounded(await readFile(path.join(docs, name), "utf8"), 6e3)}`);
|
|
359
|
+
return `Project facts:\n${JSON.stringify(project)}\n${snippets.join("\n\n")}`;
|
|
360
|
+
}
|
|
361
|
+
//#endregion
|
|
362
|
+
//#region src/scenario.ts
|
|
363
|
+
const selector = z.string().trim().min(1);
|
|
364
|
+
const timeoutMs = z.number().int().min(100).max(6e4).default(5e3);
|
|
365
|
+
const scenarioSchema = z.strictObject({
|
|
366
|
+
version: z.literal(1),
|
|
367
|
+
name: z.string().min(1),
|
|
368
|
+
steps: z.array(z.discriminatedUnion("action", [
|
|
369
|
+
z.strictObject({
|
|
370
|
+
action: z.literal("route"),
|
|
371
|
+
path: z.string().startsWith("/")
|
|
372
|
+
}),
|
|
373
|
+
z.strictObject({
|
|
374
|
+
action: z.literal("find"),
|
|
375
|
+
selector
|
|
376
|
+
}),
|
|
377
|
+
z.strictObject({
|
|
378
|
+
action: z.literal("tap"),
|
|
379
|
+
selector
|
|
380
|
+
}),
|
|
381
|
+
z.strictObject({
|
|
382
|
+
action: z.literal("input"),
|
|
383
|
+
selector,
|
|
384
|
+
value: z.string()
|
|
385
|
+
}),
|
|
386
|
+
z.strictObject({
|
|
387
|
+
action: z.literal("wait"),
|
|
388
|
+
selector,
|
|
389
|
+
timeoutMs
|
|
390
|
+
}),
|
|
391
|
+
z.strictObject({
|
|
392
|
+
action: z.literal("assert"),
|
|
393
|
+
selector,
|
|
394
|
+
text: z.string(),
|
|
395
|
+
timeoutMs
|
|
396
|
+
}),
|
|
397
|
+
z.strictObject({ action: z.literal("screenshot") })
|
|
398
|
+
])).min(1).max(100)
|
|
399
|
+
}).refine((s) => s.steps[0]?.action === "route", "Start each scenario with a route step").refine((s) => s.steps.some((step) => step.action === "assert"), "Each scenario requires an explicit assertion");
|
|
400
|
+
/** A task owns one connection; all operations use the same scoped runtime. */
|
|
401
|
+
function runtimeInvoker(connection, context) {
|
|
402
|
+
return async (name, args) => {
|
|
403
|
+
context.signal.throwIfAborted();
|
|
404
|
+
const tool = connection.tools.find((t) => t.name === `weapp__${name}`);
|
|
405
|
+
if (!tool) throw new Error(`Missing runtime capability: ${name}. Check the installed weapp-vite version.`);
|
|
406
|
+
const result = await tool.execute(args, context);
|
|
407
|
+
const data = result.data ?? JSON.parse(result.text);
|
|
408
|
+
if (!data || typeof data !== "object" || !("result" in data)) throw new Error(`Invalid runtime result: ${name}`);
|
|
409
|
+
return data.result;
|
|
410
|
+
};
|
|
411
|
+
}
|
|
412
|
+
async function runScenario(scenario, invoke, context, onStep, capture) {
|
|
413
|
+
const scope = {
|
|
414
|
+
projectPath: context.root,
|
|
415
|
+
preserveProjectRoot: true
|
|
416
|
+
};
|
|
417
|
+
for (const [index, step] of scenario.steps.entries()) {
|
|
418
|
+
context.signal.throwIfAborted();
|
|
419
|
+
const result = {
|
|
420
|
+
scenario: scenario.name,
|
|
421
|
+
index,
|
|
422
|
+
action: step.action,
|
|
423
|
+
input: step,
|
|
424
|
+
status: "running",
|
|
425
|
+
startedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
426
|
+
};
|
|
427
|
+
await onStep(result);
|
|
428
|
+
try {
|
|
429
|
+
switch (step.action) {
|
|
430
|
+
case "route":
|
|
431
|
+
result.actual = await invoke("weapp_devtools_route", {
|
|
432
|
+
...scope,
|
|
433
|
+
path: step.path,
|
|
434
|
+
transition: "reLaunch"
|
|
435
|
+
});
|
|
436
|
+
break;
|
|
437
|
+
case "find":
|
|
438
|
+
result.actual = await invoke("weapp_runtime_find_node", {
|
|
439
|
+
...scope,
|
|
440
|
+
selector: step.selector
|
|
441
|
+
});
|
|
442
|
+
break;
|
|
443
|
+
case "tap":
|
|
444
|
+
result.actual = await invoke("weapp_runtime_tap_node", {
|
|
445
|
+
...scope,
|
|
446
|
+
selector: step.selector
|
|
447
|
+
});
|
|
448
|
+
break;
|
|
449
|
+
case "input":
|
|
450
|
+
result.actual = await invoke("weapp_runtime_input_node", {
|
|
451
|
+
...scope,
|
|
452
|
+
selector: step.selector,
|
|
453
|
+
value: step.value
|
|
454
|
+
});
|
|
455
|
+
break;
|
|
456
|
+
case "wait":
|
|
457
|
+
result.actual = await invoke("weapp_runtime_wait_node", {
|
|
458
|
+
...scope,
|
|
459
|
+
selector: step.selector,
|
|
460
|
+
timeoutMs: step.timeoutMs
|
|
461
|
+
});
|
|
462
|
+
break;
|
|
463
|
+
case "assert": {
|
|
464
|
+
const deadline = Date.now() + step.timeoutMs;
|
|
465
|
+
for (;;) {
|
|
466
|
+
const actual = await invoke("weapp_runtime_find_node", {
|
|
467
|
+
...scope,
|
|
468
|
+
selector: step.selector
|
|
469
|
+
});
|
|
470
|
+
result.actual = actual;
|
|
471
|
+
if (actual.text === step.text) break;
|
|
472
|
+
if (Date.now() >= deadline) throw new Error(`Assertion failed for ${step.selector}: expected ${JSON.stringify(step.text)}, received ${JSON.stringify(actual.text)}`);
|
|
473
|
+
await setTimeout(100, void 0, { signal: context.signal });
|
|
474
|
+
}
|
|
475
|
+
break;
|
|
476
|
+
}
|
|
477
|
+
case "screenshot": result.actual = {
|
|
478
|
+
artifact: await capture(),
|
|
479
|
+
purpose: "visual evidence, not a visual assertion"
|
|
480
|
+
};
|
|
481
|
+
}
|
|
482
|
+
result.status = "passed";
|
|
483
|
+
} catch (error) {
|
|
484
|
+
result.status = "failed";
|
|
485
|
+
result.error = error instanceof Error ? error.message : String(error);
|
|
486
|
+
throw error;
|
|
487
|
+
} finally {
|
|
488
|
+
result.finishedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
489
|
+
await onStep(result);
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
//#endregion
|
|
494
|
+
//#region src/snapshot.ts
|
|
495
|
+
const excluded = /* @__PURE__ */ new Set([
|
|
496
|
+
"node_modules",
|
|
497
|
+
".git",
|
|
498
|
+
".weapp-agent",
|
|
499
|
+
".weapp-vite",
|
|
500
|
+
".turbo",
|
|
501
|
+
".cache",
|
|
502
|
+
".astro",
|
|
503
|
+
".DS_Store",
|
|
504
|
+
"coverage",
|
|
505
|
+
"artifacts"
|
|
506
|
+
]);
|
|
507
|
+
/** Include untracked source/config files, without executing Git hooks or Vite config. */
|
|
508
|
+
async function sourceSnapshot(project) {
|
|
509
|
+
const hash = createHash("sha256");
|
|
510
|
+
const output = path.resolve(project.root, project.outputRoot);
|
|
511
|
+
const source = path.resolve(project.root, project.sourceRoot);
|
|
512
|
+
let files = 0;
|
|
513
|
+
const walk = async (directory) => {
|
|
514
|
+
const entries = (await readdir(directory, { withFileTypes: true })).sort((a, b) => a.name.localeCompare(b.name));
|
|
515
|
+
for (const entry of entries) {
|
|
516
|
+
const target = path.join(directory, entry.name);
|
|
517
|
+
const relative = path.relative(project.root, target);
|
|
518
|
+
if (excluded.has(entry.name) || sensitive(relative) || output !== project.root && output !== source && target === output) continue;
|
|
519
|
+
if (entry.isSymbolicLink()) throw new Error(`Source snapshot does not support symlinks: ${relative} -> ${await readlink(target)}`);
|
|
520
|
+
if (entry.isDirectory()) await walk(target);
|
|
521
|
+
else if (entry.isFile()) {
|
|
522
|
+
if (++files > 2e4) throw new Error("Source snapshot exceeds 20000 files; select the mini-program package with -C.");
|
|
523
|
+
const bytes = await readFile(await safePath(project.root, relative));
|
|
524
|
+
hash.update(JSON.stringify([relative.split(path.sep).join("/"), bytes.length]));
|
|
525
|
+
hash.update(bytes);
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
};
|
|
529
|
+
await walk(project.root);
|
|
530
|
+
return hash.digest("hex");
|
|
531
|
+
}
|
|
532
|
+
//#endregion
|
|
533
|
+
//#region src/verify.ts
|
|
534
|
+
async function verifyProject(config, context, originalFingerprint, onCheck, skipKinds = []) {
|
|
535
|
+
await requireTrust(context);
|
|
536
|
+
const checks = [];
|
|
537
|
+
for (const command of config.verification.filter((c) => !skipKinds.includes(c.kind))) {
|
|
538
|
+
context.signal.throwIfAborted();
|
|
539
|
+
const current = await projectFingerprint(context.root, config);
|
|
540
|
+
if (current !== originalFingerprint || /publish|upload|deploy/i.test([command.command, ...command.args].join(" "))) await authorize(context, "command", `Verify: ${command.command} ${command.args.join(" ")}`, {
|
|
541
|
+
command,
|
|
542
|
+
fingerprint: current
|
|
543
|
+
});
|
|
544
|
+
const result = await execa(command.command, command.args, {
|
|
545
|
+
cwd: context.root,
|
|
546
|
+
cancelSignal: context.signal,
|
|
547
|
+
timeout: command.timeoutMs,
|
|
548
|
+
forceKillAfterDelay: 2e3,
|
|
549
|
+
reject: false,
|
|
550
|
+
maxBuffer: 2e6
|
|
551
|
+
});
|
|
552
|
+
context.signal.throwIfAborted();
|
|
553
|
+
checks.push({
|
|
554
|
+
kind: command.kind,
|
|
555
|
+
status: result.exitCode === 0 ? "passed" : "failed",
|
|
556
|
+
command: `${command.command} ${command.args.join(" ")}`,
|
|
557
|
+
exitCode: result.exitCode,
|
|
558
|
+
...result.timedOut ? { timedOut: true } : {},
|
|
559
|
+
output: bounded(result.timedOut ? `Verification command timed out after ${command.timeoutMs}ms.\n${result.stdout}\n${result.stderr}` : `${result.stdout}\n${result.stderr}`)
|
|
560
|
+
});
|
|
561
|
+
await onCheck?.(checks[checks.length - 1]);
|
|
562
|
+
}
|
|
563
|
+
for (const kind of [
|
|
564
|
+
"typecheck",
|
|
565
|
+
"build",
|
|
566
|
+
"test",
|
|
567
|
+
"devtools"
|
|
568
|
+
]) if (!checks.some((c) => c.kind === kind)) checks.push({
|
|
569
|
+
kind,
|
|
570
|
+
status: "unverified",
|
|
571
|
+
output: kind === "devtools" ? "No real DevTools verification command configured. Web preview is not WeChat runtime evidence." : "No verification command configured."
|
|
572
|
+
});
|
|
573
|
+
return {
|
|
574
|
+
passed: checks.some((c) => c.status === "passed") && !checks.some((c) => c.status === "failed"),
|
|
575
|
+
checks
|
|
576
|
+
};
|
|
577
|
+
}
|
|
578
|
+
function verificationTool(config, fingerprint) {
|
|
579
|
+
return {
|
|
580
|
+
name: "verify_project",
|
|
581
|
+
description: "Run configured typecheck/build/test/DevTools commands and return separate passed, failed and unverified results. Fix failures, then rerun. Missing checks are never considered passed.",
|
|
582
|
+
schema: z.strictObject({}),
|
|
583
|
+
mutates: true,
|
|
584
|
+
async execute(_input, context) {
|
|
585
|
+
const report = await verifyProject(config, context, fingerprint);
|
|
586
|
+
return {
|
|
587
|
+
text: JSON.stringify(report, null, 2),
|
|
588
|
+
data: report
|
|
589
|
+
};
|
|
590
|
+
}
|
|
591
|
+
};
|
|
592
|
+
}
|
|
593
|
+
//#endregion
|
|
594
|
+
//#region src/acceptance.ts
|
|
595
|
+
async function resolveProjectConfig(root, file) {
|
|
596
|
+
return await loadAcceptanceConfig(root, file) ?? projectConfigSchema.parse({ verification: defaultVerification(await detectProject(root)) });
|
|
597
|
+
}
|
|
598
|
+
function alive(pid) {
|
|
599
|
+
try {
|
|
600
|
+
process.kill(pid, 0);
|
|
601
|
+
return true;
|
|
602
|
+
} catch (error) {
|
|
603
|
+
return error.code !== "ESRCH";
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
/** Locks survive server instances. Dead owners are inspected, never replayed. */
|
|
607
|
+
async function lockProject(directory, jobId) {
|
|
608
|
+
const lock = path.join(directory, "lock");
|
|
609
|
+
try {
|
|
610
|
+
await mkdir(lock);
|
|
611
|
+
} catch (error) {
|
|
612
|
+
if (error.code !== "EEXIST") throw error;
|
|
613
|
+
const recovery = path.join(directory, "lock-recovery");
|
|
614
|
+
try {
|
|
615
|
+
await mkdir(recovery);
|
|
616
|
+
} catch {
|
|
617
|
+
throw new ApprovalRequired("Project lock recovery is busy; retry after inspecting the previous task.");
|
|
618
|
+
}
|
|
619
|
+
try {
|
|
620
|
+
const owner = await readFile(path.join(lock, "owner.json"), "utf8").then(JSON.parse).catch(() => void 0);
|
|
621
|
+
if (!owner || alive(owner.pid)) throw new ApprovalRequired("Another acceptance task owns this project. Query/cancel that task before starting a new one.");
|
|
622
|
+
await rm(lock, {
|
|
623
|
+
recursive: true,
|
|
624
|
+
force: true
|
|
625
|
+
});
|
|
626
|
+
} finally {
|
|
627
|
+
await rm(recovery, {
|
|
628
|
+
recursive: true,
|
|
629
|
+
force: true
|
|
630
|
+
});
|
|
631
|
+
}
|
|
632
|
+
return lockProject(directory, jobId);
|
|
633
|
+
}
|
|
634
|
+
await atomicJson(path.join(lock, "owner.json"), {
|
|
635
|
+
pid: process.pid,
|
|
636
|
+
jobId
|
|
637
|
+
});
|
|
638
|
+
return async () => {
|
|
639
|
+
if (JSON.parse(await readFile(path.join(lock, "owner.json"), "utf8")).jobId === jobId) await rm(lock, {
|
|
640
|
+
recursive: true,
|
|
641
|
+
force: true
|
|
642
|
+
});
|
|
643
|
+
};
|
|
644
|
+
}
|
|
645
|
+
var AcceptanceService = class AcceptanceService {
|
|
646
|
+
root;
|
|
647
|
+
directory;
|
|
648
|
+
options;
|
|
649
|
+
jobs = /* @__PURE__ */ new Map();
|
|
650
|
+
starting = /* @__PURE__ */ new Set();
|
|
651
|
+
closed = false;
|
|
652
|
+
constructor(root, directory, options) {
|
|
653
|
+
this.root = root;
|
|
654
|
+
this.directory = directory;
|
|
655
|
+
this.options = options;
|
|
656
|
+
}
|
|
657
|
+
static async create(root, options = {}) {
|
|
658
|
+
root = await realpath(root);
|
|
659
|
+
const directory = path.join(stateRoot(), "acceptance", hash(root));
|
|
660
|
+
await mkdir(directory, {
|
|
661
|
+
recursive: true,
|
|
662
|
+
mode: 448
|
|
663
|
+
});
|
|
664
|
+
if (options.trust) {
|
|
665
|
+
const config = await resolveProjectConfig(root, options.configFile);
|
|
666
|
+
await trustProject(root, await projectFingerprint(root, config));
|
|
667
|
+
}
|
|
668
|
+
return new AcceptanceService(root, directory, options);
|
|
669
|
+
}
|
|
670
|
+
jobDirectory(jobId) {
|
|
671
|
+
if (!/^[\da-f]{8}(?:-[\da-f]{4}){3}-[\da-f]{12}$/i.test(jobId)) throw new Error("Invalid acceptance job ID");
|
|
672
|
+
return path.join(this.directory, jobId);
|
|
673
|
+
}
|
|
674
|
+
async inspect() {
|
|
675
|
+
const project = await detectProject(this.root);
|
|
676
|
+
const config = await resolveProjectConfig(this.root, this.options.configFile);
|
|
677
|
+
const localMcp = Boolean(this.options.connect);
|
|
678
|
+
const trusted = await isTrusted(this.root, await projectFingerprint(this.root, config));
|
|
679
|
+
const scenarioProblems = [];
|
|
680
|
+
for (const file of config.acceptance.scenarios) try {
|
|
681
|
+
scenarioSchema.parse(JSON.parse(await readFile(await safePath(this.root, file), "utf8")));
|
|
682
|
+
} catch (error) {
|
|
683
|
+
scenarioProblems.push(`${file}: ${String(error)}`);
|
|
684
|
+
}
|
|
685
|
+
const missingChecks = config.acceptance.requiredChecks.filter((kind) => kind !== "devtools" && !config.verification.some((c) => c.kind === kind));
|
|
686
|
+
let hasTestAppId = false;
|
|
687
|
+
try {
|
|
688
|
+
const app = JSON.parse(await readFile(await safePath(this.root, "project.config.json"), "utf8"));
|
|
689
|
+
hasTestAppId = Boolean(app.appid && app.appid !== "touristappid");
|
|
690
|
+
} catch {}
|
|
691
|
+
return {
|
|
692
|
+
version: 2,
|
|
693
|
+
project,
|
|
694
|
+
supported: Boolean(project.weappViteVersion && project.kind !== "unknown"),
|
|
695
|
+
modelRequired: false,
|
|
696
|
+
configurationSource: configurationSource(config) ?? "auto",
|
|
697
|
+
trusted,
|
|
698
|
+
localMcp,
|
|
699
|
+
requiredChecks: config.acceptance.requiredChecks,
|
|
700
|
+
configuredChecks: config.verification.map((c) => c.kind),
|
|
701
|
+
scenarios: config.acceptance.scenarios,
|
|
702
|
+
scenarioProblems,
|
|
703
|
+
hasTestAppId,
|
|
704
|
+
prerequisites: [
|
|
705
|
+
...missingChecks.map((kind) => `Configure a ${kind} verification command in the selected acceptance configuration.`),
|
|
706
|
+
...scenarioProblems.map((problem) => `Repair scenario: ${problem}`),
|
|
707
|
+
...!trusted ? ["Review project scripts/configuration, then explicitly run wv accept --trust."] : [],
|
|
708
|
+
...!localMcp ? ["Install the project dependencies to provide the local weapp-vite MCP."] : [],
|
|
709
|
+
...!config.acceptance.scenarios.length ? ["Add acceptance.scenarios to the selected acceptance configuration for runtime assertions."] : [],
|
|
710
|
+
...!hasTestAppId ? ["Configure a real test AppID in project.config.json before runtime checks."] : [],
|
|
711
|
+
"Runtime checks require logged-in WeChat DevTools, its service port enabled, and a real test AppID. Connection has not been probed."
|
|
712
|
+
]
|
|
713
|
+
};
|
|
714
|
+
}
|
|
715
|
+
start() {
|
|
716
|
+
const pending = this.startTask();
|
|
717
|
+
this.starting.add(pending);
|
|
718
|
+
pending.then(() => this.starting.delete(pending), () => this.starting.delete(pending));
|
|
719
|
+
return pending;
|
|
720
|
+
}
|
|
721
|
+
async startTask() {
|
|
722
|
+
if (this.closed) throw new Error("Acceptance service is closed");
|
|
723
|
+
const project = await detectProject(this.root);
|
|
724
|
+
const config = await resolveProjectConfig(this.root, this.options.configFile);
|
|
725
|
+
const report = {
|
|
726
|
+
version: 2,
|
|
727
|
+
jobId: randomUUID(),
|
|
728
|
+
root: this.root,
|
|
729
|
+
ownerPid: process.pid,
|
|
730
|
+
status: "running",
|
|
731
|
+
passed: false,
|
|
732
|
+
startedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
733
|
+
project,
|
|
734
|
+
requiredChecks: config.acceptance.requiredChecks,
|
|
735
|
+
checks: [],
|
|
736
|
+
steps: [],
|
|
737
|
+
artifacts: [],
|
|
738
|
+
runtime: "unverified",
|
|
739
|
+
snapshot: { stale: false },
|
|
740
|
+
warnings: [...project.warnings]
|
|
741
|
+
};
|
|
742
|
+
const directory = this.jobDirectory(report.jobId);
|
|
743
|
+
await mkdir(directory, { mode: 448 });
|
|
744
|
+
const persist = () => atomicJson(path.join(directory, "report.json"), report);
|
|
745
|
+
let release;
|
|
746
|
+
try {
|
|
747
|
+
if (!project.weappViteVersion || project.kind === "unknown") throw new ApprovalRequired("Acceptance supports weapp-vite native and Wevu projects on WeChat only.");
|
|
748
|
+
const fingerprint = await projectFingerprint(this.root, config);
|
|
749
|
+
if (!await isTrusted(this.root, fingerprint)) throw new ApprovalRequired("Review project scripts/configuration, then explicitly run wv accept --trust.");
|
|
750
|
+
release = await lockProject(this.directory, report.jobId);
|
|
751
|
+
if (this.closed) throw new Error("Acceptance service is shutting down; no checks were started");
|
|
752
|
+
const controller = new AbortController();
|
|
753
|
+
await persist();
|
|
754
|
+
const unlock = release;
|
|
755
|
+
const job = {
|
|
756
|
+
controller,
|
|
757
|
+
done: Promise.resolve(),
|
|
758
|
+
report,
|
|
759
|
+
persistenceFailed: false
|
|
760
|
+
};
|
|
761
|
+
job.done = this.execute(report, config, fingerprint, controller, persist).catch((error) => {
|
|
762
|
+
report.status = "failed";
|
|
763
|
+
report.passed = false;
|
|
764
|
+
report.reason = `Could not persist acceptance result: ${String(error)}`;
|
|
765
|
+
}).finally(async () => {
|
|
766
|
+
try {
|
|
767
|
+
await unlock();
|
|
768
|
+
} catch (error) {
|
|
769
|
+
report.warnings.push(`Project lock cleanup failed: ${String(error)}`);
|
|
770
|
+
report.status = "failed";
|
|
771
|
+
report.passed = false;
|
|
772
|
+
report.reason = "Project ownership could not be released; inspect the cleanup warning before retrying.";
|
|
773
|
+
}
|
|
774
|
+
await persist().catch((error) => {
|
|
775
|
+
job.persistenceFailed = true;
|
|
776
|
+
report.status = "failed";
|
|
777
|
+
report.passed = false;
|
|
778
|
+
report.reason = `Could not persist acceptance result: ${String(error)}`;
|
|
779
|
+
});
|
|
780
|
+
if (!job.persistenceFailed) this.jobs.delete(report.jobId);
|
|
781
|
+
});
|
|
782
|
+
this.jobs.set(report.jobId, job);
|
|
783
|
+
} catch (error) {
|
|
784
|
+
await release?.();
|
|
785
|
+
report.status = error instanceof ApprovalRequired ? "action_required" : "failed";
|
|
786
|
+
report.reason = error instanceof Error ? error.message : String(error);
|
|
787
|
+
report.finishedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
788
|
+
await persist();
|
|
789
|
+
}
|
|
790
|
+
return redactValue(structuredClone(report));
|
|
791
|
+
}
|
|
792
|
+
async execute(report, config, fingerprint, controller, persist) {
|
|
793
|
+
const signal = AbortSignal.any([controller.signal, AbortSignal.timeout(config.acceptance.timeoutMs)]);
|
|
794
|
+
const context = {
|
|
795
|
+
root: this.root,
|
|
796
|
+
trusted: true,
|
|
797
|
+
signal,
|
|
798
|
+
approve: async () => false
|
|
799
|
+
};
|
|
800
|
+
let connection;
|
|
801
|
+
const directory = this.jobDirectory(report.jobId);
|
|
802
|
+
const checkFreshness = async () => {
|
|
803
|
+
if (report.snapshot.before) report.snapshot.stale ||= report.snapshot.before !== await sourceSnapshot(report.project);
|
|
804
|
+
};
|
|
805
|
+
const recordCheck = async (check) => {
|
|
806
|
+
report.checks.push(check);
|
|
807
|
+
await checkFreshness();
|
|
808
|
+
await persist();
|
|
809
|
+
};
|
|
810
|
+
try {
|
|
811
|
+
report.snapshot.before = await sourceSnapshot(report.project);
|
|
812
|
+
const scenarios = [];
|
|
813
|
+
for (const file of config.acceptance.scenarios) scenarios.push(scenarioSchema.parse(JSON.parse(await readFile(await safePath(this.root, file), "utf8"))));
|
|
814
|
+
await persist();
|
|
815
|
+
if (new Set(scenarios.map((s) => s.name)).size !== scenarios.length) throw new Error("Scenario names must be unique within an acceptance task");
|
|
816
|
+
if (scenarios.length && this.options.connect) connection = await this.options.connect({
|
|
817
|
+
name: "weapp",
|
|
818
|
+
transport: "stdio",
|
|
819
|
+
command: "",
|
|
820
|
+
args: []
|
|
821
|
+
}, context, {
|
|
822
|
+
config,
|
|
823
|
+
fingerprint,
|
|
824
|
+
builtin: true
|
|
825
|
+
});
|
|
826
|
+
await verifyProject(config, context, fingerprint, recordCheck, ["devtools"]);
|
|
827
|
+
if (scenarios.length && report.checks.some((c) => c.kind === "build" && c.status === "passed") && !report.checks.some((c) => c.status === "failed")) {
|
|
828
|
+
if (await projectFingerprint(this.root, config) !== fingerprint) throw new ApprovalRequired("Project configuration changed during verification. Review it before another run.");
|
|
829
|
+
const connect = this.options.connect;
|
|
830
|
+
if (!connect) throw new ApprovalRequired("DevTools runtime adapter is unavailable. Use wv accept or the weapp-vite MCP.");
|
|
831
|
+
const app = JSON.parse(await readFile(await safePath(this.root, "project.config.json"), "utf8"));
|
|
832
|
+
if (!app.appid || app.appid === "touristappid") throw new ApprovalRequired("Configure a real test AppID in project.config.json and log into WeChat DevTools.");
|
|
833
|
+
connection ??= await connect({
|
|
834
|
+
name: "weapp",
|
|
835
|
+
transport: "stdio",
|
|
836
|
+
command: "",
|
|
837
|
+
args: []
|
|
838
|
+
}, context, {
|
|
839
|
+
config,
|
|
840
|
+
fingerprint,
|
|
841
|
+
builtin: true
|
|
842
|
+
});
|
|
843
|
+
const invoke = runtimeInvoker(connection, context);
|
|
844
|
+
const connected = await invoke("weapp_devtools_connect", {
|
|
845
|
+
projectPath: this.root,
|
|
846
|
+
preserveProjectRoot: true,
|
|
847
|
+
timeout: 6e4
|
|
848
|
+
});
|
|
849
|
+
const runtimeRoot = connected.resolvedProjectPath ?? connected.projectPath;
|
|
850
|
+
if (typeof runtimeRoot !== "string" || await realpath(runtimeRoot) !== this.root) throw new Error("DevTools connected to a different or unidentified project; evidence is unverified.");
|
|
851
|
+
if (connected.systemInfo?.platform !== "devtools") throw new Error("Runtime did not identify itself as WeChat DevTools; evidence is unverified.");
|
|
852
|
+
report.runtime = "wechat-devtools";
|
|
853
|
+
await persist();
|
|
854
|
+
let screenshot = 0;
|
|
855
|
+
const capture = async () => {
|
|
856
|
+
const name = `screenshot-${++screenshot}.png`;
|
|
857
|
+
const outputPath = `artifacts/weapp-agent/${report.jobId}/${name}`;
|
|
858
|
+
const target = await safePath(this.root, outputPath);
|
|
859
|
+
await mkdir(path.dirname(target), { recursive: true });
|
|
860
|
+
await invoke("weapp_devtools_capture", {
|
|
861
|
+
projectPath: this.root,
|
|
862
|
+
preserveProjectRoot: true,
|
|
863
|
+
outputPath
|
|
864
|
+
});
|
|
865
|
+
const bytes = await readFile(await safePath(this.root, outputPath));
|
|
866
|
+
if (bytes.length > 10485760 || bytes.subarray(0, 8).toString("hex") !== "89504e470d0a1a0a") throw new Error("Runtime screenshot is not a supported PNG artifact (maximum 10 MB)");
|
|
867
|
+
await writeFile(path.join(directory, name), bytes, { mode: 384 });
|
|
868
|
+
report.artifacts.push({
|
|
869
|
+
name,
|
|
870
|
+
mediaType: "image/png"
|
|
871
|
+
});
|
|
872
|
+
await persist();
|
|
873
|
+
return name;
|
|
874
|
+
};
|
|
875
|
+
try {
|
|
876
|
+
for (const scenario of scenarios) {
|
|
877
|
+
await runScenario(scenario, invoke, context, async (step) => {
|
|
878
|
+
await checkFreshness();
|
|
879
|
+
const previous = report.steps.findIndex((s) => s.scenario === step.scenario && s.index === step.index);
|
|
880
|
+
if (previous === -1) report.steps.push(structuredClone(step));
|
|
881
|
+
else report.steps[previous] = structuredClone(step);
|
|
882
|
+
await persist();
|
|
883
|
+
}, capture);
|
|
884
|
+
await capture();
|
|
885
|
+
}
|
|
886
|
+
await recordCheck({
|
|
887
|
+
kind: "devtools",
|
|
888
|
+
status: "passed",
|
|
889
|
+
output: `${scenarios.length} scenario(s) passed in WeChat DevTools. No physical-device or visual-regression claim.`
|
|
890
|
+
});
|
|
891
|
+
} catch (error) {
|
|
892
|
+
await recordCheck({
|
|
893
|
+
kind: "devtools",
|
|
894
|
+
status: "failed",
|
|
895
|
+
output: String(error)
|
|
896
|
+
});
|
|
897
|
+
if (!signal.aborted) await capture().catch((e) => report.warnings.push(`Failure screenshot unavailable: ${String(e)}`));
|
|
898
|
+
throw error;
|
|
899
|
+
} finally {
|
|
900
|
+
if (!signal.aborted) try {
|
|
901
|
+
const logs = await invoke("weapp_devtools_console", { projectPath: this.root });
|
|
902
|
+
await atomicJson(path.join(directory, "console.json"), logs);
|
|
903
|
+
report.artifacts.push({
|
|
904
|
+
name: "console.json",
|
|
905
|
+
mediaType: "application/json"
|
|
906
|
+
});
|
|
907
|
+
} catch (error) {
|
|
908
|
+
report.warnings.push(`Console evidence unavailable: ${String(error)}`);
|
|
909
|
+
report.checks.push({
|
|
910
|
+
kind: "devtools",
|
|
911
|
+
status: "unverified",
|
|
912
|
+
output: "Console evidence could not be collected."
|
|
913
|
+
});
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
for (const kind of [
|
|
918
|
+
"typecheck",
|
|
919
|
+
"build",
|
|
920
|
+
"test",
|
|
921
|
+
"devtools"
|
|
922
|
+
]) if (!report.checks.some((c) => c.kind === kind)) report.checks.push({
|
|
923
|
+
kind,
|
|
924
|
+
status: "unverified",
|
|
925
|
+
output: kind === "devtools" ? "Runtime scenarios were not executed. Configure scenarios and pass the build first." : "No verification command configured."
|
|
926
|
+
});
|
|
927
|
+
report.status = report.checks.some((c) => c.timedOut) ? "timed_out" : report.checks.some((c) => c.status === "failed") ? "failed" : report.requiredChecks.every((kind) => report.checks.some((c) => c.kind === kind && c.status === "passed") && !report.checks.some((c) => c.kind === kind && c.status !== "passed")) ? "passed" : "unverified";
|
|
928
|
+
} catch (error) {
|
|
929
|
+
report.status = signal.aborted ? controller.signal.aborted ? "cancelled" : "timed_out" : error instanceof ApprovalRequired ? "action_required" : "failed";
|
|
930
|
+
report.reason = error instanceof Error ? error.message : String(error);
|
|
931
|
+
} finally {
|
|
932
|
+
if (connection) await connection.close().catch((error) => report.warnings.push(`MCP cleanup: ${String(error)}`));
|
|
933
|
+
try {
|
|
934
|
+
report.snapshot.after = await sourceSnapshot(report.project);
|
|
935
|
+
report.snapshot.stale ||= Boolean(report.snapshot.before && report.snapshot.before !== report.snapshot.after);
|
|
936
|
+
} catch (error) {
|
|
937
|
+
report.snapshot.stale = true;
|
|
938
|
+
report.warnings.push(`Cannot validate source freshness: ${String(error)}`);
|
|
939
|
+
}
|
|
940
|
+
if (report.snapshot.stale && report.status === "passed") {
|
|
941
|
+
report.status = "unverified";
|
|
942
|
+
report.reason = "Source changed during acceptance. Run acceptance again against the current code.";
|
|
943
|
+
}
|
|
944
|
+
report.finishedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
945
|
+
for (const kind of [
|
|
946
|
+
"typecheck",
|
|
947
|
+
"build",
|
|
948
|
+
"test",
|
|
949
|
+
"devtools"
|
|
950
|
+
]) if (!report.checks.some((c) => c.kind === kind)) report.checks.push({
|
|
951
|
+
kind,
|
|
952
|
+
status: "unverified",
|
|
953
|
+
output: "Check did not complete; inspect task status and reason."
|
|
954
|
+
});
|
|
955
|
+
report.passed = report.status === "passed" && !report.snapshot.stale;
|
|
956
|
+
}
|
|
957
|
+
}
|
|
958
|
+
async report(jobId) {
|
|
959
|
+
const file = path.join(this.jobDirectory(jobId), "report.json");
|
|
960
|
+
const live = this.jobs.get(jobId);
|
|
961
|
+
const report = live?.persistenceFailed ? structuredClone(live.report) : JSON.parse(await readFile(file, "utf8"));
|
|
962
|
+
if (report.root !== this.root || report.version !== 2) throw new Error("Report does not belong to this project or has an unsupported version");
|
|
963
|
+
if (report.status === "running" && !this.jobs.has(jobId) && !alive(report.ownerPid)) {
|
|
964
|
+
report.status = "interrupted";
|
|
965
|
+
report.reason = "Owner exited during acceptance. Inspect partial steps and current runtime state before starting a new task. No steps were replayed.";
|
|
966
|
+
report.passed = false;
|
|
967
|
+
}
|
|
968
|
+
if (report.snapshot.before) {
|
|
969
|
+
try {
|
|
970
|
+
report.snapshot.current = await sourceSnapshot(report.project);
|
|
971
|
+
report.snapshot.stale ||= report.snapshot.current !== report.snapshot.before;
|
|
972
|
+
} catch {
|
|
973
|
+
report.snapshot.stale = true;
|
|
974
|
+
}
|
|
975
|
+
if (report.snapshot.stale) {
|
|
976
|
+
report.passed = false;
|
|
977
|
+
if (report.status === "passed") {
|
|
978
|
+
report.status = "unverified";
|
|
979
|
+
report.reason = "Evidence is stale for the current source. Start a new acceptance task.";
|
|
980
|
+
}
|
|
981
|
+
}
|
|
982
|
+
}
|
|
983
|
+
return redactValue(report);
|
|
984
|
+
}
|
|
985
|
+
async wait(jobId) {
|
|
986
|
+
await this.jobs.get(jobId)?.done;
|
|
987
|
+
return this.report(jobId);
|
|
988
|
+
}
|
|
989
|
+
async cancel(jobId) {
|
|
990
|
+
const job = this.jobs.get(jobId);
|
|
991
|
+
if (job) {
|
|
992
|
+
job.controller.abort();
|
|
993
|
+
return this.wait(jobId);
|
|
994
|
+
}
|
|
995
|
+
const report = await this.report(jobId);
|
|
996
|
+
if (report.status === "running") throw new Error("Cancel this task through the MCP server/CLI process that started it.");
|
|
997
|
+
return report;
|
|
998
|
+
}
|
|
999
|
+
async artifact(jobId, name) {
|
|
1000
|
+
const artifact = (await this.report(jobId)).artifacts.find((a) => a.name === name);
|
|
1001
|
+
if (!artifact || !/^(?:screenshot-\d+\.png|console\.json)$/.test(name)) throw new Error("Unknown acceptance artifact");
|
|
1002
|
+
const bytes = await readFile(path.join(this.jobDirectory(jobId), name));
|
|
1003
|
+
if (bytes.length > 10485760) throw new Error("Artifact exceeds the 10 MB response limit");
|
|
1004
|
+
return {
|
|
1005
|
+
mediaType: artifact.mediaType,
|
|
1006
|
+
data: artifact.mediaType === "image/png" ? bytes.toString("base64") : bytes.toString("utf8")
|
|
1007
|
+
};
|
|
1008
|
+
}
|
|
1009
|
+
async close() {
|
|
1010
|
+
this.closed = true;
|
|
1011
|
+
await Promise.allSettled([...this.starting]);
|
|
1012
|
+
for (const job of this.jobs.values()) job.controller.abort();
|
|
1013
|
+
await Promise.allSettled([...this.jobs.values()].map((job) => job.done));
|
|
1014
|
+
}
|
|
1015
|
+
};
|
|
1016
|
+
//#endregion
|
|
1017
|
+
export { AcceptanceService, configurationSource, defaultVerification, detectProject, exists, findProjectRoot, loadAcceptanceConfig, projectConfigSchema, projectFingerprint, projectInstructions, redactValue, redactor, resolveProjectConfig, runScenario, runtimeInvoker, safePath, scenarioSchema, sourceSnapshot, stateRoot, verificationTool, verifyProject };
|
package/package.json
CHANGED
|
@@ -1,6 +1,45 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@weapp-vite/acceptance",
|
|
3
|
-
"
|
|
4
|
-
"
|
|
5
|
-
"description": "
|
|
3
|
+
"type": "module",
|
|
4
|
+
"version": "0.0.0",
|
|
5
|
+
"description": "Deterministic mini-program acceptance and evidence",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/weapp-vite/weapp-vite.git",
|
|
10
|
+
"directory": "packages/acceptance"
|
|
11
|
+
},
|
|
12
|
+
"sideEffects": false,
|
|
13
|
+
"exports": {
|
|
14
|
+
".": {
|
|
15
|
+
"types": "./dist/index.d.mts",
|
|
16
|
+
"import": "./dist/index.mjs"
|
|
17
|
+
}
|
|
18
|
+
},
|
|
19
|
+
"main": "./dist/index.mjs",
|
|
20
|
+
"types": "./dist/index.d.mts",
|
|
21
|
+
"files": [
|
|
22
|
+
"LICENSE",
|
|
23
|
+
"dist"
|
|
24
|
+
],
|
|
25
|
+
"engines": {
|
|
26
|
+
"node": "^20.19.0 || >=22.12.0"
|
|
27
|
+
},
|
|
28
|
+
"dependencies": {
|
|
29
|
+
"execa": "9.6.0",
|
|
30
|
+
"zod": "^4.6.5"
|
|
31
|
+
},
|
|
32
|
+
"devDependencies": {
|
|
33
|
+
"@weapp-agent/core": "0.0.0"
|
|
34
|
+
},
|
|
35
|
+
"publishConfig": {
|
|
36
|
+
"access": "public"
|
|
37
|
+
},
|
|
38
|
+
"scripts": {
|
|
39
|
+
"build": "tsdown",
|
|
40
|
+
"test": "vitest run",
|
|
41
|
+
"typecheck": "tsc --noEmit",
|
|
42
|
+
"lint": "eslint .",
|
|
43
|
+
"test:types": "tsd"
|
|
44
|
+
}
|
|
6
45
|
}
|
package/README.md
DELETED