@nextclaw/x-harness 0.2.52 → 0.2.54

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/SOURCE.md CHANGED
@@ -1,8 +1,8 @@
1
1
  # Corresponding source
2
2
 
3
- Version 0.2.52, commit b3e3310d4977db067f05ee409f58ae619f042c9a.
3
+ Version 0.2.54, commit a9b2bb0d7399463a8dc259e433e3a2bd31dc9bc7.
4
4
 
5
- Complete source, build scripts and dependencies: https://github.com/Peiiii/x-harness/tree/b3e3310d4977db067f05ee409f58ae619f042c9a
5
+ Complete source, build scripts and dependencies: https://github.com/Peiiii/x-harness/tree/a9b2bb0d7399463a8dc259e433e3a2bd31dc9bc7
6
6
 
7
7
  Clone this revision, run npm ci, npm run bootstrap and npm run release:build.
8
8
 
@@ -198,7 +198,7 @@ export class DOMEditing {
198
198
  .locator(`a[href=${JSON.stringify(primary)}]`)
199
199
  .filter({ visible: true }));
200
200
  this.#session.current = null;
201
- await anchor.press("End");
201
+ await anchor.press("PageDown");
202
202
  return { completed: true };
203
203
  return { completed: false, loc, submittedEditor };
204
204
  }
@@ -3,5 +3,6 @@ export declare class DOMSubmission {
3
3
  #private;
4
4
  constructor(dependencies: Pick<DOMDependencies, "submitAX" | "session" | "tab" | "observe" | "unique" | "locator">);
5
5
  finishDOMAction(command: any, prepared: any, query: any, result: any): Promise<void>;
6
+ private waitSubmittedEditor;
6
7
  }
7
8
  export declare function createDOMSubmission(...args: ConstructorParameters<typeof DOMSubmission>): (command: any, prepared: any, query: any, result: any) => Promise<void>;
@@ -1,4 +1,4 @@
1
- import { xURL } from "./platform-rules.js";
1
+ import { xURL, sameHandle, sameText } from "./platform-rules.js";
2
2
  import { valueOf } from "./browser-common.js";
3
3
  export class DOMSubmission {
4
4
  #submitAX;
@@ -29,6 +29,7 @@ export class DOMSubmission {
29
29
  : null;
30
30
  let nativeSubmitIndex;
31
31
  if (this.#submitAX &&
32
+ command.activation !== "keyboard" &&
32
33
  ["submitPost", "submitReply"].includes(command.operation)) {
33
34
  const native = await this.#submitAX.observe({ mode: "composer" });
34
35
  const editors = native.evidence?.filter((e) => /^(?:文本输入区(?:域)?|文本区域|文本栏) /.test(e.label)) ?? [];
@@ -88,7 +89,7 @@ export class DOMSubmission {
88
89
  // Inline replies keep the editor mounted. Wait for this submitted text to clear before
89
90
  // receipt navigation/reload can interrupt the actual submission request.
90
91
  if (submittedEditor)
91
- await submittedEditor.waitFor({ state: "hidden", timeoutMs: 10000 });
92
+ await this.waitSubmittedEditor(submittedEditor, result);
92
93
  if (["submitPost", "submitReply"].includes(command.operation)) {
93
94
  try {
94
95
  await this.#tab.playwright.waitForLoadState({
@@ -104,6 +105,36 @@ export class DOMSubmission {
104
105
  }
105
106
  }
106
107
  }
108
+ async waitSubmittedEditor(editor, submitted) {
109
+ const deadline = Date.now() + 10000;
110
+ for (let attempt = 0; attempt < 4; attempt++) {
111
+ const remaining = deadline - Date.now();
112
+ if (remaining <= 0)
113
+ throw Error("submitted-editor-clear-timeout");
114
+ try {
115
+ await editor.waitFor({ state: "hidden", timeoutMs: remaining });
116
+ return;
117
+ }
118
+ catch (error) {
119
+ if (!/timed out|timeout|deadline exceeded/i.test(error.message) ||
120
+ attempt === 3 ||
121
+ Date.now() >= deadline)
122
+ throw error;
123
+ // Resume only the wait, never the submission or a navigation that could interrupt it.
124
+ const current = await this.#observe({ view: "composer" });
125
+ if (current.alerts?.length ||
126
+ !current.accountHandle ||
127
+ !submitted.accountHandle ||
128
+ !sameHandle(current.accountHandle, submitted.accountHandle) ||
129
+ xURL(current.page).href !== xURL(submitted.page).href ||
130
+ (typeof current.editorText === "string" &&
131
+ current.editorText.trim() &&
132
+ (!sameText(current.editorText, submitted.editorText) ||
133
+ current.replyToId !== submitted.replyToId)))
134
+ throw error;
135
+ }
136
+ }
137
+ }
107
138
  }
108
139
  export function createDOMSubmission(...args) {
109
140
  return new DOMSubmission(...args).finishDOMAction;
@@ -7,8 +7,10 @@ export declare class PlatformCollections {
7
7
  searchSources: ({ query, limit, maxPages, processedIds, }?: any) => Promise<any>;
8
8
  constructor(dependencies: Pick<PlatformDependencies, "ready" | "observed" | "browser" | "choose">);
9
9
  private handleCollectSources;
10
+ private advanceSourceWindow;
10
11
  private handleReadCreator;
11
12
  private handleReadBookmarks;
13
+ private resumeBookmarks;
12
14
  private confirmBookmarkFolder;
13
15
  private bookmarkDiscovery;
14
16
  private waitForBookmarkLabel;
@@ -117,28 +117,19 @@ export class PlatformCollections {
117
117
  if (last.moreInDOM)
118
118
  offset += 5;
119
119
  else {
120
- await this.#browser.act({ operation: "nextPosts" });
121
- const scrolled = await this.#ready({ view: "posts", limit: 5 });
122
- if (scrolled.needsStateCheck || scrolled.alerts?.length)
120
+ const advanced = await this.advanceSourceWindow(last, seen);
121
+ if (advanced.needsStateCheck || advanced.alerts?.length)
123
122
  return {
124
- ...scrolled,
123
+ ...advanced,
125
124
  sources,
126
125
  complete: false,
127
126
  missing: ["source-page-unconfirmed"],
128
127
  };
129
- // Scroll and lazy loading settle asynchronously. Existing cards are
130
- // already visible, so waiting for the first article proves nothing.
131
- try {
132
- await this.#browser.act({
133
- operation: "waitForPosts",
134
- excludeIds: [...seen],
135
- });
128
+ if (advanced.paginationBoundary) {
129
+ last = advanced;
130
+ missing.push("stable-rendered-tail");
131
+ break;
136
132
  }
137
- catch (error) {
138
- if (!/timed out|timeout/i.test(error.message))
139
- throw error;
140
- }
141
- const advanced = await this.#ready({ view: "posts", limit: 5 });
142
133
  // Retained DOM prefixes include a partial final window too; skip
143
134
  // exactly the consumed cards, while virtualized replacements restart.
144
135
  offset =
@@ -157,11 +148,49 @@ export class PlatformCollections {
157
148
  sources,
158
149
  complete: sources.length >= limit,
159
150
  pageComplete: false,
151
+ ...(last?.paginationBoundary
152
+ ? { paginationBoundary: last.paginationBoundary }
153
+ : {}),
160
154
  missing: sources.length >= limit
161
155
  ? []
162
156
  : [...new Set([...missing, "bounded-source-window-exhausted"])],
163
157
  };
164
158
  }
159
+ async advanceSourceWindow(previous, seen) {
160
+ const renderedIds = previous.renderedIds ?? [...seen];
161
+ await this.#browser.act({ operation: "nextPosts" });
162
+ let advanced = await this.#ready({ view: "posts", limit: 5 });
163
+ if (advanced.needsStateCheck || advanced.alerts?.length)
164
+ return advanced;
165
+ let timedOut = false;
166
+ try {
167
+ // Wait for a newly rendered card, even if that source was processed on an
168
+ // earlier run. Global processed IDs do not describe the current DOM window.
169
+ await this.#browser.act({
170
+ operation: "waitForPosts",
171
+ excludeIds: renderedIds,
172
+ });
173
+ }
174
+ catch (error) {
175
+ if (!/timed out|timeout/i.test(error.message))
176
+ throw error;
177
+ timedOut = true;
178
+ }
179
+ advanced = await this.#ready({ view: "posts", limit: 5 });
180
+ if (timedOut &&
181
+ advanced.atRenderedTail &&
182
+ !advanced.loading &&
183
+ advanced.firstRenderedId === previous.firstRenderedId &&
184
+ advanced.renderedArticles === previous.renderedArticles)
185
+ advanced.paginationBoundary = {
186
+ kind: "stable-rendered-tail",
187
+ lastId: advanced.renderedIds?.at(-1),
188
+ renderedCount: advanced.renderedArticles,
189
+ waitMs: 5000,
190
+ scopeComplete: false,
191
+ };
192
+ return advanced;
193
+ }
165
194
  async handleReadCreator(args) {
166
195
  if (typeof this.#browser.collectCreator !== "function")
167
196
  return {
@@ -186,7 +215,7 @@ export class PlatformCollections {
186
215
  missing: absent,
187
216
  };
188
217
  }
189
- async handleReadBookmarks({ folder, discoverOnly = false, ...window } = {}) {
218
+ async handleReadBookmarks({ folder, discoverOnly = false, resume = false, ...window } = {}) {
190
219
  if (!discoverOnly && (typeof folder !== "string" || !folder.trim()))
191
220
  throw new Error("需指定收藏文件夹;不以全部书签冒充命名文件夹");
192
221
  let nav = await this.#ready({
@@ -194,6 +223,10 @@ export class PlatformCollections {
194
223
  targetLabel: folder,
195
224
  bookmarkNavigation: true,
196
225
  });
226
+ if (resume && discoverOnly)
227
+ throw Error("bookmark-resume-discovery-conflict");
228
+ if (resume)
229
+ return this.resumeBookmarks(folder, window, nav.page);
197
230
  if (discoverOnly && nav.bookmarkFolders?.length)
198
231
  return this.bookmarkDiscovery(nav);
199
232
  const bookmark = (c) => /^(书签|收藏|Bookmarks)$/i.test(c.label);
@@ -308,6 +341,17 @@ export class PlatformCollections {
308
341
  },
309
342
  };
310
343
  }
344
+ async resumeBookmarks(folder, window, entryPage) {
345
+ const selected = await this.confirmBookmarkFolder(folder);
346
+ if (selected.status)
347
+ return selected;
348
+ const value = await this.handleCollectSources(window);
349
+ return {
350
+ ...value,
351
+ folder,
352
+ folderEvidence: { entryPage, selectedPage: selected.page, name: folder },
353
+ };
354
+ }
311
355
  async confirmBookmarkFolder(folder) {
312
356
  let selected = await this.#ready({ view: "navigation" });
313
357
  if (!selected.headings?.includes(folder)) {
@@ -102,6 +102,17 @@ export function readPosts({ offset = 0, limit = 5, targetId = "", } = {}) {
102
102
  ?.getAttribute("href")
103
103
  ?.match(/\/status\/(\d+)/)?.[1] ?? null,
104
104
  renderedArticles: all.length,
105
+ renderedIds: all
106
+ .map((a) => primaryTime(a)
107
+ ?.closest("a")
108
+ ?.getAttribute("href")
109
+ ?.match(/\/status\/(\d+)/)?.[1])
110
+ .filter(Boolean),
111
+ atRenderedTail: Boolean(document.scrollingElement) &&
112
+ document.scrollingElement.scrollTop +
113
+ document.scrollingElement.clientHeight >=
114
+ document.scrollingElement.scrollHeight - 2,
115
+ loading: Array.from(document.querySelectorAll('[role="progressbar"]')).some(visible),
105
116
  matched: candidates.length,
106
117
  offset,
107
118
  moreInDOM: candidates.length > offset + posts.length,
package/dist/results.js CHANGED
@@ -162,6 +162,14 @@ export function readResult(operation, value, mode) {
162
162
  "moreInDOM",
163
163
  ]);
164
164
  if (operation === "readBookmarks") {
165
+ if (raw.paginationBoundary)
166
+ out.paginationBoundary = pick(raw.paginationBoundary, [
167
+ "kind",
168
+ "lastId",
169
+ "renderedCount",
170
+ "waitMs",
171
+ "scopeComplete",
172
+ ]);
165
173
  if (raw.folders !== undefined)
166
174
  out.folders = strings(raw.folders);
167
175
  if (typeof raw.folder === "string")
package/dist/types.d.ts CHANGED
@@ -116,6 +116,13 @@ export interface ReadResult {
116
116
  selectedPage?: string;
117
117
  name?: string;
118
118
  };
119
+ paginationBoundary?: {
120
+ kind?: string;
121
+ lastId?: string;
122
+ renderedCount?: number;
123
+ waitMs?: number;
124
+ scopeComplete?: boolean;
125
+ };
119
126
  sources?: PostEvidence[];
120
127
  post?: PostEvidence;
121
128
  context?: PostEvidence[];
@@ -198,6 +205,8 @@ export interface IncomingConversationResult {
198
205
  }
199
206
  export interface BookmarkOptions extends WindowOptions {
200
207
  folder?: string;
208
+ /** Continue the currently observed named-folder window without reselecting it. */
209
+ resume?: boolean;
201
210
  /** Discover names only; never read all bookmarks as a named folder. */
202
211
  discoverOnly?: boolean;
203
212
  }
@@ -103,6 +103,17 @@ export const readXObservation = (query) => {
103
103
  ?.getAttribute("href")
104
104
  ?.match(/\/status\/(\d+)/)?.[1] ?? null,
105
105
  renderedArticles: all.length,
106
+ renderedIds: all
107
+ .map((a) => primaryTime(a)
108
+ ?.closest("a")
109
+ ?.getAttribute("href")
110
+ ?.match(/\/status\/(\d+)/)?.[1])
111
+ .filter(Boolean),
112
+ atRenderedTail: Boolean(document.scrollingElement) &&
113
+ document.scrollingElement.scrollTop +
114
+ document.scrollingElement.clientHeight >=
115
+ document.scrollingElement.scrollHeight - 2,
116
+ loading: Array.from(document.querySelectorAll('[role="progressbar"]')).some(visible),
106
117
  matched: candidates.length,
107
118
  offset,
108
119
  moreInDOM: candidates.length > offset + posts.length,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nextclaw/x-harness",
3
- "version": "0.2.52",
3
+ "version": "0.2.54",
4
4
  "type": "module",
5
5
  "description": "Typed X task harness with compact business results and explicit diagnostics.",
6
6
  "main": "./dist/index.js",
@@ -42,5 +42,5 @@
42
42
  "publishConfig": {
43
43
  "access": "public"
44
44
  },
45
- "gitHead": "b3e3310d4977db067f05ee409f58ae619f042c9a"
45
+ "gitHead": "a9b2bb0d7399463a8dc259e433e3a2bd31dc9bc7"
46
46
  }
@@ -4,7 +4,7 @@
4
4
  | --- | --- | --- |
5
5
  | readIncomingReplies | none | processedIds, limit (5), maxPages (3), mode |
6
6
  | readCreator | handle | target (10;1–50), maxScrolls, navigate, expand, mode |
7
- | readBookmarks | folder (observed name), or discoverOnly:true | processedIds, limit (1–5), maxPages (1–10), mode |
7
+ | readBookmarks | folder (observed name), or discoverOnly:true | processedIds, limit (1–5), maxPages (1–10), resume, mode |
8
8
  | searchSources | query | processedIds, limit (1–5), maxPages (1–10), mode |
9
9
  | readPost | target: {postId,url,evidenceRef} | includeContext, resolveQuote, mode |
10
10
  | readPerson | target: {handle,url,evidenceRef} | mode |
@@ -13,16 +13,18 @@
13
13
  | inspectAccount | none | mode |
14
14
  | inspectRound | taskId (original batch ID) | none |
15
15
 
16
- Targets need observed proof. Native creator: selected profile, navigate:false; relative time unknown; page changes stop.
16
+ Targets require proof. Native creator: selected profile, navigate:false; relative time unknown; stop on page change.
17
+
18
+ Bookmarks resume:true continues the bound folder; rendered tail ≠ full proof.
17
19
 
18
20
  observed ≠ field completeness; pageComplete:false = scope. View media before describing; preserve target text; keep other reads local.
19
21
 
20
22
  `readIncomingReplies`: ledger account; skip settled/intent/unknown and processedIds across dates. readPost(includeContext:true) for ancestors; missing context blocks review. Dedup publicly.
21
23
 
22
- Incoming work: use `readIncomingConversations({targets?,...window})` once for discovery + context (≤5); no preceding `readIncomingReplies`. It preflights required capabilities and preserves source failures. Returns ancestorPath, nodes, confirmed edges, unknown parents and attempts; rootCandidateId is only the earliest loaded ancestor. Missing context blocks review; never guess roots/edges or inbox clearance. Use `readIncomingReplies` only when the candidate list alone is the goal; it excludes settled/intent/unknown + cross-date processedIds.
24
+ Incoming: `readIncomingConversations({targets?,...window})` combines discovery/context (≤5); no preceding `readIncomingReplies`. Preflight/source failures remain visible. Returns ancestorPath, nodes, confirmed edges, unknown parents and attempts; rootCandidateId is only the earliest loaded ancestor. Missing context blocks review; never guess roots/edges or inbox clearance. Use `readIncomingReplies` only when the candidate list alone is the goal; it excludes settled/intent/unknown + cross-date processedIds.
23
25
 
24
26
  `resolveQuote:true` follows one observed card and returns `post.quoteSource` only after exact destination/body/author/time binding.
25
27
 
26
- `readPeople`: observed follower/following lists; offsets reuse cells. advance scrolls a unique fresh handle. people has full bio, blue/relationship. Dedup; unknowns need profiles. Native unsupported.
28
+ `readPeople`: observed follower/following; offset reuses cells; advance scrolls a fresh unique handle. Full bio/blue/relationship; dedup; unknowns need profiles. No Native.
27
29
 
28
- Discovery returns names only.
30
+ Discovery: names only.