chore: align sqlite control plane architecture
- replace default Convex control-plane wording with Rust SQLite control-plane across architecture, AGENTS, Reasonix, and design docs - retire root Convex functions source and deploy script into recycle while keeping explicit cloud/compat/sync-replica boundaries - add control-plane migration guard/docs and keep CodeGraph refreshed after the SQLite control-plane cutover
This commit is contained in:
@@ -0,0 +1,53 @@
|
||||
/* eslint-disable */
|
||||
/**
|
||||
* Generated `api` utility.
|
||||
*
|
||||
* THIS CODE IS AUTOMATICALLY GENERATED.
|
||||
*
|
||||
* To regenerate, run `npx convex dev`.
|
||||
* @module
|
||||
*/
|
||||
|
||||
import type * as aiSessions from "../aiSessions.js";
|
||||
import type * as auth from "../auth.js";
|
||||
import type * as users from "../users.js";
|
||||
|
||||
import type {
|
||||
ApiFromModules,
|
||||
FilterApi,
|
||||
FunctionReference,
|
||||
} from "convex/server";
|
||||
|
||||
declare const fullApi: ApiFromModules<{
|
||||
aiSessions: typeof aiSessions;
|
||||
auth: typeof auth;
|
||||
users: typeof users;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* A utility for referencing Convex functions in your app's public API.
|
||||
*
|
||||
* Usage:
|
||||
* ```js
|
||||
* const myFunctionReference = api.myModule.myFunction;
|
||||
* ```
|
||||
*/
|
||||
export declare const api: FilterApi<
|
||||
typeof fullApi,
|
||||
FunctionReference<any, "public">
|
||||
>;
|
||||
|
||||
/**
|
||||
* A utility for referencing Convex functions in your app's internal API.
|
||||
*
|
||||
* Usage:
|
||||
* ```js
|
||||
* const myFunctionReference = internal.myModule.myFunction;
|
||||
* ```
|
||||
*/
|
||||
export declare const internal: FilterApi<
|
||||
typeof fullApi,
|
||||
FunctionReference<any, "internal">
|
||||
>;
|
||||
|
||||
export declare const components: {};
|
||||
@@ -0,0 +1,23 @@
|
||||
/* eslint-disable */
|
||||
/**
|
||||
* Generated `api` utility.
|
||||
*
|
||||
* THIS CODE IS AUTOMATICALLY GENERATED.
|
||||
*
|
||||
* To regenerate, run `npx convex dev`.
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { anyApi, componentsGeneric } from "convex/server";
|
||||
|
||||
/**
|
||||
* A utility for referencing Convex functions in your app's API.
|
||||
*
|
||||
* Usage:
|
||||
* ```js
|
||||
* const myFunctionReference = api.myModule.myFunction;
|
||||
* ```
|
||||
*/
|
||||
export const api = anyApi;
|
||||
export const internal = anyApi;
|
||||
export const components = componentsGeneric();
|
||||
@@ -0,0 +1,60 @@
|
||||
/* eslint-disable */
|
||||
/**
|
||||
* Generated data model types.
|
||||
*
|
||||
* THIS CODE IS AUTOMATICALLY GENERATED.
|
||||
*
|
||||
* To regenerate, run `npx convex dev`.
|
||||
* @module
|
||||
*/
|
||||
|
||||
import type {
|
||||
DataModelFromSchemaDefinition,
|
||||
DocumentByName,
|
||||
TableNamesInDataModel,
|
||||
SystemTableNames,
|
||||
} from "convex/server";
|
||||
import type { GenericId } from "convex/values";
|
||||
import schema from "../schema.js";
|
||||
|
||||
/**
|
||||
* The names of all of your Convex tables.
|
||||
*/
|
||||
export type TableNames = TableNamesInDataModel<DataModel>;
|
||||
|
||||
/**
|
||||
* The type of a document stored in Convex.
|
||||
*
|
||||
* @typeParam TableName - A string literal type of the table name (like "users").
|
||||
*/
|
||||
export type Doc<TableName extends TableNames> = DocumentByName<
|
||||
DataModel,
|
||||
TableName
|
||||
>;
|
||||
|
||||
/**
|
||||
* An identifier for a document in Convex.
|
||||
*
|
||||
* Convex documents are uniquely identified by their `Id`, which is accessible
|
||||
* on the `_id` field. To learn more, see [Document IDs](https://docs.convex.dev/using/document-ids).
|
||||
*
|
||||
* Documents can be loaded using `db.get(tableName, id)` in query and mutation functions.
|
||||
*
|
||||
* IDs are just strings at runtime, but this type can be used to distinguish them from other
|
||||
* strings when type checking.
|
||||
*
|
||||
* @typeParam TableName - A string literal type of the table name (like "users").
|
||||
*/
|
||||
export type Id<TableName extends TableNames | SystemTableNames> =
|
||||
GenericId<TableName>;
|
||||
|
||||
/**
|
||||
* A type describing your Convex data model.
|
||||
*
|
||||
* This type includes information about what tables you have, the type of
|
||||
* documents stored in those tables, and the indexes defined on them.
|
||||
*
|
||||
* This type is used to parameterize methods like `queryGeneric` and
|
||||
* `mutationGeneric` to make them type-safe.
|
||||
*/
|
||||
export type DataModel = DataModelFromSchemaDefinition<typeof schema>;
|
||||
@@ -0,0 +1,143 @@
|
||||
/* eslint-disable */
|
||||
/**
|
||||
* Generated utilities for implementing server-side Convex query and mutation functions.
|
||||
*
|
||||
* THIS CODE IS AUTOMATICALLY GENERATED.
|
||||
*
|
||||
* To regenerate, run `npx convex dev`.
|
||||
* @module
|
||||
*/
|
||||
|
||||
import {
|
||||
ActionBuilder,
|
||||
HttpActionBuilder,
|
||||
MutationBuilder,
|
||||
QueryBuilder,
|
||||
GenericActionCtx,
|
||||
GenericMutationCtx,
|
||||
GenericQueryCtx,
|
||||
GenericDatabaseReader,
|
||||
GenericDatabaseWriter,
|
||||
} from "convex/server";
|
||||
import type { DataModel } from "./dataModel.js";
|
||||
|
||||
/**
|
||||
* Define a query in this Convex app's public API.
|
||||
*
|
||||
* This function will be allowed to read your Convex database and will be accessible from the client.
|
||||
*
|
||||
* @param func - The query function. It receives a {@link QueryCtx} as its first argument.
|
||||
* @returns The wrapped query. Include this as an `export` to name it and make it accessible.
|
||||
*/
|
||||
export declare const query: QueryBuilder<DataModel, "public">;
|
||||
|
||||
/**
|
||||
* Define a query that is only accessible from other Convex functions (but not from the client).
|
||||
*
|
||||
* This function will be allowed to read from your Convex database. It will not be accessible from the client.
|
||||
*
|
||||
* @param func - The query function. It receives a {@link QueryCtx} as its first argument.
|
||||
* @returns The wrapped query. Include this as an `export` to name it and make it accessible.
|
||||
*/
|
||||
export declare const internalQuery: QueryBuilder<DataModel, "internal">;
|
||||
|
||||
/**
|
||||
* Define a mutation in this Convex app's public API.
|
||||
*
|
||||
* This function will be allowed to modify your Convex database and will be accessible from the client.
|
||||
*
|
||||
* @param func - The mutation function. It receives a {@link MutationCtx} as its first argument.
|
||||
* @returns The wrapped mutation. Include this as an `export` to name it and make it accessible.
|
||||
*/
|
||||
export declare const mutation: MutationBuilder<DataModel, "public">;
|
||||
|
||||
/**
|
||||
* Define a mutation that is only accessible from other Convex functions (but not from the client).
|
||||
*
|
||||
* This function will be allowed to modify your Convex database. It will not be accessible from the client.
|
||||
*
|
||||
* @param func - The mutation function. It receives a {@link MutationCtx} as its first argument.
|
||||
* @returns The wrapped mutation. Include this as an `export` to name it and make it accessible.
|
||||
*/
|
||||
export declare const internalMutation: MutationBuilder<DataModel, "internal">;
|
||||
|
||||
/**
|
||||
* Define an action in this Convex app's public API.
|
||||
*
|
||||
* An action is a function which can execute any JavaScript code, including non-deterministic
|
||||
* code and code with side-effects, like calling third-party services.
|
||||
* They can be run in Convex's JavaScript environment or in Node.js using the "use node" directive.
|
||||
* They can interact with the database indirectly by calling queries and mutations using the {@link ActionCtx}.
|
||||
*
|
||||
* @param func - The action. It receives an {@link ActionCtx} as its first argument.
|
||||
* @returns The wrapped action. Include this as an `export` to name it and make it accessible.
|
||||
*/
|
||||
export declare const action: ActionBuilder<DataModel, "public">;
|
||||
|
||||
/**
|
||||
* Define an action that is only accessible from other Convex functions (but not from the client).
|
||||
*
|
||||
* @param func - The function. It receives an {@link ActionCtx} as its first argument.
|
||||
* @returns The wrapped function. Include this as an `export` to name it and make it accessible.
|
||||
*/
|
||||
export declare const internalAction: ActionBuilder<DataModel, "internal">;
|
||||
|
||||
/**
|
||||
* Define an HTTP action.
|
||||
*
|
||||
* The wrapped function will be used to respond to HTTP requests received
|
||||
* by a Convex deployment if the requests matches the path and method where
|
||||
* this action is routed. Be sure to route your httpAction in `convex/http.js`.
|
||||
*
|
||||
* @param func - The function. It receives an {@link ActionCtx} as its first argument
|
||||
* and a Fetch API `Request` object as its second.
|
||||
* @returns The wrapped function. Import this function from `convex/http.js` and route it to hook it up.
|
||||
*/
|
||||
export declare const httpAction: HttpActionBuilder;
|
||||
|
||||
/**
|
||||
* A set of services for use within Convex query functions.
|
||||
*
|
||||
* The query context is passed as the first argument to any Convex query
|
||||
* function run on the server.
|
||||
*
|
||||
* This differs from the {@link MutationCtx} because all of the services are
|
||||
* read-only.
|
||||
*/
|
||||
export type QueryCtx = GenericQueryCtx<DataModel>;
|
||||
|
||||
/**
|
||||
* A set of services for use within Convex mutation functions.
|
||||
*
|
||||
* The mutation context is passed as the first argument to any Convex mutation
|
||||
* function run on the server.
|
||||
*/
|
||||
export type MutationCtx = GenericMutationCtx<DataModel>;
|
||||
|
||||
/**
|
||||
* A set of services for use within Convex action functions.
|
||||
*
|
||||
* The action context is passed as the first argument to any Convex action
|
||||
* function run on the server.
|
||||
*/
|
||||
export type ActionCtx = GenericActionCtx<DataModel>;
|
||||
|
||||
/**
|
||||
* An interface to read from the database within Convex query functions.
|
||||
*
|
||||
* The two entry points are {@link DatabaseReader.get}, which fetches a single
|
||||
* document by its {@link Id}, or {@link DatabaseReader.query}, which starts
|
||||
* building a query.
|
||||
*/
|
||||
export type DatabaseReader = GenericDatabaseReader<DataModel>;
|
||||
|
||||
/**
|
||||
* An interface to read from and write to the database within Convex mutation
|
||||
* functions.
|
||||
*
|
||||
* Convex guarantees that all writes within a single mutation are
|
||||
* executed atomically, so you never have to worry about partial writes leaving
|
||||
* your data in an inconsistent state. See [the Convex Guide](https://docs.convex.dev/understanding/convex-fundamentals/functions#atomicity-and-optimistic-concurrency-control)
|
||||
* for the guarantees Convex provides your functions.
|
||||
*/
|
||||
export type DatabaseWriter = GenericDatabaseWriter<DataModel>;
|
||||
@@ -0,0 +1,93 @@
|
||||
/* eslint-disable */
|
||||
/**
|
||||
* Generated utilities for implementing server-side Convex query and mutation functions.
|
||||
*
|
||||
* THIS CODE IS AUTOMATICALLY GENERATED.
|
||||
*
|
||||
* To regenerate, run `npx convex dev`.
|
||||
* @module
|
||||
*/
|
||||
|
||||
import {
|
||||
actionGeneric,
|
||||
httpActionGeneric,
|
||||
queryGeneric,
|
||||
mutationGeneric,
|
||||
internalActionGeneric,
|
||||
internalMutationGeneric,
|
||||
internalQueryGeneric,
|
||||
} from "convex/server";
|
||||
|
||||
/**
|
||||
* Define a query in this Convex app's public API.
|
||||
*
|
||||
* This function will be allowed to read your Convex database and will be accessible from the client.
|
||||
*
|
||||
* @param func - The query function. It receives a {@link QueryCtx} as its first argument.
|
||||
* @returns The wrapped query. Include this as an `export` to name it and make it accessible.
|
||||
*/
|
||||
export const query = queryGeneric;
|
||||
|
||||
/**
|
||||
* Define a query that is only accessible from other Convex functions (but not from the client).
|
||||
*
|
||||
* This function will be allowed to read from your Convex database. It will not be accessible from the client.
|
||||
*
|
||||
* @param func - The query function. It receives a {@link QueryCtx} as its first argument.
|
||||
* @returns The wrapped query. Include this as an `export` to name it and make it accessible.
|
||||
*/
|
||||
export const internalQuery = internalQueryGeneric;
|
||||
|
||||
/**
|
||||
* Define a mutation in this Convex app's public API.
|
||||
*
|
||||
* This function will be allowed to modify your Convex database and will be accessible from the client.
|
||||
*
|
||||
* @param func - The mutation function. It receives a {@link MutationCtx} as its first argument.
|
||||
* @returns The wrapped mutation. Include this as an `export` to name it and make it accessible.
|
||||
*/
|
||||
export const mutation = mutationGeneric;
|
||||
|
||||
/**
|
||||
* Define a mutation that is only accessible from other Convex functions (but not from the client).
|
||||
*
|
||||
* This function will be allowed to modify your Convex database. It will not be accessible from the client.
|
||||
*
|
||||
* @param func - The mutation function. It receives a {@link MutationCtx} as its first argument.
|
||||
* @returns The wrapped mutation. Include this as an `export` to name it and make it accessible.
|
||||
*/
|
||||
export const internalMutation = internalMutationGeneric;
|
||||
|
||||
/**
|
||||
* Define an action in this Convex app's public API.
|
||||
*
|
||||
* An action is a function which can execute any JavaScript code, including non-deterministic
|
||||
* code and code with side-effects, like calling third-party services.
|
||||
* They can be run in Convex's JavaScript environment or in Node.js using the "use node" directive.
|
||||
* They can interact with the database indirectly by calling queries and mutations using the {@link ActionCtx}.
|
||||
*
|
||||
* @param func - The action. It receives an {@link ActionCtx} as its first argument.
|
||||
* @returns The wrapped action. Include this as an `export` to name it and make it accessible.
|
||||
*/
|
||||
export const action = actionGeneric;
|
||||
|
||||
/**
|
||||
* Define an action that is only accessible from other Convex functions (but not from the client).
|
||||
*
|
||||
* @param func - The function. It receives an {@link ActionCtx} as its first argument.
|
||||
* @returns The wrapped function. Include this as an `export` to name it and make it accessible.
|
||||
*/
|
||||
export const internalAction = internalActionGeneric;
|
||||
|
||||
/**
|
||||
* Define an HTTP action.
|
||||
*
|
||||
* The wrapped function will be used to respond to HTTP requests received
|
||||
* by a Convex deployment if the requests matches the path and method where
|
||||
* this action is routed. Be sure to route your httpAction in `convex/http.js`.
|
||||
*
|
||||
* @param func - The function. It receives an {@link ActionCtx} as its first argument
|
||||
* and a Fetch API `Request` object as its second.
|
||||
* @returns The wrapped function. Import this function from `convex/http.js` and route it to hook it up.
|
||||
*/
|
||||
export const httpAction = httpActionGeneric;
|
||||
@@ -0,0 +1,433 @@
|
||||
import { mutation, query } from "./_generated/server";
|
||||
import { v } from "convex/values";
|
||||
|
||||
const runtimeRunArgs = {
|
||||
schema: v.literal("mnote.acp_runtime_run.v1"),
|
||||
source: v.literal("acp"),
|
||||
userId: v.string(),
|
||||
workspaceId: v.optional(v.union(v.string(), v.null())),
|
||||
documentId: v.string(),
|
||||
sessionId: v.string(),
|
||||
runId: v.string(),
|
||||
title: v.optional(v.union(v.string(), v.null())),
|
||||
profile: v.string(),
|
||||
acpRuntime: v.string(),
|
||||
traceId: v.string(),
|
||||
status: v.string(),
|
||||
runtime: v.any(),
|
||||
payload: v.any(),
|
||||
retention: v.any(),
|
||||
createdAt: v.number(),
|
||||
updatedAt: v.number(),
|
||||
};
|
||||
|
||||
const runtimeEventArgs = {
|
||||
schema: v.literal("mnote.acp_runtime_event.v1"),
|
||||
source: v.literal("acp"),
|
||||
eventId: v.string(),
|
||||
userId: v.string(),
|
||||
workspaceId: v.optional(v.union(v.string(), v.null())),
|
||||
documentId: v.string(),
|
||||
sessionId: v.string(),
|
||||
runId: v.string(),
|
||||
profile: v.string(),
|
||||
acpRuntime: v.string(),
|
||||
eventType: v.string(),
|
||||
payload: v.any(),
|
||||
createdAt: v.number(),
|
||||
};
|
||||
|
||||
function isoFromMillis(value: number) {
|
||||
return new Date(value).toISOString();
|
||||
}
|
||||
|
||||
async function requireScopedUser(ctx: any, expectedUserId: string) {
|
||||
const identity = await ctx.auth.getUserIdentity();
|
||||
const subject = String(identity?.subject ?? "");
|
||||
const identityUserId = subject.split("|")[0] || "";
|
||||
if (identityUserId && identityUserId !== expectedUserId) {
|
||||
throw new Error("无权限");
|
||||
}
|
||||
if (!identityUserId && !expectedUserId.trim()) {
|
||||
throw new Error("未登录");
|
||||
}
|
||||
return expectedUserId;
|
||||
}
|
||||
|
||||
function rowToClient(row: any) {
|
||||
return {
|
||||
id: row.id,
|
||||
userId: row.user_id,
|
||||
workspaceId: row.workspace_id,
|
||||
documentId: row.document_id,
|
||||
sessionId: row.session_id,
|
||||
runId: row.run_id,
|
||||
profile: row.profile,
|
||||
title: row.title,
|
||||
acpRuntime: row.acp_runtime,
|
||||
source: row.source,
|
||||
status: row.status,
|
||||
traceId: row.trace_id,
|
||||
runtime: row.runtime,
|
||||
usage: row.usage ?? null,
|
||||
payload: row.payload,
|
||||
retention: row.retention,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
export const upsertRuntimeRun = mutation({
|
||||
args: runtimeRunArgs,
|
||||
handler: async (ctx, args) => {
|
||||
const userId = await requireScopedUser(ctx, args.userId);
|
||||
const workspaceId = args.workspaceId ?? null;
|
||||
const nowIso = isoFromMillis(args.updatedAt);
|
||||
const existing = await ctx.db
|
||||
.query("acp_runtime_runs")
|
||||
.withIndex("by_run_id", (q: any) => q.eq("run_id", args.runId))
|
||||
.first();
|
||||
|
||||
const patch = {
|
||||
id: `acp_run:${args.runId}`,
|
||||
user_id: userId,
|
||||
workspace_id: workspaceId,
|
||||
document_id: args.documentId,
|
||||
session_id: args.sessionId,
|
||||
run_id: args.runId,
|
||||
title: args.title ?? null,
|
||||
profile: args.profile,
|
||||
acp_runtime: args.acpRuntime,
|
||||
source: args.source,
|
||||
status: args.status,
|
||||
trace_id: args.traceId,
|
||||
runtime: args.runtime,
|
||||
usage: null,
|
||||
payload: args.payload,
|
||||
retention: args.retention,
|
||||
updated_at: nowIso,
|
||||
deleted_at: null,
|
||||
};
|
||||
|
||||
if (existing) {
|
||||
if (String(existing.user_id) !== userId) {
|
||||
throw new Error("无权限");
|
||||
}
|
||||
await ctx.db.patch(existing._id, patch);
|
||||
return { ok: true, id: existing.id, runId: args.runId, updated: true };
|
||||
}
|
||||
|
||||
await ctx.db.insert("acp_runtime_runs", {
|
||||
...patch,
|
||||
created_at: isoFromMillis(args.createdAt),
|
||||
});
|
||||
return { ok: true, id: patch.id, runId: args.runId, created: true };
|
||||
},
|
||||
});
|
||||
|
||||
export const getRuntimeRun = query({
|
||||
args: {
|
||||
userId: v.string(),
|
||||
runId: v.string(),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const userId = await requireScopedUser(ctx, args.userId);
|
||||
const row = await ctx.db
|
||||
.query("acp_runtime_runs")
|
||||
.withIndex("by_run_id", (q: any) => q.eq("run_id", args.runId))
|
||||
.first();
|
||||
if (!row || row.deleted_at !== null || String(row.user_id) !== userId) {
|
||||
return null;
|
||||
}
|
||||
return rowToClient(row);
|
||||
},
|
||||
});
|
||||
|
||||
export const listRuntimeRuns = query({
|
||||
args: {
|
||||
userId: v.string(),
|
||||
workspaceId: v.optional(v.union(v.string(), v.null())),
|
||||
documentId: v.optional(v.union(v.string(), v.null())),
|
||||
sessionId: v.optional(v.union(v.string(), v.null())),
|
||||
limit: v.optional(v.number()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const userId = await requireScopedUser(ctx, args.userId);
|
||||
const workspaceId = args.workspaceId ?? null;
|
||||
const limit = Math.min(Math.max(args.limit ?? 50, 1), 100);
|
||||
let rows;
|
||||
if (args.sessionId) {
|
||||
rows = await ctx.db
|
||||
.query("acp_runtime_runs")
|
||||
.withIndex("by_user_workspace_session", (q: any) =>
|
||||
q.eq("user_id", userId).eq("workspace_id", workspaceId).eq("session_id", args.sessionId),
|
||||
)
|
||||
.order("desc")
|
||||
.take(limit);
|
||||
} else if (args.documentId) {
|
||||
rows = await ctx.db
|
||||
.query("acp_runtime_runs")
|
||||
.withIndex("by_user_workspace_document", (q: any) =>
|
||||
q.eq("user_id", userId).eq("workspace_id", workspaceId).eq("document_id", args.documentId),
|
||||
)
|
||||
.order("desc")
|
||||
.take(limit);
|
||||
} else {
|
||||
rows = await ctx.db
|
||||
.query("acp_runtime_runs")
|
||||
.withIndex("by_user_created_at", (q: any) => q.eq("user_id", userId))
|
||||
.order("desc")
|
||||
.take(limit);
|
||||
}
|
||||
|
||||
return rows
|
||||
.filter((row: any) => row.deleted_at === null)
|
||||
.map(rowToClient);
|
||||
},
|
||||
});
|
||||
|
||||
export const appendRuntimeEvent = mutation({
|
||||
args: runtimeEventArgs,
|
||||
handler: async (ctx, args) => {
|
||||
const userId = await requireScopedUser(ctx, args.userId);
|
||||
const workspaceId = args.workspaceId ?? null;
|
||||
const existingRun = await ctx.db
|
||||
.query("acp_runtime_runs")
|
||||
.withIndex("by_run_id", (q: any) => q.eq("run_id", args.runId))
|
||||
.first();
|
||||
if (existingRun && String(existingRun.user_id) !== userId) {
|
||||
throw new Error("无权限");
|
||||
}
|
||||
|
||||
await ctx.db.insert("acp_runtime_events", {
|
||||
id: args.eventId,
|
||||
user_id: userId,
|
||||
workspace_id: workspaceId,
|
||||
document_id: args.documentId,
|
||||
session_id: args.sessionId,
|
||||
run_id: args.runId,
|
||||
profile: args.profile,
|
||||
acp_runtime: args.acpRuntime,
|
||||
source: args.source,
|
||||
event_type: args.eventType,
|
||||
payload: args.payload,
|
||||
created_at: isoFromMillis(args.createdAt),
|
||||
});
|
||||
const usage = usageFromRuntimeEvent(args.eventType, args.payload);
|
||||
if (existingRun && usage) {
|
||||
await ctx.db.patch(existingRun._id, {
|
||||
usage,
|
||||
updated_at: isoFromMillis(args.createdAt),
|
||||
});
|
||||
}
|
||||
return { ok: true, id: args.eventId, runId: args.runId };
|
||||
},
|
||||
});
|
||||
|
||||
function usageFromRuntimeEvent(eventType: string, payload: any) {
|
||||
if (eventType === "usage.updated") {
|
||||
return {
|
||||
source: "usage_update",
|
||||
contextUsed: Number(payload?.used ?? payload?.contextUsed ?? 0),
|
||||
contextSize: Number(payload?.size ?? payload?.contextSize ?? 0),
|
||||
};
|
||||
}
|
||||
if (eventType === "run.completed" && payload?.usage && typeof payload.usage === "object") {
|
||||
return {
|
||||
source: "run_completed",
|
||||
...payload.usage,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export const listRuntimeEvents = query({
|
||||
args: {
|
||||
userId: v.string(),
|
||||
runId: v.string(),
|
||||
limit: v.optional(v.number()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const userId = await requireScopedUser(ctx, args.userId);
|
||||
const limit = Math.min(Math.max(args.limit ?? 100, 1), 500);
|
||||
const rows = await ctx.db
|
||||
.query("acp_runtime_events")
|
||||
.withIndex("by_run_created_at", (q: any) => q.eq("run_id", args.runId))
|
||||
.order("asc")
|
||||
.take(limit);
|
||||
return rows
|
||||
.filter((row: any) => String(row.user_id) === userId)
|
||||
.map((row: any) => ({
|
||||
id: row.id,
|
||||
userId: row.user_id,
|
||||
workspaceId: row.workspace_id,
|
||||
documentId: row.document_id,
|
||||
sessionId: row.session_id,
|
||||
runId: row.run_id,
|
||||
profile: row.profile,
|
||||
acpRuntime: row.acp_runtime,
|
||||
source: row.source,
|
||||
eventType: row.event_type,
|
||||
payload: row.payload,
|
||||
createdAt: row.created_at,
|
||||
}));
|
||||
},
|
||||
});
|
||||
|
||||
function titleFromRun(row: any) {
|
||||
const payload = row?.payload ?? {};
|
||||
const raw = String(payload.message ?? payload.input ?? payload.title ?? "").trim();
|
||||
if (!raw) return "当前页问答";
|
||||
return raw.length > 32 ? `${raw.slice(0, 32)}...` : raw;
|
||||
}
|
||||
|
||||
async function sessionRows(ctx: any, userId: string, workspaceId: string | null, sessionId: string) {
|
||||
return await ctx.db
|
||||
.query("acp_runtime_runs")
|
||||
.withIndex("by_user_workspace_session", (q: any) =>
|
||||
q.eq("user_id", userId).eq("workspace_id", workspaceId).eq("session_id", sessionId),
|
||||
)
|
||||
.collect();
|
||||
}
|
||||
|
||||
export const renameRuntimeSession = mutation({
|
||||
args: {
|
||||
userId: v.string(),
|
||||
workspaceId: v.optional(v.union(v.string(), v.null())),
|
||||
sessionId: v.string(),
|
||||
title: v.string(),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const userId = await requireScopedUser(ctx, args.userId);
|
||||
const workspaceId = args.workspaceId ?? null;
|
||||
const title = args.title.trim();
|
||||
if (!title) throw new Error("标题不能为空");
|
||||
const rows = await sessionRows(ctx, userId, workspaceId, args.sessionId);
|
||||
for (const row of rows) {
|
||||
if (row.deleted_at === null) {
|
||||
await ctx.db.patch(row._id, {
|
||||
title,
|
||||
updated_at: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
}
|
||||
return { ok: true, sessionId: args.sessionId, title, updated: rows.length };
|
||||
},
|
||||
});
|
||||
|
||||
export const autoTitleRuntimeSession = mutation({
|
||||
args: {
|
||||
userId: v.string(),
|
||||
workspaceId: v.optional(v.union(v.string(), v.null())),
|
||||
sessionId: v.string(),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const userId = await requireScopedUser(ctx, args.userId);
|
||||
const workspaceId = args.workspaceId ?? null;
|
||||
const rows = await sessionRows(ctx, userId, workspaceId, args.sessionId);
|
||||
const title = titleFromRun(rows.find((row: any) => row.deleted_at === null));
|
||||
for (const row of rows) {
|
||||
if (row.deleted_at === null) {
|
||||
await ctx.db.patch(row._id, {
|
||||
title,
|
||||
updated_at: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
}
|
||||
return { ok: true, sessionId: args.sessionId, title, updated: rows.length };
|
||||
},
|
||||
});
|
||||
|
||||
export const deleteRuntimeSession = mutation({
|
||||
args: {
|
||||
userId: v.string(),
|
||||
workspaceId: v.optional(v.union(v.string(), v.null())),
|
||||
sessionId: v.string(),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const userId = await requireScopedUser(ctx, args.userId);
|
||||
const workspaceId = args.workspaceId ?? null;
|
||||
const rows = await sessionRows(ctx, userId, workspaceId, args.sessionId);
|
||||
const deletedAt = new Date().toISOString();
|
||||
for (const row of rows) {
|
||||
if (row.deleted_at === null) {
|
||||
await ctx.db.patch(row._id, {
|
||||
deleted_at: deletedAt,
|
||||
updated_at: deletedAt,
|
||||
});
|
||||
}
|
||||
}
|
||||
return { ok: true, sessionId: args.sessionId, deleted: rows.length };
|
||||
},
|
||||
});
|
||||
|
||||
function searchText(row: any) {
|
||||
const payload = row?.payload ?? {};
|
||||
return [
|
||||
row.title,
|
||||
row.session_id,
|
||||
row.run_id,
|
||||
row.profile,
|
||||
row.status,
|
||||
payload.message,
|
||||
payload.input,
|
||||
payload.title,
|
||||
]
|
||||
.map((value) => String(value ?? ""))
|
||||
.join(" ")
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
function snippetFor(row: any, q: string) {
|
||||
const payload = row?.payload ?? {};
|
||||
const raw = String(row.title ?? payload.message ?? payload.input ?? row.session_id ?? "").trim();
|
||||
if (!raw) return "";
|
||||
const index = raw.toLowerCase().indexOf(q.toLowerCase());
|
||||
if (index < 0) return raw.length > 80 ? `${raw.slice(0, 80)}...` : raw;
|
||||
const start = Math.max(0, index - 24);
|
||||
const end = Math.min(raw.length, index + q.length + 56);
|
||||
return `${start > 0 ? "..." : ""}${raw.slice(start, end)}${end < raw.length ? "..." : ""}`;
|
||||
}
|
||||
|
||||
export const searchRuntimeSessions = query({
|
||||
args: {
|
||||
userId: v.string(),
|
||||
workspaceId: v.optional(v.union(v.string(), v.null())),
|
||||
q: v.string(),
|
||||
limit: v.optional(v.number()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const userId = await requireScopedUser(ctx, args.userId);
|
||||
const workspaceId = args.workspaceId ?? null;
|
||||
const q = args.q.trim().toLowerCase();
|
||||
if (!q) return [];
|
||||
const limit = Math.min(Math.max(args.limit ?? 20, 1), 50);
|
||||
const rows = await ctx.db
|
||||
.query("acp_runtime_runs")
|
||||
.withIndex("by_user_created_at", (query: any) => query.eq("user_id", userId))
|
||||
.order("desc")
|
||||
.take(500);
|
||||
const seen = new Set<string>();
|
||||
const results = [];
|
||||
for (const row of rows) {
|
||||
if (row.deleted_at !== null) continue;
|
||||
if ((row.workspace_id ?? null) !== workspaceId) continue;
|
||||
if (!searchText(row).includes(q)) continue;
|
||||
if (seen.has(row.session_id)) continue;
|
||||
seen.add(row.session_id);
|
||||
results.push({
|
||||
sessionId: row.session_id,
|
||||
runId: row.run_id,
|
||||
workspaceId: row.workspace_id,
|
||||
documentId: row.document_id,
|
||||
title: row.title ?? titleFromRun(row),
|
||||
profile: row.profile,
|
||||
status: row.status,
|
||||
snippet: snippetFor(row, args.q),
|
||||
score: 1,
|
||||
});
|
||||
if (results.length >= limit) break;
|
||||
}
|
||||
return results;
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
export default {
|
||||
providers: [
|
||||
{
|
||||
domain: process.env.CONVEX_SITE_URL,
|
||||
applicationID: "convex",
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,19 @@
|
||||
import { Password } from "@convex-dev/auth/providers/Password";
|
||||
import { convexAuth } from "@convex-dev/auth/server";
|
||||
|
||||
export const { auth, signIn, signOut, store, isAuthenticated } = convexAuth({
|
||||
providers: [
|
||||
Password({
|
||||
validatePasswordRequirements: (password: string) => {
|
||||
if (password.length < 8) {
|
||||
throw new Error("密码至少需要 8 个字符");
|
||||
}
|
||||
},
|
||||
profile(params) {
|
||||
const email = typeof params.email === "string" ? params.email : String(params.email ?? "");
|
||||
const name = typeof params.name === "string" ? params.name.trim() : "";
|
||||
return { email, name };
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import { defineSchema, defineTable } from "convex/server";
|
||||
import { v } from "convex/values";
|
||||
import { authTables } from "@convex-dev/auth/server";
|
||||
|
||||
export default defineSchema({
|
||||
...authTables,
|
||||
|
||||
acp_runtime_runs: defineTable({
|
||||
id: v.string(),
|
||||
user_id: v.string(),
|
||||
workspace_id: v.union(v.string(), v.null()),
|
||||
document_id: v.string(),
|
||||
session_id: v.string(),
|
||||
run_id: v.string(),
|
||||
title: v.union(v.string(), v.null()),
|
||||
profile: v.string(),
|
||||
acp_runtime: v.string(),
|
||||
source: v.literal("acp"),
|
||||
status: v.string(),
|
||||
trace_id: v.string(),
|
||||
runtime: v.any(),
|
||||
usage: v.any(),
|
||||
payload: v.any(),
|
||||
retention: v.any(),
|
||||
created_at: v.string(),
|
||||
updated_at: v.string(),
|
||||
deleted_at: v.union(v.string(), v.null()),
|
||||
})
|
||||
.index("by_run_id", ["run_id"])
|
||||
.index("by_user_workspace_document", ["user_id", "workspace_id", "document_id", "created_at"])
|
||||
.index("by_user_workspace_session", ["user_id", "workspace_id", "session_id", "created_at"])
|
||||
.index("by_user_created_at", ["user_id", "created_at"]),
|
||||
|
||||
acp_runtime_events: defineTable({
|
||||
id: v.string(),
|
||||
user_id: v.string(),
|
||||
workspace_id: v.union(v.string(), v.null()),
|
||||
document_id: v.string(),
|
||||
session_id: v.string(),
|
||||
run_id: v.string(),
|
||||
profile: v.string(),
|
||||
acp_runtime: v.string(),
|
||||
source: v.literal("acp"),
|
||||
event_type: v.string(),
|
||||
payload: v.any(),
|
||||
created_at: v.string(),
|
||||
})
|
||||
.index("by_run_created_at", ["run_id", "created_at"])
|
||||
.index("by_user_workspace_session_created_at", [
|
||||
"user_id",
|
||||
"workspace_id",
|
||||
"session_id",
|
||||
"created_at",
|
||||
]),
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
import { query } from "./_generated/server";
|
||||
import { mutation } from "./_generated/server";
|
||||
import { v } from "convex/values";
|
||||
import { getAuthUserId } from "@convex-dev/auth/server";
|
||||
|
||||
function normalizeAccount(value: string) {
|
||||
return value.trim().toLowerCase();
|
||||
}
|
||||
|
||||
export const resolveLoginAccount = query({
|
||||
args: {
|
||||
account: v.string(),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const account = normalizeAccount(args.account);
|
||||
if (!account) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const users = await ctx.db.query("users").collect();
|
||||
const user = users.find((row: any) => {
|
||||
const email = normalizeAccount(String(row.email ?? ""));
|
||||
const name = normalizeAccount(String(row.name ?? ""));
|
||||
return email === account || name === account;
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
userId: user._id,
|
||||
email: String((user as any).email ?? ""),
|
||||
name: String((user as any).name ?? ""),
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
function normalizeUsername(raw: string): string {
|
||||
const username = String(raw ?? "").trim();
|
||||
if (!username) throw new Error("用户名不能为空");
|
||||
if (username.length < 2 || username.length > 32) throw new Error("用户名长度需为 2-32 个字符");
|
||||
if (/\s/.test(username)) throw new Error("用户名不能包含空格");
|
||||
return username;
|
||||
}
|
||||
|
||||
export const currentUser = query({
|
||||
args: {},
|
||||
handler: async (ctx) => {
|
||||
const userId = await getAuthUserId(ctx);
|
||||
if (userId === null) {
|
||||
return null;
|
||||
}
|
||||
return await ctx.db.get(userId);
|
||||
},
|
||||
});
|
||||
|
||||
export const setMyUsername = mutation({
|
||||
args: { username: v.string() },
|
||||
handler: async (ctx, args) => {
|
||||
const userId = await getAuthUserId(ctx);
|
||||
if (userId === null) throw new Error("未登录");
|
||||
|
||||
const username = normalizeUsername(args.username);
|
||||
const existing = await ctx.db
|
||||
.query("users")
|
||||
.filter((q) => q.eq(q.field("name"), username))
|
||||
.first();
|
||||
|
||||
if (existing && String(existing._id) !== String(userId)) {
|
||||
throw new Error("用户名已被占用");
|
||||
}
|
||||
|
||||
await ctx.db.patch(userId, { name: username });
|
||||
return { ok: true, username };
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
#!/usr/bin/env node
|
||||
const { spawn } = require("child_process");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
process.env.CONVEX_TMPDIR = "/mnt/Data1T/mnote/.convex-tmp";
|
||||
|
||||
const adminKey = "mnote-local|01cedce68c51e168c6aacb282a90f7d233f56eddfabb07944a1d9dd9506f73ba888fc7daff";
|
||||
const repoRoot = "/mnt/Data1T/mnote";
|
||||
const rootConvexDir = path.join(repoRoot, "convex");
|
||||
const requiredRootFiles = ["schema.ts", "aiSessions.ts"];
|
||||
|
||||
for (const fileName of requiredRootFiles) {
|
||||
const filePath = path.join(rootConvexDir, fileName);
|
||||
if (!fs.existsSync(filePath)) {
|
||||
console.error(`[convex-deploy] 缺少当前 active Convex 文件:${filePath}`);
|
||||
console.error("[convex-deploy] 不允许从 recycle/wolai-frontend/convex 回退部署;需要的函数必须迁回 root convex/ 或明确退役。");
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
const args = [
|
||||
"deploy",
|
||||
"--url", "http://127.0.0.1:3210", // Backend port, NOT HTTP actions (3211)
|
||||
"--admin-key", adminKey,
|
||||
"--typecheck", "disable",
|
||||
"--codegen", "disable",
|
||||
];
|
||||
|
||||
const child = spawn("npx", ["convex", ...args], {
|
||||
cwd: repoRoot,
|
||||
stdio: "inherit",
|
||||
env: { ...process.env, CONVEX_TMPDIR: "/mnt/Data1T/mnote/.convex-tmp" },
|
||||
});
|
||||
|
||||
child.on("close", (code) => {
|
||||
process.exit(code || 0);
|
||||
});
|
||||
@@ -0,0 +1,234 @@
|
||||
"use strict";
|
||||
|
||||
// 说明:
|
||||
// - 这是 task-019 的最小真实浏览器回归脚本。
|
||||
// - 目标只覆盖文档页元信息、Sidebar、标题/正文保存主链,不扩大到 Mindmap / OnlyOffice。
|
||||
// - 脚本会先创建一篇临时页面,完成回归后再彻底删除,避免污染现有数据。
|
||||
|
||||
const { chromium } = require("playwright");
|
||||
|
||||
const BASE_URL = process.env.MNOTE_UI_BASE_URL || "http://127.0.0.1:3000";
|
||||
const REQUEST_TIMEOUT_MS = 20_000;
|
||||
const UI_TIMEOUT_MS = 30_000;
|
||||
const TEST_LOGIN_BUTTON_NAME = "测试账号快速登录";
|
||||
|
||||
function assert(condition, message) {
|
||||
if (!condition) {
|
||||
throw new Error(message);
|
||||
}
|
||||
}
|
||||
|
||||
async function requestJson(requestContext, path, init = {}) {
|
||||
const response = await requestContext.fetch(`${BASE_URL}${path}`, {
|
||||
...init,
|
||||
headers:
|
||||
init.data !== undefined
|
||||
? {
|
||||
"content-type": "application/json",
|
||||
...(init.headers || {}),
|
||||
}
|
||||
: {
|
||||
...(init.headers || {}),
|
||||
},
|
||||
timeout: REQUEST_TIMEOUT_MS,
|
||||
});
|
||||
|
||||
const text = await response.text();
|
||||
let payload = null;
|
||||
try {
|
||||
payload = text ? JSON.parse(text) : null;
|
||||
} catch {
|
||||
payload = text;
|
||||
}
|
||||
|
||||
if (!response.ok()) {
|
||||
throw new Error(
|
||||
`${path} 请求失败: ${response.status()} ${response.statusText()} ${typeof payload === "string" ? payload : JSON.stringify(payload)}`,
|
||||
);
|
||||
}
|
||||
|
||||
const contentType = response.headers()["content-type"] || "";
|
||||
if (!contentType.includes("application/json")) {
|
||||
const snippet = typeof payload === "string" ? payload.slice(0, 200) : JSON.stringify(payload).slice(0, 200);
|
||||
throw new Error(
|
||||
`${path} 返回了非 JSON 内容,当前回归脚本需要可直接调用的 API 会话。` +
|
||||
`如果页面被重定向到 /auth 或返回 HTML,说明前端未启用 MNOTE_DEV_AUTH=1,或当前节点没有带上有效的 Convex Auth 会话。` +
|
||||
`响应片段:${snippet}`,
|
||||
);
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
async function createTempDocument(requestContext) {
|
||||
const payload = await requestJson(requestContext, "/api/documents/create", {
|
||||
method: "POST",
|
||||
data: { parentId: null },
|
||||
});
|
||||
|
||||
assert(payload && typeof payload.id === "string", "创建临时页面失败:缺少 id");
|
||||
assert(payload && typeof payload.workspace_id === "string", "创建临时页面失败:缺少 workspace_id");
|
||||
|
||||
return {
|
||||
documentId: payload.id,
|
||||
workspaceId: payload.workspace_id,
|
||||
};
|
||||
}
|
||||
|
||||
async function purgeTempDocument(requestContext, documentId) {
|
||||
await requestJson(requestContext, "/api/documents/purge", {
|
||||
method: "POST",
|
||||
data: { documentId },
|
||||
});
|
||||
}
|
||||
|
||||
async function getViewerIdentity(requestContext) {
|
||||
const payload = await requestJson(requestContext, "/api/auth/whoami", { method: "GET" });
|
||||
assert(payload && typeof payload.userId === "string" && payload.userId, "获取当前用户失败:缺少 userId");
|
||||
return payload;
|
||||
}
|
||||
|
||||
async function ensureAuthenticated(page, requestContext) {
|
||||
await page.goto(`${BASE_URL}/auth`, { waitUntil: "networkidle", timeout: UI_TIMEOUT_MS });
|
||||
|
||||
if (page.url().includes("/auth")) {
|
||||
const quickLoginButton = page.getByRole("button", { name: TEST_LOGIN_BUTTON_NAME });
|
||||
await quickLoginButton.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
await quickLoginButton.click({ timeout: UI_TIMEOUT_MS });
|
||||
await page.waitForURL((url) => !url.toString().includes("/auth"), {
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
}
|
||||
|
||||
return await getViewerIdentity(requestContext);
|
||||
}
|
||||
|
||||
async function runBrowserRegression(page, target) {
|
||||
const uniqueSuffix = Date.now().toString();
|
||||
const nextTitle = `task019-ui-${uniqueSuffix}`;
|
||||
const nextBody = `task019 正文保存回归 ${uniqueSuffix}`;
|
||||
const documentUrl = `${BASE_URL}/documents/${target.documentId}?workspaceId=${encodeURIComponent(target.workspaceId)}`;
|
||||
await page.goto(documentUrl, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
||||
|
||||
const sidebarPanel = page.getByText("页面树");
|
||||
const privateSection = page.getByText("私有 / 我的页面");
|
||||
const titleInput = page.getByLabel("页面标题");
|
||||
const editorSurface = page.locator(".wolai-editor [contenteditable=\"true\"]").first();
|
||||
const saveIndicator = page.locator("text=已保存");
|
||||
|
||||
await sidebarPanel.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
await privateSection.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
await titleInput.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
await editorSurface.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
await saveIndicator.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
|
||||
await titleInput.fill(nextTitle);
|
||||
const titleSaveResponse = page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes("/api/documents/title") &&
|
||||
response.request().method() === "POST" &&
|
||||
response.status() === 200,
|
||||
{ timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
await titleInput.evaluate((node) => {
|
||||
node.blur();
|
||||
});
|
||||
await titleSaveResponse;
|
||||
|
||||
const saveResponse = page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes("/api/documents/save") &&
|
||||
response.request().method() === "POST" &&
|
||||
response.status() === 200 &&
|
||||
(response.request().postData() || "").includes(nextBody),
|
||||
{ timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
await editorSurface.click({ timeout: UI_TIMEOUT_MS });
|
||||
await editorSurface.fill(nextBody, { timeout: UI_TIMEOUT_MS });
|
||||
await saveResponse;
|
||||
await saveIndicator.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
|
||||
await page.reload({ waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
||||
await sidebarPanel.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
await privateSection.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
await titleInput.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
await page.locator(`text=${nextBody}`).first().waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
|
||||
const persistedTitle = await titleInput.inputValue();
|
||||
assert(persistedTitle === nextTitle, `标题刷新后不一致:期望 ${nextTitle},实际 ${persistedTitle}`);
|
||||
const editorText = await page.locator(".wolai-editor").innerText({ timeout: UI_TIMEOUT_MS });
|
||||
assert(editorText.includes(nextBody), "正文刷新后未保留刚写入的内容");
|
||||
|
||||
return {
|
||||
documentUrl,
|
||||
nextTitle,
|
||||
nextBody,
|
||||
};
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const health = await fetch(`${BASE_URL}/`, {
|
||||
method: "HEAD",
|
||||
redirect: "manual",
|
||||
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
||||
});
|
||||
assert(
|
||||
[200, 307, 308].includes(health.status),
|
||||
`首页探活失败:收到状态码 ${health.status}`,
|
||||
);
|
||||
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const context = await browser.newContext({
|
||||
viewport: { width: 1440, height: 960 },
|
||||
});
|
||||
const page = await context.newPage();
|
||||
let tempDocument = null;
|
||||
let regressionResult = null;
|
||||
let caughtError = null;
|
||||
|
||||
try {
|
||||
await ensureAuthenticated(page, context.request);
|
||||
tempDocument = await createTempDocument(context.request);
|
||||
regressionResult = await runBrowserRegression(page, tempDocument);
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
ok: true,
|
||||
workspaceId: tempDocument.workspaceId,
|
||||
documentId: tempDocument.documentId,
|
||||
...regressionResult,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
} catch (error) {
|
||||
caughtError = error;
|
||||
} finally {
|
||||
if (tempDocument?.documentId) {
|
||||
try {
|
||||
await purgeTempDocument(context.request, tempDocument.documentId);
|
||||
} catch (cleanupError) {
|
||||
if (!caughtError) {
|
||||
caughtError = cleanupError;
|
||||
} else {
|
||||
console.error(
|
||||
`清理临时页面失败:${cleanupError instanceof Error ? cleanupError.stack || cleanupError.message : String(cleanupError)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
await page.close().catch(() => undefined);
|
||||
await context.close().catch(() => undefined);
|
||||
await browser.close().catch(() => undefined);
|
||||
}
|
||||
|
||||
if (caughtError) {
|
||||
throw caughtError;
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.stack || error.message : String(error));
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,492 @@
|
||||
"use strict";
|
||||
|
||||
// 说明:
|
||||
// - 这是 task-021 的最小真实浏览器回归脚本。
|
||||
// - 目标覆盖 Mindmap 全屏页、节点新增/删除、保存链与 requestId/traceId 元信息同步。
|
||||
// - 脚本会创建临时页面和临时导图,回归结束后清理,避免污染现有数据。
|
||||
|
||||
const { chromium } = require("playwright");
|
||||
|
||||
const BASE_URL = process.env.MNOTE_UI_BASE_URL || "http://127.0.0.1:3000";
|
||||
const REQUEST_TIMEOUT_MS = 20_000;
|
||||
const UI_TIMEOUT_MS = 30_000;
|
||||
const TEST_LOGIN_BUTTON_NAME = "测试账号快速登录";
|
||||
|
||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
function assert(condition, message) {
|
||||
if (!condition) {
|
||||
throw new Error(message);
|
||||
}
|
||||
}
|
||||
|
||||
async function requestJson(requestContext, path, init = {}) {
|
||||
const response = await requestContext.fetch(`${BASE_URL}${path}`, {
|
||||
...init,
|
||||
headers:
|
||||
init.data !== undefined
|
||||
? {
|
||||
"content-type": "application/json",
|
||||
...(init.headers || {}),
|
||||
}
|
||||
: {
|
||||
...(init.headers || {}),
|
||||
},
|
||||
timeout: REQUEST_TIMEOUT_MS,
|
||||
});
|
||||
|
||||
const text = await response.text();
|
||||
let payload = null;
|
||||
try {
|
||||
payload = text ? JSON.parse(text) : null;
|
||||
} catch {
|
||||
payload = text;
|
||||
}
|
||||
|
||||
if (!response.ok()) {
|
||||
throw new Error(
|
||||
`${path} 请求失败: ${response.status()} ${response.statusText()} ${typeof payload === "string" ? payload : JSON.stringify(payload)}`,
|
||||
);
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
async function createTempDocument(requestContext) {
|
||||
const payload = await requestJson(requestContext, "/api/documents/create", {
|
||||
method: "POST",
|
||||
data: { parentId: null },
|
||||
});
|
||||
|
||||
assert(payload && typeof payload.id === "string", "创建临时页面失败:缺少 id");
|
||||
assert(payload && typeof payload.workspace_id === "string", "创建临时页面失败:缺少 workspace_id");
|
||||
|
||||
return {
|
||||
documentId: payload.id,
|
||||
workspaceId: payload.workspace_id,
|
||||
};
|
||||
}
|
||||
|
||||
async function createTempMindmap(requestContext, documentId, mindmapId) {
|
||||
await requestJson(requestContext, `/api/mindmap/${documentId}/${mindmapId}`, {
|
||||
method: "POST",
|
||||
data: {
|
||||
createOnly: true,
|
||||
data: {
|
||||
data: { text: "中心主题" },
|
||||
children: [],
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function cleanupTempMindmap(requestContext, documentId, mindmapId) {
|
||||
try {
|
||||
await requestJson(requestContext, `/api/mindmap/${documentId}/${mindmapId}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
} catch {
|
||||
// 忽略清理失败,继续尝试 purge 文档。
|
||||
}
|
||||
}
|
||||
|
||||
async function purgeTempDocument(requestContext, documentId) {
|
||||
await requestJson(requestContext, "/api/documents/purge", {
|
||||
method: "POST",
|
||||
data: { documentId },
|
||||
});
|
||||
}
|
||||
|
||||
async function getViewerIdentity(requestContext) {
|
||||
const payload = await requestJson(requestContext, "/api/auth/whoami", { method: "GET" });
|
||||
assert(payload && typeof payload.userId === "string" && payload.userId, "获取当前用户失败:缺少 userId");
|
||||
return payload;
|
||||
}
|
||||
|
||||
async function ensureAuthenticated(page, requestContext) {
|
||||
await page.goto(`${BASE_URL}/auth`, { waitUntil: "networkidle", timeout: UI_TIMEOUT_MS });
|
||||
|
||||
if (page.url().includes("/auth")) {
|
||||
const quickLoginButton = page.getByRole("button", { name: TEST_LOGIN_BUTTON_NAME });
|
||||
await quickLoginButton.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
await quickLoginButton.click({ timeout: UI_TIMEOUT_MS });
|
||||
await page.waitForURL((url) => !url.toString().includes("/auth"), {
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
}
|
||||
|
||||
return await getViewerIdentity(requestContext);
|
||||
}
|
||||
|
||||
async function waitForMindmapInstance(page, mindmapId) {
|
||||
await page.waitForFunction(
|
||||
(id) => Boolean(window.__mindmapInstancesById?.[id] || window.__mindmapInstance),
|
||||
mindmapId,
|
||||
{ timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
}
|
||||
|
||||
async function waitForMindmapReady(page, mindmapId) {
|
||||
await page.waitForFunction(
|
||||
(id) => {
|
||||
const instance = window.__mindmapInstancesById?.[id] || window.__mindmapInstance;
|
||||
const persist = window.__mindmapPersistById?.[id];
|
||||
const fullscreen = document.querySelector("[data-testid=\"mindmap-fullscreen\"]");
|
||||
const requestId = fullscreen?.getAttribute("data-request-id");
|
||||
const traceId = fullscreen?.getAttribute("data-trace-id");
|
||||
return Boolean(instance && persist && requestId && traceId);
|
||||
},
|
||||
mindmapId,
|
||||
{ timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
}
|
||||
|
||||
async function readMindmapMetaAttrs(page) {
|
||||
const fullscreen = page.locator("[data-testid=\"mindmap-fullscreen\"]");
|
||||
return {
|
||||
documentId: await fullscreen.getAttribute("data-document-id"),
|
||||
pageId: await fullscreen.getAttribute("data-page-id"),
|
||||
attachmentId: await fullscreen.getAttribute("data-attachment-id"),
|
||||
mindmapId: await fullscreen.getAttribute("data-mindmap-id"),
|
||||
workspaceId: await fullscreen.getAttribute("data-workspace-id"),
|
||||
requestId: await fullscreen.getAttribute("data-request-id"),
|
||||
traceId: await fullscreen.getAttribute("data-trace-id"),
|
||||
};
|
||||
}
|
||||
|
||||
async function waitForMetaAttrs(page, meta) {
|
||||
await page.waitForFunction(
|
||||
({ requestId, traceId }) => {
|
||||
const el = document.querySelector("[data-testid=\"mindmap-fullscreen\"]");
|
||||
if (!el) return false;
|
||||
return (
|
||||
el.getAttribute("data-request-id") === requestId &&
|
||||
el.getAttribute("data-trace-id") === traceId
|
||||
);
|
||||
},
|
||||
{
|
||||
requestId: meta.requestId,
|
||||
traceId: meta.traceId,
|
||||
},
|
||||
{ timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
}
|
||||
|
||||
async function waitForMetaMutation(page, previousMeta) {
|
||||
await page.waitForFunction(
|
||||
({ requestId, traceId }) => {
|
||||
const el = document.querySelector("[data-testid=\"mindmap-fullscreen\"]");
|
||||
if (!el) return false;
|
||||
const nextRequestId = el.getAttribute("data-request-id");
|
||||
const nextTraceId = el.getAttribute("data-trace-id");
|
||||
return Boolean(
|
||||
nextRequestId &&
|
||||
nextTraceId &&
|
||||
nextRequestId !== requestId &&
|
||||
nextTraceId !== traceId,
|
||||
);
|
||||
},
|
||||
{
|
||||
requestId: previousMeta.requestId,
|
||||
traceId: previousMeta.traceId,
|
||||
},
|
||||
{ timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
return await readMindmapMetaAttrs(page);
|
||||
}
|
||||
|
||||
async function waitForMindmapState(requestContext, documentId, mindmapId, check, description) {
|
||||
const deadline = Date.now() + UI_TIMEOUT_MS;
|
||||
let lastPayload = null;
|
||||
while (Date.now() < deadline) {
|
||||
lastPayload = await requestJson(requestContext, `/api/mindmap/${documentId}/${mindmapId}`);
|
||||
if (check(lastPayload)) {
|
||||
return lastPayload;
|
||||
}
|
||||
await sleep(500);
|
||||
}
|
||||
throw new Error(`${description} 超时:${JSON.stringify(lastPayload)}`);
|
||||
}
|
||||
|
||||
function assertRouteMeta(meta, expected) {
|
||||
assert(meta && typeof meta.requestId === "string" && meta.requestId, "缺少 meta.requestId");
|
||||
assert(meta && typeof meta.traceId === "string" && meta.traceId, "缺少 meta.traceId");
|
||||
assert(meta.documentId === expected.documentId, `documentId 不一致:${meta.documentId}`);
|
||||
assert(meta.pageId === expected.documentId, `pageId 不一致:${meta.pageId}`);
|
||||
assert(meta.mindmapId === expected.mindmapId, `mindmapId 不一致:${meta.mindmapId}`);
|
||||
assert(meta.attachmentId === expected.mindmapId, `attachmentId 不一致:${meta.attachmentId}`);
|
||||
assert(meta.workspaceId === expected.workspaceId, `workspaceId 不一致:${meta.workspaceId}`);
|
||||
}
|
||||
|
||||
async function persistInsertAndRename(page, mindmapId) {
|
||||
return page.evaluate(
|
||||
({ currentMindmapId }) => {
|
||||
const instance =
|
||||
window.__mindmapInstancesById?.[currentMindmapId] ?? window.__mindmapInstance;
|
||||
if (!instance) {
|
||||
throw new Error("未找到 mindmap 实例");
|
||||
}
|
||||
|
||||
const renderer = instance.renderer;
|
||||
const root = renderer?.root ?? renderer?.renderTree?._node;
|
||||
if (!root) {
|
||||
throw new Error("未找到根节点");
|
||||
}
|
||||
|
||||
renderer?.clearActiveNodeList?.();
|
||||
renderer?.addNodeToActiveList?.(root, true);
|
||||
renderer.lastActiveNodeList = [root];
|
||||
renderer?.emitNodeActiveEvent?.(root);
|
||||
instance.execCommand?.("SET_NODE_ACTIVE", root, true);
|
||||
instance.execCommand?.("INSERT_CHILD_NODE", false, [root]);
|
||||
|
||||
const snapshot = instance.getData?.(true) ?? instance.getData?.() ?? null;
|
||||
if (!snapshot?.root?.children?.[0]?.data) {
|
||||
throw new Error("插入子节点后未拿到快照");
|
||||
}
|
||||
|
||||
const persist = window.__mindmapPersistById?.[currentMindmapId];
|
||||
if (!persist) {
|
||||
throw new Error("未找到 mindmap 持久化回调");
|
||||
}
|
||||
persist(snapshot);
|
||||
return {
|
||||
childText: String(snapshot.root.children[0].data.text ?? ""),
|
||||
childCount: snapshot.root.children.length,
|
||||
};
|
||||
},
|
||||
{
|
||||
currentMindmapId: mindmapId,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async function persistDeleteChild(page, mindmapId, childUid) {
|
||||
return page.evaluate(async ({ currentMindmapId, currentChildUid }) => {
|
||||
const instance =
|
||||
window.__mindmapInstancesById?.[currentMindmapId] ?? window.__mindmapInstance;
|
||||
if (!instance) {
|
||||
throw new Error("未找到 mindmap 实例");
|
||||
}
|
||||
const renderer = instance.renderer;
|
||||
const child =
|
||||
typeof renderer?.findNodeByUid === "function"
|
||||
? renderer.findNodeByUid(currentChildUid)
|
||||
: null;
|
||||
if (!child) {
|
||||
throw new Error("删除子节点时未找到目标节点");
|
||||
}
|
||||
renderer?.clearActiveNodeList?.();
|
||||
renderer?.addNodeToActiveList?.(child, true);
|
||||
renderer.lastActiveNodeList = [child];
|
||||
renderer?.emitNodeActiveEvent?.(child);
|
||||
instance.execCommand?.("SET_NODE_ACTIVE", child, true);
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 0));
|
||||
instance.execCommand?.("REMOVE_NODE");
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 0));
|
||||
const snapshot = instance.getData?.(true) ?? instance.getData?.() ?? null;
|
||||
const root = snapshot?.root ?? snapshot;
|
||||
return {
|
||||
childCount:
|
||||
root && typeof root === "object" && Array.isArray(root.children)
|
||||
? root.children.length
|
||||
: -1,
|
||||
};
|
||||
}, { currentMindmapId: mindmapId, currentChildUid: childUid });
|
||||
}
|
||||
|
||||
async function openOutlinePanel(page) {
|
||||
const outlineButton = page.getByRole("button", { name: "大纲" });
|
||||
await outlineButton.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
await outlineButton.click();
|
||||
}
|
||||
|
||||
async function runBrowserRegression(page, requestContext, target) {
|
||||
const uniqueSuffix = Date.now().toString();
|
||||
const mindmapId = `task021-${uniqueSuffix}`;
|
||||
const defaultChildText = "二级节点";
|
||||
|
||||
try {
|
||||
await createTempMindmap(requestContext, target.documentId, mindmapId);
|
||||
|
||||
const mindmapUrl = `${BASE_URL}/mindmap/${target.documentId}/${mindmapId}`;
|
||||
await page.goto(mindmapUrl, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
||||
|
||||
const fullscreen = page.locator("[data-testid=\"mindmap-fullscreen\"]");
|
||||
const canvas = page.locator("[data-testid=\"mindmap-canvas\"]");
|
||||
const rootText = page.getByText("中心主题").first();
|
||||
|
||||
await fullscreen.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
await canvas.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
await rootText.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
await waitForMindmapInstance(page, mindmapId);
|
||||
await waitForMindmapReady(page, mindmapId);
|
||||
|
||||
const initialMetaAttrs = await readMindmapMetaAttrs(page);
|
||||
assert(initialMetaAttrs.documentId === target.documentId, "页面 data-document-id 不正确");
|
||||
assert(initialMetaAttrs.pageId === target.documentId, "页面 data-page-id 不正确");
|
||||
assert(initialMetaAttrs.mindmapId === mindmapId, "页面 data-mindmap-id 不正确");
|
||||
assert(initialMetaAttrs.attachmentId === mindmapId, "页面 data-attachment-id 不正确");
|
||||
assert(initialMetaAttrs.workspaceId === target.workspaceId, "页面 data-workspace-id 不正确");
|
||||
|
||||
const insertMutation = await persistInsertAndRename(page, mindmapId);
|
||||
assert(insertMutation.childCount === 1, `插入子节点后数量异常:${insertMutation.childCount}`);
|
||||
assert(insertMutation.childText, "插入子节点后名称为空");
|
||||
|
||||
const insertSaveMeta = await waitForMetaMutation(page, initialMetaAttrs);
|
||||
assertRouteMeta(insertSaveMeta, {
|
||||
documentId: target.documentId,
|
||||
mindmapId,
|
||||
workspaceId: target.workspaceId,
|
||||
});
|
||||
await waitForMindmapState(
|
||||
requestContext,
|
||||
target.documentId,
|
||||
mindmapId,
|
||||
(payload) => Array.isArray(payload?.data?.children) && payload.data.children.length === 1,
|
||||
"插入子节点后后端回查",
|
||||
);
|
||||
|
||||
await page.reload({ waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
||||
await fullscreen.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
await canvas.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
await waitForMindmapInstance(page, mindmapId);
|
||||
await waitForMindmapReady(page, mindmapId);
|
||||
const beforeDeleteMeta = await readMindmapMetaAttrs(page);
|
||||
const insertSavedMindmap = await requestJson(requestContext, `/api/mindmap/${target.documentId}/${mindmapId}`);
|
||||
assert(
|
||||
Array.isArray(insertSavedMindmap.data?.children) &&
|
||||
insertSavedMindmap.data.children.length === 1,
|
||||
"刷新后导图子节点数量不正确",
|
||||
);
|
||||
assert(
|
||||
typeof insertSavedMindmap.data.children[0]?.data?.text === "string" &&
|
||||
insertSavedMindmap.data.children[0].data.text.trim(),
|
||||
"刷新后导图子节点名称为空",
|
||||
);
|
||||
const persistedChildText =
|
||||
String(insertSavedMindmap.data.children[0]?.data?.text ?? "")
|
||||
.replace(/<[^>]+>/g, "")
|
||||
.trim() || defaultChildText;
|
||||
const persistedChildUid = String(insertSavedMindmap.data.children[0]?.data?.uid ?? "");
|
||||
assert(persistedChildUid, "刷新后导图子节点缺少 uid");
|
||||
await openOutlinePanel(page);
|
||||
await page.getByText(persistedChildText).first().waitFor({
|
||||
state: "visible",
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
|
||||
const deleteMutation = await persistDeleteChild(page, mindmapId, persistedChildUid);
|
||||
assert(deleteMutation.childCount === 0, `删除子节点后数量异常:${deleteMutation.childCount}`);
|
||||
|
||||
const deleteSaveMeta = await waitForMetaMutation(page, beforeDeleteMeta);
|
||||
assertRouteMeta(deleteSaveMeta, {
|
||||
documentId: target.documentId,
|
||||
mindmapId,
|
||||
workspaceId: target.workspaceId,
|
||||
});
|
||||
await waitForMindmapState(
|
||||
requestContext,
|
||||
target.documentId,
|
||||
mindmapId,
|
||||
(payload) => Array.isArray(payload?.data?.children) && payload.data.children.length === 0,
|
||||
"删除子节点后后端回查",
|
||||
);
|
||||
|
||||
await page.reload({ waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
||||
await fullscreen.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
await canvas.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
await waitForMindmapInstance(page, mindmapId);
|
||||
await waitForMindmapReady(page, mindmapId);
|
||||
await openOutlinePanel(page);
|
||||
|
||||
await page.getByText("中心主题").first().waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
|
||||
const deleteSavedMindmap = await requestJson(requestContext, `/api/mindmap/${target.documentId}/${mindmapId}`);
|
||||
assert(
|
||||
Array.isArray(deleteSavedMindmap.data?.children) &&
|
||||
deleteSavedMindmap.data.children.length === 0,
|
||||
"删除子节点后后端仍保留子节点",
|
||||
);
|
||||
const canvasText = await canvas.innerText({ timeout: UI_TIMEOUT_MS });
|
||||
assert(!canvasText.includes(persistedChildText), "删除子节点后画布仍残留旧节点文本");
|
||||
|
||||
return {
|
||||
mindmapUrl,
|
||||
mindmapId,
|
||||
childText: persistedChildText,
|
||||
initialMetaAttrs,
|
||||
insertMeta: insertSaveMeta,
|
||||
deleteMeta: deleteSaveMeta,
|
||||
};
|
||||
} finally {
|
||||
await cleanupTempMindmap(requestContext, target.documentId, mindmapId);
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const health = await fetch(`${BASE_URL}/`, {
|
||||
method: "HEAD",
|
||||
redirect: "manual",
|
||||
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
||||
});
|
||||
assert(
|
||||
[200, 307, 308].includes(health.status),
|
||||
`首页探活失败:收到状态码 ${health.status}`,
|
||||
);
|
||||
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const context = await browser.newContext({
|
||||
viewport: { width: 1440, height: 960 },
|
||||
});
|
||||
const page = await context.newPage();
|
||||
let tempDocument = null;
|
||||
let regressionResult = null;
|
||||
let caughtError = null;
|
||||
|
||||
try {
|
||||
await ensureAuthenticated(page, context.request);
|
||||
tempDocument = await createTempDocument(context.request);
|
||||
regressionResult = await runBrowserRegression(page, context.request, tempDocument);
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
ok: true,
|
||||
workspaceId: tempDocument.workspaceId,
|
||||
documentId: tempDocument.documentId,
|
||||
...regressionResult,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
} catch (error) {
|
||||
caughtError = error;
|
||||
} finally {
|
||||
if (tempDocument?.documentId) {
|
||||
try {
|
||||
await purgeTempDocument(context.request, tempDocument.documentId);
|
||||
} catch (cleanupError) {
|
||||
if (!caughtError) {
|
||||
caughtError = cleanupError;
|
||||
} else {
|
||||
console.error(
|
||||
`清理临时页面失败:${cleanupError instanceof Error ? cleanupError.stack || cleanupError.message : String(cleanupError)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
await page.close().catch(() => undefined);
|
||||
await context.close().catch(() => undefined);
|
||||
await browser.close().catch(() => undefined);
|
||||
}
|
||||
|
||||
if (caughtError) {
|
||||
throw caughtError;
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.stack || error.message : String(error));
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,414 @@
|
||||
"use strict";
|
||||
|
||||
// 说明:
|
||||
// - 这是 task-022 的最小真实浏览器回归脚本。
|
||||
// - 目标覆盖 OnlyOffice 页面打开、插件桥接插入文本、forcesave 按钮、callback 写回闭环。
|
||||
// - 脚本会创建临时页面并上传临时 docx,回归结束后 purge 页面,避免污染现有数据。
|
||||
|
||||
const fs = require("node:fs");
|
||||
const { chromium } = require("playwright");
|
||||
|
||||
const BASE_URL = process.env.MNOTE_UI_BASE_URL || "http://127.0.0.1:3000";
|
||||
const REQUEST_TIMEOUT_MS = 20_000;
|
||||
const UI_TIMEOUT_MS = 120_000;
|
||||
const CALLBACK_TIMEOUT_MS = 90_000;
|
||||
const PROBE_DOCX_PATH = process.env.MNOTE_ONLYOFFICE_PROBE_DOCX || "/tmp/mnote-onlyoffice-probe/probe.docx";
|
||||
const ONLYOFFICE_PLUGIN_CHANNEL = "mnote_onlyoffice_agent_tools_v1";
|
||||
const DOCX_MIME = "application/vnd.openxmlformats-officedocument.wordprocessingml.document";
|
||||
const TEST_LOGIN_BUTTON_NAME = "测试账号快速登录";
|
||||
|
||||
function assert(condition, message) {
|
||||
if (!condition) {
|
||||
throw new Error(message);
|
||||
}
|
||||
}
|
||||
|
||||
async function requestPayload(requestContext, path, init = {}) {
|
||||
const headers =
|
||||
init.multipart || init.form
|
||||
? { ...(init.headers || {}) }
|
||||
: init.data !== undefined
|
||||
? {
|
||||
"content-type": "application/json",
|
||||
...(init.headers || {}),
|
||||
}
|
||||
: { ...(init.headers || {}) };
|
||||
|
||||
const response = await requestContext.fetch(`${BASE_URL}${path}`, {
|
||||
...init,
|
||||
headers,
|
||||
timeout: REQUEST_TIMEOUT_MS,
|
||||
});
|
||||
|
||||
const text = await response.text();
|
||||
let payload = null;
|
||||
try {
|
||||
payload = text ? JSON.parse(text) : null;
|
||||
} catch {
|
||||
payload = text;
|
||||
}
|
||||
|
||||
if (!response.ok()) {
|
||||
throw new Error(
|
||||
`${path} 请求失败: ${response.status()} ${response.statusText()} ${typeof payload === "string" ? payload : JSON.stringify(payload)}`,
|
||||
);
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
async function createTempDocument(requestContext) {
|
||||
const payload = await requestPayload(requestContext, "/api/documents/create", {
|
||||
method: "POST",
|
||||
data: { parentId: null },
|
||||
});
|
||||
|
||||
assert(payload && typeof payload.id === "string", "创建临时页面失败:缺少 id");
|
||||
assert(payload && typeof payload.workspace_id === "string", "创建临时页面失败:缺少 workspace_id");
|
||||
|
||||
return {
|
||||
documentId: payload.id,
|
||||
workspaceId: payload.workspace_id,
|
||||
};
|
||||
}
|
||||
|
||||
async function purgeTempDocument(requestContext, documentId) {
|
||||
await requestPayload(requestContext, "/api/documents/purge", {
|
||||
method: "POST",
|
||||
data: { documentId },
|
||||
});
|
||||
}
|
||||
|
||||
async function getViewerIdentity(requestContext) {
|
||||
const payload = await requestPayload(requestContext, "/api/auth/whoami", { method: "GET" });
|
||||
assert(payload && typeof payload.userId === "string" && payload.userId, "获取当前用户失败:缺少 userId");
|
||||
return payload;
|
||||
}
|
||||
|
||||
async function uploadProbeDocx(requestContext, target) {
|
||||
assert(fs.existsSync(PROBE_DOCX_PATH), `缺少探测文件:${PROBE_DOCX_PATH}`);
|
||||
const buffer = fs.readFileSync(PROBE_DOCX_PATH);
|
||||
const payload = await requestPayload(requestContext, "/api/media/upload", {
|
||||
method: "POST",
|
||||
multipart: {
|
||||
file: {
|
||||
name: "task022-probe.docx",
|
||||
mimeType: DOCX_MIME,
|
||||
buffer,
|
||||
},
|
||||
workspaceId: target.workspaceId,
|
||||
documentId: target.documentId,
|
||||
},
|
||||
});
|
||||
|
||||
assert(payload && payload.asset && typeof payload.asset.id === "string", "上传探测 docx 失败:缺少 asset.id");
|
||||
return payload.asset;
|
||||
}
|
||||
|
||||
async function getSignedAsset(requestContext, assetId) {
|
||||
const payload = await requestPayload(requestContext, `/api/media/sign?assetId=${encodeURIComponent(assetId)}`, {
|
||||
method: "GET",
|
||||
});
|
||||
assert(payload && typeof payload.signedUrl === "string" && payload.signedUrl, "缺少 signedUrl");
|
||||
return payload;
|
||||
}
|
||||
|
||||
async function ensureAuthenticated(page, requestContext) {
|
||||
await page.goto(`${BASE_URL}/auth`, { waitUntil: "networkidle", timeout: UI_TIMEOUT_MS });
|
||||
|
||||
if (page.url().includes("/auth")) {
|
||||
const quickLoginButton = page.getByRole("button", { name: TEST_LOGIN_BUTTON_NAME });
|
||||
await quickLoginButton.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
await quickLoginButton.click({ timeout: UI_TIMEOUT_MS });
|
||||
await page.waitForURL((url) => !url.toString().includes("/auth"), {
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
}
|
||||
|
||||
return await getViewerIdentity(requestContext);
|
||||
}
|
||||
|
||||
async function installPluginBridge(page) {
|
||||
await page.addInitScript(
|
||||
({ channel }) => {
|
||||
const state = {
|
||||
channel,
|
||||
ready: false,
|
||||
origin: "*",
|
||||
target: null,
|
||||
pending: new Map(),
|
||||
};
|
||||
|
||||
window.__TASK022_ONLYOFFICE_PLUGIN__ = state;
|
||||
window.addEventListener("message", (event) => {
|
||||
const data = event?.data;
|
||||
if (!data || typeof data !== "object") return;
|
||||
if (data.channel !== channel) return;
|
||||
|
||||
if (data.type === "ready") {
|
||||
state.ready = true;
|
||||
state.origin = String(event.origin || "*");
|
||||
state.target =
|
||||
event.source && typeof event.source.postMessage === "function" ? event.source : null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.type === "result") {
|
||||
const callId = String(data.callId || "").trim();
|
||||
if (!callId) return;
|
||||
const pending = state.pending.get(callId);
|
||||
if (!pending) return;
|
||||
state.pending.delete(callId);
|
||||
window.clearTimeout(pending.timeoutId);
|
||||
if (data.ok) {
|
||||
pending.resolve(data.result ?? null);
|
||||
} else {
|
||||
pending.reject(new Error(String(data.error || "插件执行失败")));
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
{ channel: ONLYOFFICE_PLUGIN_CHANNEL },
|
||||
);
|
||||
}
|
||||
|
||||
async function waitForOnlyOfficeReady(page) {
|
||||
await page.waitForFunction(() => window.__MNOTE_ONLYOFFICE_READY__ === true, {
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
await page.waitForFunction(
|
||||
() => {
|
||||
const root = document.getElementById("onlyoffice-frame");
|
||||
if (root && root.querySelector("iframe,canvas")) return true;
|
||||
return Boolean(document.querySelector("iframe,canvas"));
|
||||
},
|
||||
{ timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
}
|
||||
|
||||
function getEditorIframe(page) {
|
||||
return page.locator('iframe[src*="/documenteditor/main/index.html"]').first();
|
||||
}
|
||||
|
||||
async function waitForPluginReady(page) {
|
||||
await page.waitForFunction(
|
||||
() => Boolean(window.__TASK022_ONLYOFFICE_PLUGIN__?.ready && window.__TASK022_ONLYOFFICE_PLUGIN__?.target),
|
||||
{ timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
}
|
||||
|
||||
async function callOnlyOfficePlugin(page, tool, args) {
|
||||
return page.evaluate(
|
||||
async ({ channel, toolName, toolArgs }) => {
|
||||
const state = window.__TASK022_ONLYOFFICE_PLUGIN__;
|
||||
if (!state || !state.ready || !state.target) {
|
||||
throw new Error("OnlyOffice 插件桥未就绪");
|
||||
}
|
||||
|
||||
const callId = `task022-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
return await new Promise((resolve, reject) => {
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
state.pending.delete(callId);
|
||||
reject(new Error(`插件调用超时: ${toolName}`));
|
||||
}, 60_000);
|
||||
|
||||
state.pending.set(callId, { resolve, reject, timeoutId });
|
||||
state.target.postMessage(
|
||||
{
|
||||
channel,
|
||||
type: "call",
|
||||
callId,
|
||||
tool: toolName,
|
||||
args: toolArgs,
|
||||
},
|
||||
state.origin || "*",
|
||||
);
|
||||
});
|
||||
},
|
||||
{
|
||||
channel: ONLYOFFICE_PLUGIN_CHANNEL,
|
||||
toolName: tool,
|
||||
toolArgs: args,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async function getOnlyOfficeDebug(page) {
|
||||
return page.evaluate(() => ({
|
||||
ready: Boolean(window.__MNOTE_ONLYOFFICE_READY__),
|
||||
debug: window.__MNOTE_ONLYOFFICE_DEBUG__ ?? null,
|
||||
errlog: window.__MNOTE_ONLYOFFICE_ERRLOG__ ?? [],
|
||||
}));
|
||||
}
|
||||
|
||||
async function waitForStorageIdChange(requestContext, assetId, previousStorageId) {
|
||||
const startedAt = Date.now();
|
||||
while (Date.now() - startedAt < CALLBACK_TIMEOUT_MS) {
|
||||
const payload = await getSignedAsset(requestContext, assetId);
|
||||
const nextStorageId = String(payload.asset?.storage_id || "").trim();
|
||||
if (nextStorageId && nextStorageId !== previousStorageId) {
|
||||
return payload;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 2_000));
|
||||
}
|
||||
throw new Error(`等待 callback 写回超时:storage_id 仍为 ${previousStorageId || "<empty>"}`);
|
||||
}
|
||||
|
||||
async function runBrowserRegression(page, requestContext, viewer, target) {
|
||||
const asset = await uploadProbeDocx(requestContext, target);
|
||||
const initialSigned = await getSignedAsset(requestContext, asset.id);
|
||||
const initialStorageId = String(initialSigned.asset?.storage_id || "").trim();
|
||||
assert(initialStorageId, "初始 storage_id 为空");
|
||||
|
||||
const uniqueSuffix = Date.now().toString();
|
||||
const insertedText = ` task022-onlyoffice-${uniqueSuffix} `;
|
||||
|
||||
await installPluginBridge(page);
|
||||
|
||||
try {
|
||||
const pageUrl = new URL("/onlyoffice", BASE_URL);
|
||||
pageUrl.searchParams.set("fileUrl", String(initialSigned.signedUrl));
|
||||
pageUrl.searchParams.set("fileName", "task022-probe.docx");
|
||||
pageUrl.searchParams.set("fileType", "docx");
|
||||
pageUrl.searchParams.set("mode", "edit");
|
||||
pageUrl.searchParams.set("assetId", asset.id);
|
||||
pageUrl.searchParams.set("documentId", target.documentId);
|
||||
pageUrl.searchParams.set("userId", viewer.userId);
|
||||
pageUrl.searchParams.set("channel", "web");
|
||||
|
||||
await page.goto(pageUrl.toString(), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
||||
await page.getByText("ONLYOFFICE 加载失败").waitFor({ state: "hidden", timeout: 5_000 }).catch(() => null);
|
||||
|
||||
await waitForOnlyOfficeReady(page);
|
||||
await waitForPluginReady(page);
|
||||
|
||||
const editorIframe = getEditorIframe(page);
|
||||
await editorIframe.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
await editorIframe.click({ position: { x: 160, y: 120 }, timeout: UI_TIMEOUT_MS });
|
||||
|
||||
const initialDebug = await getOnlyOfficeDebug(page);
|
||||
assert(initialDebug.ready === true, "OnlyOffice ready 标记未就绪");
|
||||
assert(initialDebug.debug && initialDebug.debug.assetId === asset.id, "OnlyOffice debug.assetId 不正确");
|
||||
assert(initialDebug.debug && initialDebug.debug.documentId === target.documentId, "OnlyOffice debug.documentId 不正确");
|
||||
assert(initialDebug.debug && initialDebug.debug.baseUrl === "/onlyoffice-server", `OnlyOffice baseUrl 异常:${JSON.stringify(initialDebug.debug)}`);
|
||||
assert(
|
||||
initialDebug.debug && typeof initialDebug.debug.resolvedFileUrl === "string" && initialDebug.debug.resolvedFileUrl.includes("/api/onlyoffice/proxy"),
|
||||
`OnlyOffice resolvedFileUrl 未走 proxy:${JSON.stringify(initialDebug.debug)}`,
|
||||
);
|
||||
assert(initialDebug.debug && typeof initialDebug.debug.docKey === "string" && initialDebug.debug.docKey, "OnlyOffice debug.docKey 为空");
|
||||
|
||||
const pluginResult = await callOnlyOfficePlugin(page, "oo_insert_text", { text: insertedText });
|
||||
assert(pluginResult && pluginResult.ok === true, `插件插入文本失败:${JSON.stringify(pluginResult)}`);
|
||||
|
||||
await page.waitForTimeout(2_000);
|
||||
|
||||
const forceSaveResponsePromise = page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes(`/api/onlyoffice/forcesave?assetId=${encodeURIComponent(asset.id)}`) &&
|
||||
response.request().method() === "POST" &&
|
||||
response.status() === 200,
|
||||
{ timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
|
||||
const forceSaveButton = page.getByRole("button", { name: "同步保存" });
|
||||
await forceSaveButton.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
await forceSaveButton.click({ timeout: UI_TIMEOUT_MS });
|
||||
const forceSaveResponse = await forceSaveResponsePromise;
|
||||
const forceSavePayload = await forceSaveResponse.json();
|
||||
assert(forceSavePayload && forceSavePayload.ok === true, `forcesave 返回异常:${JSON.stringify(forceSavePayload)}`);
|
||||
|
||||
await page.getByText("已触发同步保存").waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
const updatedSigned = await waitForStorageIdChange(requestContext, asset.id, initialStorageId);
|
||||
|
||||
await page.reload({ waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
||||
await waitForOnlyOfficeReady(page);
|
||||
await waitForPluginReady(page);
|
||||
|
||||
const reloadDebug = await getOnlyOfficeDebug(page);
|
||||
assert(reloadDebug.ready === true, "刷新后 OnlyOffice ready 标记未就绪");
|
||||
assert(
|
||||
String(updatedSigned.asset?.storage_id || "").trim() !== initialStorageId,
|
||||
"callback 写回后 storage_id 未发生变化",
|
||||
);
|
||||
|
||||
return {
|
||||
pageUrl: pageUrl.toString(),
|
||||
assetId: asset.id,
|
||||
initialStorageId,
|
||||
updatedStorageId: String(updatedSigned.asset?.storage_id || "").trim(),
|
||||
insertedText,
|
||||
debug: reloadDebug.debug,
|
||||
errlog: reloadDebug.errlog,
|
||||
};
|
||||
} catch (error) {
|
||||
const debug = await getOnlyOfficeDebug(page).catch(() => null);
|
||||
if (debug) {
|
||||
console.error(JSON.stringify({ onlyofficeDebug: debug }, null, 2));
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const health = await fetch(`${BASE_URL}/`, {
|
||||
method: "HEAD",
|
||||
redirect: "manual",
|
||||
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
||||
});
|
||||
assert([200, 307, 308].includes(health.status), `首页探活失败:收到状态码 ${health.status}`);
|
||||
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const context = await browser.newContext({
|
||||
viewport: { width: 1440, height: 960 },
|
||||
});
|
||||
const page = await context.newPage();
|
||||
let tempDocument = null;
|
||||
let caughtError = null;
|
||||
|
||||
try {
|
||||
const viewer = await ensureAuthenticated(page, context.request);
|
||||
tempDocument = await createTempDocument(context.request);
|
||||
const result = await runBrowserRegression(page, context.request, viewer, tempDocument);
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
ok: true,
|
||||
workspaceId: tempDocument.workspaceId,
|
||||
documentId: tempDocument.documentId,
|
||||
...result,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
} catch (error) {
|
||||
caughtError = error;
|
||||
} finally {
|
||||
if (tempDocument?.documentId) {
|
||||
try {
|
||||
await purgeTempDocument(context.request, tempDocument.documentId);
|
||||
} catch (cleanupError) {
|
||||
if (!caughtError) {
|
||||
caughtError = cleanupError;
|
||||
} else {
|
||||
console.error(
|
||||
`清理临时页面失败:${cleanupError instanceof Error ? cleanupError.stack || cleanupError.message : String(cleanupError)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await page.close().catch(() => undefined);
|
||||
await context.close().catch(() => undefined);
|
||||
await browser.close().catch(() => undefined);
|
||||
}
|
||||
|
||||
if (caughtError) {
|
||||
throw caughtError;
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.stack || error.message : String(error));
|
||||
process.exit(1);
|
||||
});
|
||||
Reference in New Issue
Block a user