@simonfestl/husky-cli 1.13.0 → 1.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/commands/task.js +12 -0
- package/dist/commands/worktree.js +58 -14
- package/dist/index.js +1 -0
- package/dist/lib/agent-lock.d.ts +87 -0
- package/dist/lib/agent-lock.js +332 -0
- package/dist/lib/biz/wattiz-playwright.js +205 -55
- package/package.json +1 -1
package/dist/commands/task.js
CHANGED
|
@@ -9,6 +9,7 @@ import { execSync } from "child_process";
|
|
|
9
9
|
import { resolveProject, fetchProjects, formatProjectList } from "../lib/project-resolver.js";
|
|
10
10
|
import { requirePermission } from "../lib/permissions.js";
|
|
11
11
|
import { ErrorHelpers, errorWithHint, ExplainTopic } from "../lib/error-hints.js";
|
|
12
|
+
import { AgentLock, AgentLockError } from "../lib/agent-lock.js";
|
|
12
13
|
export const taskCommand = new Command("task")
|
|
13
14
|
.description("Manage tasks");
|
|
14
15
|
// Helper: Get task ID from --id flag or HUSKY_TASK_ID env var
|
|
@@ -245,6 +246,17 @@ taskCommand
|
|
|
245
246
|
if (worktreeInfo) {
|
|
246
247
|
console.log(`✓ Created worktree: ${worktreeInfo.path}`);
|
|
247
248
|
console.log(` Branch: ${worktreeInfo.branch}`);
|
|
249
|
+
// Acquire agent lock for the worktree
|
|
250
|
+
try {
|
|
251
|
+
const agentLock = new AgentLock(process.cwd(), worktreeInfo.path, workerId, sessionId);
|
|
252
|
+
agentLock.acquire();
|
|
253
|
+
console.log(`✓ Acquired agent lock`);
|
|
254
|
+
}
|
|
255
|
+
catch (error) {
|
|
256
|
+
if (error instanceof AgentLockError) {
|
|
257
|
+
console.warn(`⚠ Warning: ${error.message}`);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
248
260
|
}
|
|
249
261
|
}
|
|
250
262
|
const res = await fetch(`${config.apiUrl}/api/tasks/${id}/start`, {
|
|
@@ -2,6 +2,7 @@ import { Command } from "commander";
|
|
|
2
2
|
import * as path from "path";
|
|
3
3
|
import { WorktreeManager } from "../lib/worktree.js";
|
|
4
4
|
import { MergeLock, withMergeLock } from "../lib/merge-lock.js";
|
|
5
|
+
import { AgentLock } from "../lib/agent-lock.js";
|
|
5
6
|
import { getConfig } from "./config.js";
|
|
6
7
|
export const worktreeCommand = new Command("worktree")
|
|
7
8
|
.description("Manage Git worktrees for isolated agent workspaces");
|
|
@@ -87,13 +88,24 @@ worktreeCommand
|
|
|
87
88
|
.option("--json", "Output as JSON")
|
|
88
89
|
.action(async (options) => {
|
|
89
90
|
try {
|
|
91
|
+
const projectDir = getProjectDir(options);
|
|
90
92
|
const manager = getManager(options);
|
|
91
93
|
const worktrees = manager.listWorktrees();
|
|
94
|
+
const agentLocks = AgentLock.listLocks(projectDir);
|
|
95
|
+
// Build a map of worktree path to lock info
|
|
96
|
+
const locksByPath = new Map();
|
|
97
|
+
for (const lock of agentLocks) {
|
|
98
|
+
locksByPath.set(lock.worktree, { info: lock.info, isStale: lock.isStale });
|
|
99
|
+
}
|
|
92
100
|
if (options.json) {
|
|
93
|
-
|
|
101
|
+
const worktreesWithLocks = worktrees.map(wt => ({
|
|
102
|
+
...wt,
|
|
103
|
+
agentLock: locksByPath.get(wt.path) || null,
|
|
104
|
+
}));
|
|
105
|
+
console.log(JSON.stringify({ worktrees: worktreesWithLocks, baseBranch: manager.getBaseBranch(), agentLocks }, null, 2));
|
|
94
106
|
}
|
|
95
107
|
else {
|
|
96
|
-
printWorktreeList(worktrees, manager.getBaseBranch());
|
|
108
|
+
printWorktreeList(worktrees, manager.getBaseBranch(), locksByPath);
|
|
97
109
|
}
|
|
98
110
|
}
|
|
99
111
|
catch (error) {
|
|
@@ -109,6 +121,7 @@ worktreeCommand
|
|
|
109
121
|
.option("--json", "Output as JSON")
|
|
110
122
|
.action(async (sessionName, options) => {
|
|
111
123
|
try {
|
|
124
|
+
const projectDir = getProjectDir(options);
|
|
112
125
|
const manager = getManager(options);
|
|
113
126
|
const info = manager.getWorktree(sessionName);
|
|
114
127
|
if (!info) {
|
|
@@ -117,11 +130,14 @@ worktreeCommand
|
|
|
117
130
|
}
|
|
118
131
|
const changedFiles = manager.getChangedFiles(sessionName);
|
|
119
132
|
const hasUncommitted = manager.hasUncommittedChanges(sessionName);
|
|
133
|
+
// Check for agent lock
|
|
134
|
+
const agentLocks = AgentLock.listLocks(projectDir);
|
|
135
|
+
const agentLock = agentLocks.find(l => l.worktree === info.path) || null;
|
|
120
136
|
if (options.json) {
|
|
121
|
-
console.log(JSON.stringify({ ...info, changedFiles, hasUncommittedChanges: hasUncommitted }, null, 2));
|
|
137
|
+
console.log(JSON.stringify({ ...info, changedFiles, hasUncommittedChanges: hasUncommitted, agentLock }, null, 2));
|
|
122
138
|
}
|
|
123
139
|
else {
|
|
124
|
-
printWorktreeDetail(info, changedFiles, hasUncommitted);
|
|
140
|
+
printWorktreeDetail(info, changedFiles, hasUncommitted, agentLock);
|
|
125
141
|
}
|
|
126
142
|
}
|
|
127
143
|
catch (error) {
|
|
@@ -316,14 +332,18 @@ worktreeCommand
|
|
|
316
332
|
else {
|
|
317
333
|
// Just cleanup stale
|
|
318
334
|
manager.cleanupStale();
|
|
319
|
-
const
|
|
335
|
+
const staleMergeLocks = MergeLock.cleanupStale(projectDir);
|
|
336
|
+
const staleAgentLocks = AgentLock.cleanupStale(projectDir);
|
|
320
337
|
if (options.json) {
|
|
321
|
-
console.log(JSON.stringify({
|
|
338
|
+
console.log(JSON.stringify({ staleMergeLocksRemoved: staleMergeLocks, staleAgentLocksRemoved: staleAgentLocks, type: "stale" }, null, 2));
|
|
322
339
|
}
|
|
323
340
|
else {
|
|
324
341
|
console.log("Cleaned up stale worktrees and locks");
|
|
325
|
-
if (
|
|
326
|
-
console.log(` Removed ${
|
|
342
|
+
if (staleMergeLocks > 0) {
|
|
343
|
+
console.log(` Removed ${staleMergeLocks} stale merge lock(s)`);
|
|
344
|
+
}
|
|
345
|
+
if (staleAgentLocks > 0) {
|
|
346
|
+
console.log(` Removed ${staleAgentLocks} stale agent lock(s)`);
|
|
327
347
|
}
|
|
328
348
|
}
|
|
329
349
|
}
|
|
@@ -376,24 +396,34 @@ worktreeCommand
|
|
|
376
396
|
}
|
|
377
397
|
});
|
|
378
398
|
// Print helpers
|
|
379
|
-
function printWorktreeList(worktrees, baseBranch) {
|
|
399
|
+
function printWorktreeList(worktrees, baseBranch, locksByPath) {
|
|
380
400
|
console.log(`\n Base branch: ${baseBranch}`);
|
|
381
|
-
console.log(" " + "-".repeat(
|
|
401
|
+
console.log(" " + "-".repeat(90));
|
|
382
402
|
if (worktrees.length === 0) {
|
|
383
403
|
console.log(" No worktrees found.");
|
|
384
404
|
console.log(" Create one with: husky worktree create <session-name>");
|
|
385
405
|
}
|
|
386
406
|
else {
|
|
387
|
-
console.log(` ${"SESSION".padEnd(20)} ${"BRANCH".padEnd(25)} ${"COMMITS".padEnd(8)} ${"CHANGES"}`);
|
|
388
|
-
console.log(" " + "-".repeat(
|
|
407
|
+
console.log(` ${"SESSION".padEnd(20)} ${"BRANCH".padEnd(25)} ${"COMMITS".padEnd(8)} ${"CHANGES".padEnd(20)} ${"AGENT"}`);
|
|
408
|
+
console.log(" " + "-".repeat(90));
|
|
389
409
|
for (const wt of worktrees) {
|
|
390
410
|
const changes = `+${wt.stats.additions}/-${wt.stats.deletions} (${wt.stats.filesChanged} files)`;
|
|
391
|
-
|
|
411
|
+
const lock = locksByPath.get(wt.path);
|
|
412
|
+
let agentInfo = "";
|
|
413
|
+
if (lock) {
|
|
414
|
+
if (lock.isStale) {
|
|
415
|
+
agentInfo = `[stale] ${lock.info.workerId}`;
|
|
416
|
+
}
|
|
417
|
+
else {
|
|
418
|
+
agentInfo = `${lock.info.workerId} (PID: ${lock.info.pid})`;
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
console.log(` ${wt.sessionName.padEnd(20)} ${wt.branch.padEnd(25)} ${String(wt.stats.commitCount).padEnd(8)} ${changes.padEnd(20)} ${agentInfo}`);
|
|
392
422
|
}
|
|
393
423
|
}
|
|
394
424
|
console.log("");
|
|
395
425
|
}
|
|
396
|
-
function printWorktreeDetail(info, changedFiles, hasUncommitted) {
|
|
426
|
+
function printWorktreeDetail(info, changedFiles, hasUncommitted, agentLock) {
|
|
397
427
|
console.log(`\n Worktree: ${info.sessionName}`);
|
|
398
428
|
console.log(" " + "-".repeat(60));
|
|
399
429
|
console.log(` Path: ${info.path}`);
|
|
@@ -405,6 +435,20 @@ function printWorktreeDetail(info, changedFiles, hasUncommitted) {
|
|
|
405
435
|
console.log(` Files: ${info.stats.filesChanged}`);
|
|
406
436
|
console.log(` Added: +${info.stats.additions}`);
|
|
407
437
|
console.log(` Removed: -${info.stats.deletions}`);
|
|
438
|
+
if (agentLock) {
|
|
439
|
+
console.log(`\n Agent Lock:`);
|
|
440
|
+
if (agentLock.isStale) {
|
|
441
|
+
console.log(` Status: STALE (process no longer running)`);
|
|
442
|
+
}
|
|
443
|
+
else {
|
|
444
|
+
console.log(` Status: ACTIVE`);
|
|
445
|
+
}
|
|
446
|
+
console.log(` Worker: ${agentLock.info.workerId}`);
|
|
447
|
+
console.log(` Session: ${agentLock.info.sessionId}`);
|
|
448
|
+
console.log(` PID: ${agentLock.info.pid}`);
|
|
449
|
+
console.log(` Host: ${agentLock.info.hostname}`);
|
|
450
|
+
console.log(` Since: ${new Date(agentLock.info.timestamp).toISOString()}`);
|
|
451
|
+
}
|
|
408
452
|
if (hasUncommitted) {
|
|
409
453
|
console.log(`\n ⚠ Has uncommitted changes`);
|
|
410
454
|
}
|
package/dist/index.js
CHANGED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent Lock Mechanism for Husky Worktrees
|
|
3
|
+
*
|
|
4
|
+
* Prevents multiple agents from working in the same worktree simultaneously.
|
|
5
|
+
* Tracks agent identity (workerId, sessionId) alongside PID for detection.
|
|
6
|
+
*/
|
|
7
|
+
export declare class AgentLockError extends Error {
|
|
8
|
+
lockInfo?: AgentLockInfo | undefined;
|
|
9
|
+
constructor(message: string, lockInfo?: AgentLockInfo | undefined);
|
|
10
|
+
}
|
|
11
|
+
export interface AgentLockInfo {
|
|
12
|
+
workerId: string;
|
|
13
|
+
sessionId: string;
|
|
14
|
+
pid: number;
|
|
15
|
+
timestamp: number;
|
|
16
|
+
hostname: string;
|
|
17
|
+
worktreePath: string;
|
|
18
|
+
cwd: string;
|
|
19
|
+
}
|
|
20
|
+
export declare class AgentLock {
|
|
21
|
+
private projectDir;
|
|
22
|
+
private worktreePath;
|
|
23
|
+
private lockFile;
|
|
24
|
+
private lockDir;
|
|
25
|
+
private isHeldByMe;
|
|
26
|
+
private workerId;
|
|
27
|
+
private sessionId;
|
|
28
|
+
static readonly STALE_TIMEOUT: number;
|
|
29
|
+
constructor(projectDir: string, worktreePath: string, workerId: string, sessionId: string);
|
|
30
|
+
/**
|
|
31
|
+
* Create a simple hash of a path for lock file naming.
|
|
32
|
+
*/
|
|
33
|
+
private hashPath;
|
|
34
|
+
/**
|
|
35
|
+
* Check if another agent is working in this worktree.
|
|
36
|
+
* Returns the lock info if locked by another agent, null otherwise.
|
|
37
|
+
*/
|
|
38
|
+
checkForConflict(): AgentLockInfo | null;
|
|
39
|
+
/**
|
|
40
|
+
* Acquire the agent lock for this worktree.
|
|
41
|
+
*/
|
|
42
|
+
acquire(): boolean;
|
|
43
|
+
/**
|
|
44
|
+
* Release the agent lock.
|
|
45
|
+
*/
|
|
46
|
+
release(): void;
|
|
47
|
+
/**
|
|
48
|
+
* Update the lock timestamp (heartbeat).
|
|
49
|
+
*/
|
|
50
|
+
heartbeat(): void;
|
|
51
|
+
/**
|
|
52
|
+
* Check if a process exists.
|
|
53
|
+
*/
|
|
54
|
+
private processExists;
|
|
55
|
+
/**
|
|
56
|
+
* Check if a lock is stale.
|
|
57
|
+
*/
|
|
58
|
+
private isStale;
|
|
59
|
+
/**
|
|
60
|
+
* Get the current lock info.
|
|
61
|
+
*/
|
|
62
|
+
getLockInfo(): AgentLockInfo | null;
|
|
63
|
+
/**
|
|
64
|
+
* Check if the lock file exists.
|
|
65
|
+
*/
|
|
66
|
+
isLocked(): boolean;
|
|
67
|
+
/**
|
|
68
|
+
* List all agent locks in a project.
|
|
69
|
+
*/
|
|
70
|
+
static listLocks(projectDir: string): Array<{
|
|
71
|
+
worktree: string;
|
|
72
|
+
info: AgentLockInfo;
|
|
73
|
+
isStale: boolean;
|
|
74
|
+
}>;
|
|
75
|
+
/**
|
|
76
|
+
* Clean up all stale agent locks.
|
|
77
|
+
*/
|
|
78
|
+
static cleanupStale(projectDir: string): number;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Helper to check if a worktree is in use by another agent.
|
|
82
|
+
*/
|
|
83
|
+
export declare function checkWorktreeConflict(projectDir: string, worktreePath: string, workerId: string, sessionId: string): AgentLockInfo | null;
|
|
84
|
+
/**
|
|
85
|
+
* Helper to acquire an agent lock and run a function.
|
|
86
|
+
*/
|
|
87
|
+
export declare function withAgentLock<T>(projectDir: string, worktreePath: string, workerId: string, sessionId: string, fn: () => Promise<T>): Promise<T>;
|
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent Lock Mechanism for Husky Worktrees
|
|
3
|
+
*
|
|
4
|
+
* Prevents multiple agents from working in the same worktree simultaneously.
|
|
5
|
+
* Tracks agent identity (workerId, sessionId) alongside PID for detection.
|
|
6
|
+
*/
|
|
7
|
+
import * as fs from "fs";
|
|
8
|
+
import * as path from "path";
|
|
9
|
+
import * as os from "os";
|
|
10
|
+
export class AgentLockError extends Error {
|
|
11
|
+
lockInfo;
|
|
12
|
+
constructor(message, lockInfo) {
|
|
13
|
+
super(message);
|
|
14
|
+
this.lockInfo = lockInfo;
|
|
15
|
+
this.name = "AgentLockError";
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
export class AgentLock {
|
|
19
|
+
projectDir;
|
|
20
|
+
worktreePath;
|
|
21
|
+
lockFile;
|
|
22
|
+
lockDir;
|
|
23
|
+
isHeldByMe = false;
|
|
24
|
+
workerId;
|
|
25
|
+
sessionId;
|
|
26
|
+
static STALE_TIMEOUT = 30 * 60 * 1000; // 30 minutes
|
|
27
|
+
constructor(projectDir, worktreePath, workerId, sessionId) {
|
|
28
|
+
this.projectDir = path.resolve(projectDir);
|
|
29
|
+
this.worktreePath = worktreePath;
|
|
30
|
+
this.workerId = workerId;
|
|
31
|
+
this.sessionId = sessionId;
|
|
32
|
+
this.lockDir = path.join(this.projectDir, ".husky", ".locks");
|
|
33
|
+
// Use worktree path hash to create unique lock file name
|
|
34
|
+
const worktreeHash = this.hashPath(worktreePath);
|
|
35
|
+
this.lockFile = path.join(this.lockDir, `agent-${worktreeHash}.lock`);
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Create a simple hash of a path for lock file naming.
|
|
39
|
+
*/
|
|
40
|
+
hashPath(p) {
|
|
41
|
+
// Simple hash: use last two path segments
|
|
42
|
+
const parts = p.split(path.sep).filter(Boolean);
|
|
43
|
+
if (parts.length >= 2) {
|
|
44
|
+
return `${parts[parts.length - 2]}-${parts[parts.length - 1]}`;
|
|
45
|
+
}
|
|
46
|
+
return parts[parts.length - 1] || "default";
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Check if another agent is working in this worktree.
|
|
50
|
+
* Returns the lock info if locked by another agent, null otherwise.
|
|
51
|
+
*/
|
|
52
|
+
checkForConflict() {
|
|
53
|
+
if (!fs.existsSync(this.lockFile)) {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
try {
|
|
57
|
+
const content = fs.readFileSync(this.lockFile, "utf-8");
|
|
58
|
+
const lockInfo = JSON.parse(content);
|
|
59
|
+
// Same agent, same session - no conflict
|
|
60
|
+
if (lockInfo.workerId === this.workerId && lockInfo.sessionId === this.sessionId) {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
// Check if the locking process is still running
|
|
64
|
+
if (!this.processExists(lockInfo.pid)) {
|
|
65
|
+
// Process is dead, lock is stale
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
// Check if lock is too old
|
|
69
|
+
if (this.isStale(lockInfo)) {
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
// Another agent is actively working here
|
|
73
|
+
return lockInfo;
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
// Can't read lock file, assume no conflict
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Acquire the agent lock for this worktree.
|
|
82
|
+
*/
|
|
83
|
+
acquire() {
|
|
84
|
+
// First check for conflicts
|
|
85
|
+
const conflict = this.checkForConflict();
|
|
86
|
+
if (conflict) {
|
|
87
|
+
throw new AgentLockError(`Worktree is already in use by another agent!\n` +
|
|
88
|
+
` Worker: ${conflict.workerId}\n` +
|
|
89
|
+
` Session: ${conflict.sessionId}\n` +
|
|
90
|
+
` PID: ${conflict.pid}\n` +
|
|
91
|
+
` Since: ${new Date(conflict.timestamp).toISOString()}\n` +
|
|
92
|
+
` Host: ${conflict.hostname}`, conflict);
|
|
93
|
+
}
|
|
94
|
+
// Clean up stale lock if exists
|
|
95
|
+
if (fs.existsSync(this.lockFile)) {
|
|
96
|
+
try {
|
|
97
|
+
const content = fs.readFileSync(this.lockFile, "utf-8");
|
|
98
|
+
const lockInfo = JSON.parse(content);
|
|
99
|
+
if (!this.processExists(lockInfo.pid) || this.isStale(lockInfo)) {
|
|
100
|
+
fs.unlinkSync(this.lockFile);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
// Ignore errors, try to acquire anyway
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
// Create lock directory
|
|
108
|
+
fs.mkdirSync(this.lockDir, { recursive: true });
|
|
109
|
+
const lockInfo = {
|
|
110
|
+
workerId: this.workerId,
|
|
111
|
+
sessionId: this.sessionId,
|
|
112
|
+
pid: process.pid,
|
|
113
|
+
timestamp: Date.now(),
|
|
114
|
+
hostname: os.hostname(),
|
|
115
|
+
worktreePath: this.worktreePath,
|
|
116
|
+
cwd: process.cwd(),
|
|
117
|
+
};
|
|
118
|
+
try {
|
|
119
|
+
// Use exclusive flag for atomic creation
|
|
120
|
+
const fd = fs.openSync(this.lockFile, fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_WRONLY);
|
|
121
|
+
fs.writeSync(fd, JSON.stringify(lockInfo, null, 2));
|
|
122
|
+
fs.closeSync(fd);
|
|
123
|
+
this.isHeldByMe = true;
|
|
124
|
+
return true;
|
|
125
|
+
}
|
|
126
|
+
catch (error) {
|
|
127
|
+
if (error.code === "EEXIST") {
|
|
128
|
+
// File was created between our check and acquire attempt
|
|
129
|
+
// Re-check for conflict
|
|
130
|
+
const newConflict = this.checkForConflict();
|
|
131
|
+
if (newConflict) {
|
|
132
|
+
throw new AgentLockError(`Worktree was just locked by another agent!\n` +
|
|
133
|
+
` Worker: ${newConflict.workerId}\n` +
|
|
134
|
+
` Session: ${newConflict.sessionId}`, newConflict);
|
|
135
|
+
}
|
|
136
|
+
// Stale lock, try to remove and retry
|
|
137
|
+
try {
|
|
138
|
+
fs.unlinkSync(this.lockFile);
|
|
139
|
+
return this.acquire();
|
|
140
|
+
}
|
|
141
|
+
catch {
|
|
142
|
+
return false;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
throw error;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* Release the agent lock.
|
|
150
|
+
*/
|
|
151
|
+
release() {
|
|
152
|
+
if (!this.isHeldByMe) {
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
try {
|
|
156
|
+
fs.unlinkSync(this.lockFile);
|
|
157
|
+
}
|
|
158
|
+
catch {
|
|
159
|
+
// Ignore errors during cleanup
|
|
160
|
+
}
|
|
161
|
+
this.isHeldByMe = false;
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* Update the lock timestamp (heartbeat).
|
|
165
|
+
*/
|
|
166
|
+
heartbeat() {
|
|
167
|
+
if (!this.isHeldByMe || !fs.existsSync(this.lockFile)) {
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
try {
|
|
171
|
+
const content = fs.readFileSync(this.lockFile, "utf-8");
|
|
172
|
+
const lockInfo = JSON.parse(content);
|
|
173
|
+
lockInfo.timestamp = Date.now();
|
|
174
|
+
fs.writeFileSync(this.lockFile, JSON.stringify(lockInfo, null, 2));
|
|
175
|
+
}
|
|
176
|
+
catch {
|
|
177
|
+
// Ignore heartbeat errors
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* Check if a process exists.
|
|
182
|
+
*/
|
|
183
|
+
processExists(pid) {
|
|
184
|
+
try {
|
|
185
|
+
process.kill(pid, 0);
|
|
186
|
+
return true;
|
|
187
|
+
}
|
|
188
|
+
catch {
|
|
189
|
+
return false;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* Check if a lock is stale.
|
|
194
|
+
*/
|
|
195
|
+
isStale(lockInfo) {
|
|
196
|
+
const age = Date.now() - lockInfo.timestamp;
|
|
197
|
+
return age > AgentLock.STALE_TIMEOUT;
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* Get the current lock info.
|
|
201
|
+
*/
|
|
202
|
+
getLockInfo() {
|
|
203
|
+
if (!fs.existsSync(this.lockFile)) {
|
|
204
|
+
return null;
|
|
205
|
+
}
|
|
206
|
+
try {
|
|
207
|
+
const content = fs.readFileSync(this.lockFile, "utf-8");
|
|
208
|
+
return JSON.parse(content);
|
|
209
|
+
}
|
|
210
|
+
catch {
|
|
211
|
+
return null;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* Check if the lock file exists.
|
|
216
|
+
*/
|
|
217
|
+
isLocked() {
|
|
218
|
+
return fs.existsSync(this.lockFile);
|
|
219
|
+
}
|
|
220
|
+
/**
|
|
221
|
+
* List all agent locks in a project.
|
|
222
|
+
*/
|
|
223
|
+
static listLocks(projectDir) {
|
|
224
|
+
const lockDir = path.join(projectDir, ".husky", ".locks");
|
|
225
|
+
if (!fs.existsSync(lockDir)) {
|
|
226
|
+
return [];
|
|
227
|
+
}
|
|
228
|
+
const locks = [];
|
|
229
|
+
const entries = fs.readdirSync(lockDir);
|
|
230
|
+
for (const entry of entries) {
|
|
231
|
+
if (!entry.startsWith("agent-") || !entry.endsWith(".lock")) {
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
const lockFile = path.join(lockDir, entry);
|
|
235
|
+
try {
|
|
236
|
+
const content = fs.readFileSync(lockFile, "utf-8");
|
|
237
|
+
const info = JSON.parse(content);
|
|
238
|
+
// Check if stale
|
|
239
|
+
let isStale = false;
|
|
240
|
+
try {
|
|
241
|
+
process.kill(info.pid, 0);
|
|
242
|
+
// Process exists, check age
|
|
243
|
+
isStale = Date.now() - info.timestamp > AgentLock.STALE_TIMEOUT;
|
|
244
|
+
}
|
|
245
|
+
catch {
|
|
246
|
+
// Process doesn't exist
|
|
247
|
+
isStale = true;
|
|
248
|
+
}
|
|
249
|
+
locks.push({
|
|
250
|
+
worktree: info.worktreePath,
|
|
251
|
+
info,
|
|
252
|
+
isStale,
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
catch {
|
|
256
|
+
// Skip invalid lock files
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
return locks;
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* Clean up all stale agent locks.
|
|
263
|
+
*/
|
|
264
|
+
static cleanupStale(projectDir) {
|
|
265
|
+
const lockDir = path.join(projectDir, ".husky", ".locks");
|
|
266
|
+
if (!fs.existsSync(lockDir)) {
|
|
267
|
+
return 0;
|
|
268
|
+
}
|
|
269
|
+
let cleaned = 0;
|
|
270
|
+
const entries = fs.readdirSync(lockDir);
|
|
271
|
+
for (const entry of entries) {
|
|
272
|
+
if (!entry.startsWith("agent-") || !entry.endsWith(".lock")) {
|
|
273
|
+
continue;
|
|
274
|
+
}
|
|
275
|
+
const lockFile = path.join(lockDir, entry);
|
|
276
|
+
try {
|
|
277
|
+
const content = fs.readFileSync(lockFile, "utf-8");
|
|
278
|
+
const info = JSON.parse(content);
|
|
279
|
+
let isStale = false;
|
|
280
|
+
try {
|
|
281
|
+
process.kill(info.pid, 0);
|
|
282
|
+
isStale = Date.now() - info.timestamp > AgentLock.STALE_TIMEOUT;
|
|
283
|
+
}
|
|
284
|
+
catch {
|
|
285
|
+
isStale = true;
|
|
286
|
+
}
|
|
287
|
+
if (isStale) {
|
|
288
|
+
fs.unlinkSync(lockFile);
|
|
289
|
+
cleaned++;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
catch {
|
|
293
|
+
// Try to remove invalid lock files
|
|
294
|
+
try {
|
|
295
|
+
fs.unlinkSync(lockFile);
|
|
296
|
+
cleaned++;
|
|
297
|
+
}
|
|
298
|
+
catch {
|
|
299
|
+
// Ignore
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
return cleaned;
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
/**
|
|
307
|
+
* Helper to check if a worktree is in use by another agent.
|
|
308
|
+
*/
|
|
309
|
+
export function checkWorktreeConflict(projectDir, worktreePath, workerId, sessionId) {
|
|
310
|
+
const lock = new AgentLock(projectDir, worktreePath, workerId, sessionId);
|
|
311
|
+
return lock.checkForConflict();
|
|
312
|
+
}
|
|
313
|
+
/**
|
|
314
|
+
* Helper to acquire an agent lock and run a function.
|
|
315
|
+
*/
|
|
316
|
+
export async function withAgentLock(projectDir, worktreePath, workerId, sessionId, fn) {
|
|
317
|
+
const lock = new AgentLock(projectDir, worktreePath, workerId, sessionId);
|
|
318
|
+
if (!lock.acquire()) {
|
|
319
|
+
throw new AgentLockError(`Could not acquire agent lock for ${worktreePath}`);
|
|
320
|
+
}
|
|
321
|
+
// Set up heartbeat interval
|
|
322
|
+
const heartbeatInterval = setInterval(() => {
|
|
323
|
+
lock.heartbeat();
|
|
324
|
+
}, 60000); // Every minute
|
|
325
|
+
try {
|
|
326
|
+
return await fn();
|
|
327
|
+
}
|
|
328
|
+
finally {
|
|
329
|
+
clearInterval(heartbeatInterval);
|
|
330
|
+
lock.release();
|
|
331
|
+
}
|
|
332
|
+
}
|
|
@@ -115,7 +115,6 @@ export class WattizPlaywrightClient {
|
|
|
115
115
|
if (!passwordInput) {
|
|
116
116
|
throw new Error('Could not find password input field');
|
|
117
117
|
}
|
|
118
|
-
console.log(`Using selectors: email="${emailInput}", password="${passwordInput}"`);
|
|
119
118
|
// Fill in login form
|
|
120
119
|
await page.fill(emailInput, this.config.username);
|
|
121
120
|
await page.fill(passwordInput, this.config.password);
|
|
@@ -142,24 +141,13 @@ export class WattizPlaywrightClient {
|
|
|
142
141
|
await page.waitForLoadState('networkidle', { timeout: 15000 }).catch(() => { });
|
|
143
142
|
// Check if logged in by looking for my-account page or logged-in indicators
|
|
144
143
|
const currentUrl = page.url();
|
|
145
|
-
console.log('After login, current URL:', currentUrl);
|
|
146
|
-
// Check for error messages first
|
|
147
|
-
const errorMsg = await page.locator('.alert-danger, .error-message, .ps-alert-error').textContent().catch(() => '');
|
|
148
|
-
if (errorMsg) {
|
|
149
|
-
console.log('Error message on page:', errorMsg.trim());
|
|
150
|
-
}
|
|
151
144
|
const isLoggedIn = currentUrl.includes('/my-account') ||
|
|
152
145
|
currentUrl.includes('/mon-compte') ||
|
|
153
146
|
await page.locator('.account-link').count() > 0 ||
|
|
154
147
|
await page.locator('[data-link-action="sign-out"]').count() > 0 ||
|
|
155
148
|
await page.locator('.logout, .sign-out').count() > 0 ||
|
|
156
149
|
await page.locator('a[href*="logout"]').count() > 0;
|
|
157
|
-
console.log('Is logged in:', isLoggedIn);
|
|
158
150
|
if (!isLoggedIn) {
|
|
159
|
-
// Save debug HTML
|
|
160
|
-
const debugHtml = await page.content();
|
|
161
|
-
await fs.promises.writeFile('/tmp/wattiz-after-login.html', debugHtml);
|
|
162
|
-
console.log('Debug HTML saved to /tmp/wattiz-after-login.html');
|
|
163
151
|
return {
|
|
164
152
|
success: false,
|
|
165
153
|
cookies: '',
|
|
@@ -200,16 +188,38 @@ export class WattizPlaywrightClient {
|
|
|
200
188
|
const count = await orderRows.count();
|
|
201
189
|
for (let i = 0; i < count; i++) {
|
|
202
190
|
const row = orderRows.nth(i);
|
|
203
|
-
// PrestaShop order structure
|
|
191
|
+
// PrestaShop order structure - get link for order ID
|
|
204
192
|
const orderLinkEl = row.locator('a[href*="order-detail"]').first();
|
|
205
193
|
const orderLink = await orderLinkEl.getAttribute('href').catch(() => '');
|
|
206
194
|
// Extract order ID from PrestaShop controller URL
|
|
207
195
|
const orderIdMatch = orderLink?.match(/id_order=(\d+)/);
|
|
208
196
|
const orderId = orderIdMatch ? orderIdMatch[1] : '';
|
|
209
|
-
|
|
210
|
-
const
|
|
211
|
-
const
|
|
212
|
-
|
|
197
|
+
// Get all table cells
|
|
198
|
+
const cells = row.locator('td');
|
|
199
|
+
const cellCount = await cells.count();
|
|
200
|
+
// Wattiz order-history table structure (customized PrestaShop):
|
|
201
|
+
// Col 0: Date, Col 1: Total, Col 2: ?, Col 3: ?, Col 4: Status, Col 5: Actions
|
|
202
|
+
// Order reference is in the link or we use order ID
|
|
203
|
+
let orderNumber = orderId;
|
|
204
|
+
let date = '';
|
|
205
|
+
let total = '';
|
|
206
|
+
let status = '';
|
|
207
|
+
if (cellCount >= 2) {
|
|
208
|
+
// First cell contains Date
|
|
209
|
+
date = (await cells.nth(0).textContent().catch(() => '')) || '';
|
|
210
|
+
// Second cell contains Total
|
|
211
|
+
total = (await cells.nth(1).textContent().catch(() => '')) || '';
|
|
212
|
+
}
|
|
213
|
+
if (cellCount >= 5) {
|
|
214
|
+
// Status is in column 5 (index 4)
|
|
215
|
+
status = (await cells.nth(4).textContent().catch(() => '')) || '';
|
|
216
|
+
}
|
|
217
|
+
// Try to find actual order reference in row text
|
|
218
|
+
const rowText = await row.textContent().catch(() => '') || '';
|
|
219
|
+
const refMatch = rowText.match(/WATTIZ[A-Z0-9]+|[A-Z]{2,}[0-9]{6,}/i);
|
|
220
|
+
if (refMatch) {
|
|
221
|
+
orderNumber = refMatch[0];
|
|
222
|
+
}
|
|
213
223
|
if (orderId) {
|
|
214
224
|
orders.push({
|
|
215
225
|
id: orderId,
|
|
@@ -226,62 +236,202 @@ export class WattizPlaywrightClient {
|
|
|
226
236
|
async getOrder(orderId) {
|
|
227
237
|
const page = await this.ensureBrowser();
|
|
228
238
|
// PrestaShop controller-based URL
|
|
229
|
-
await page.goto(`${this.config.baseUrl}/${this.config.language}/index.php?controller=order-detail&id_order=${orderId}
|
|
230
|
-
|
|
239
|
+
await page.goto(`${this.config.baseUrl}/${this.config.language}/index.php?controller=order-detail&id_order=${orderId}`, {
|
|
240
|
+
waitUntil: 'domcontentloaded',
|
|
241
|
+
timeout: 30000
|
|
242
|
+
});
|
|
243
|
+
await page.waitForLoadState('domcontentloaded').catch(() => { });
|
|
231
244
|
// Check if we need to login
|
|
232
|
-
const needsLogin =
|
|
245
|
+
const needsLogin = page.url().includes('/login');
|
|
233
246
|
if (needsLogin) {
|
|
234
247
|
await this.login();
|
|
235
|
-
await page.goto(`${this.config.baseUrl}/${this.config.language}/index.php?controller=order-detail&id_order=${orderId}
|
|
236
|
-
|
|
248
|
+
await page.goto(`${this.config.baseUrl}/${this.config.language}/index.php?controller=order-detail&id_order=${orderId}`, {
|
|
249
|
+
waitUntil: 'domcontentloaded',
|
|
250
|
+
timeout: 30000
|
|
251
|
+
});
|
|
252
|
+
await page.waitForLoadState('domcontentloaded').catch(() => { });
|
|
237
253
|
}
|
|
238
|
-
// Extract order information
|
|
239
|
-
|
|
240
|
-
const
|
|
241
|
-
|
|
242
|
-
|
|
254
|
+
// Extract order information from Wattiz order detail page
|
|
255
|
+
// Use order ID as the order number (Wattiz doesn't display a separate reference)
|
|
256
|
+
const orderNumber = orderId;
|
|
257
|
+
let orderDate = '';
|
|
258
|
+
let orderStatus = '';
|
|
259
|
+
// Get visible page text for pattern matching
|
|
260
|
+
const pageText = await page.locator('body').textContent().catch(() => '') || '';
|
|
261
|
+
// Find date - look for format DD/MM/YYYY or YYYY-MM-DD
|
|
262
|
+
const dateMatches = pageText.match(/\b(\d{2}[\/\-]\d{2}[\/\-]20\d{2}|20\d{2}[\/\-]\d{2}[\/\-]\d{2})\b/g);
|
|
263
|
+
if (dateMatches && dateMatches.length > 0 && dateMatches[0]) {
|
|
264
|
+
orderDate = dateMatches[0];
|
|
265
|
+
}
|
|
266
|
+
// Get status - look for common shipping status text
|
|
267
|
+
const statusPatterns = ['Shipped', 'Delivered', 'Processing', 'Pending', 'Cancelled', 'En cours', 'Expédié', 'Livré'];
|
|
268
|
+
for (const pattern of statusPatterns) {
|
|
269
|
+
if (pageText.includes(pattern)) {
|
|
270
|
+
orderStatus = pattern;
|
|
271
|
+
break;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
// Also try status elements if no pattern found
|
|
275
|
+
if (!orderStatus) {
|
|
276
|
+
const statusSelectors = ['.label.bright', '.badge', '.order-status'];
|
|
277
|
+
for (const selector of statusSelectors) {
|
|
278
|
+
const statusText = await page.locator(selector).first().textContent().catch(() => '');
|
|
279
|
+
if (statusText && statusText.length > 2 && statusText.length < 25 && !statusText.includes('€')) {
|
|
280
|
+
orderStatus = statusText.trim();
|
|
281
|
+
break;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
// Extract customer info from address blocks
|
|
243
286
|
let customerName = '';
|
|
244
287
|
let customerAddress = '';
|
|
245
288
|
let customerEmail = '';
|
|
246
289
|
let customerPhone = '';
|
|
247
290
|
try {
|
|
248
|
-
|
|
249
|
-
const
|
|
250
|
-
const
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
291
|
+
// PrestaShop uses .address class for address blocks
|
|
292
|
+
const addressBlocks = page.locator('.address, article.address');
|
|
293
|
+
const blockCount = await addressBlocks.count();
|
|
294
|
+
for (let i = 0; i < blockCount; i++) {
|
|
295
|
+
const block = addressBlocks.nth(i);
|
|
296
|
+
const addressText = await block.textContent() || '';
|
|
297
|
+
const lines = addressText.split('\n').map(l => l.trim()).filter(l => l && l.length > 1);
|
|
298
|
+
// First line is usually the name
|
|
299
|
+
if (lines.length > 0 && !customerName) {
|
|
300
|
+
customerName = lines[0];
|
|
301
|
+
}
|
|
302
|
+
// Combine address lines (skip name, email, phone)
|
|
303
|
+
if (lines.length > 1 && !customerAddress) {
|
|
304
|
+
customerAddress = lines.slice(1)
|
|
305
|
+
.filter(l => !l.includes('@') && !l.match(/^\+?\d[\d\s\-]+$/))
|
|
306
|
+
.join(', ');
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
// Try to find customer email (exclude wattiz domain)
|
|
310
|
+
const mailLinks = await page.locator('[href^="mailto:"]').all();
|
|
311
|
+
for (const link of mailLinks) {
|
|
312
|
+
const href = await link.getAttribute('href').catch(() => '') || '';
|
|
313
|
+
const email = href.replace('mailto:', '');
|
|
314
|
+
if (email && !email.includes('wattiz')) {
|
|
315
|
+
customerEmail = email;
|
|
316
|
+
break;
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
// Fallback: look for email pattern in address blocks (exclude wattiz)
|
|
320
|
+
if (!customerEmail) {
|
|
321
|
+
for (let i = 0; i < blockCount; i++) {
|
|
322
|
+
const blockText = await addressBlocks.nth(i).textContent().catch(() => '') || '';
|
|
323
|
+
const emailMatch = blockText.match(/[\w.+-]+@[\w.-]+\.\w+/);
|
|
324
|
+
if (emailMatch && !emailMatch[0].includes('wattiz')) {
|
|
325
|
+
customerEmail = emailMatch[0];
|
|
326
|
+
break;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
}
|
|
257
330
|
customerPhone = await page.locator('[href^="tel:"]').first().textContent().catch(() => '') || '';
|
|
258
331
|
}
|
|
259
332
|
catch (error) {
|
|
260
333
|
// Customer details not available
|
|
261
334
|
}
|
|
262
|
-
// Extract line items
|
|
335
|
+
// Extract line items from product table
|
|
336
|
+
// Look for the products section specifically
|
|
263
337
|
const items = [];
|
|
264
|
-
|
|
265
|
-
const
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
338
|
+
// Try different selectors for the product table
|
|
339
|
+
const productSelectors = [
|
|
340
|
+
'#order-products table tbody tr',
|
|
341
|
+
'.order-products tbody tr',
|
|
342
|
+
'[id*="product"] table tbody tr',
|
|
343
|
+
'table.table-striped tbody tr'
|
|
344
|
+
];
|
|
345
|
+
let productRows = null;
|
|
346
|
+
for (const selector of productSelectors) {
|
|
347
|
+
const rows = page.locator(selector);
|
|
348
|
+
const count = await rows.count();
|
|
349
|
+
if (count > 0) {
|
|
350
|
+
// Check if first row looks like a product (not a date or header)
|
|
351
|
+
const firstRowText = await rows.first().textContent().catch(() => '') || '';
|
|
352
|
+
if (!firstRowText.match(/^\d{4}[\/\-]\d{2}/) && firstRowText.length > 10) {
|
|
353
|
+
productRows = rows;
|
|
354
|
+
break;
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
if (productRows) {
|
|
359
|
+
const itemCount = await productRows.count();
|
|
360
|
+
for (let i = 0; i < itemCount; i++) {
|
|
361
|
+
const itemRow = productRows.nth(i);
|
|
362
|
+
const rowText = await itemRow.textContent() || '';
|
|
363
|
+
// Skip rows that look like dates or headers
|
|
364
|
+
if (rowText.match(/^\s*\d{4}[\/\-]\d{2}/) || rowText.includes('Product') || rowText.trim().length < 5) {
|
|
365
|
+
continue;
|
|
366
|
+
}
|
|
367
|
+
const cells = itemRow.locator('td');
|
|
368
|
+
const cellCount = await cells.count();
|
|
369
|
+
if (cellCount >= 2) {
|
|
370
|
+
const nameCell = await cells.nth(0).textContent() || '';
|
|
371
|
+
const name = nameCell.replace(/\s+/g, ' ').trim();
|
|
372
|
+
let qty = '1';
|
|
373
|
+
let total = '';
|
|
374
|
+
// Find quantity and price cells
|
|
375
|
+
for (let c = 1; c < cellCount; c++) {
|
|
376
|
+
const cellText = (await cells.nth(c).textContent() || '').trim();
|
|
377
|
+
if (cellText.match(/^\d+$/) && parseInt(cellText, 10) < 1000) {
|
|
378
|
+
qty = cellText;
|
|
379
|
+
}
|
|
380
|
+
else if (cellText.includes('€') || cellText.includes('$')) {
|
|
381
|
+
total = cellText;
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
// Only add if name looks like a product
|
|
385
|
+
if (name && name.length > 5 && !name.match(/^\d{4}[\/\-]/)) {
|
|
386
|
+
items.push({
|
|
387
|
+
sku: '',
|
|
388
|
+
name: name,
|
|
389
|
+
quantity: parseInt(qty, 10) || 1,
|
|
390
|
+
price: '',
|
|
391
|
+
total: total,
|
|
392
|
+
});
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
}
|
|
278
396
|
}
|
|
279
397
|
// Look for invoice link
|
|
280
|
-
const invoiceLink = await page.locator('a[href*="invoice"], a
|
|
281
|
-
// Extract total
|
|
282
|
-
|
|
398
|
+
const invoiceLink = await page.locator('a[href*="pdf-invoice"], a[href*="get-invoice"], a[href*="invoice"]').first().getAttribute('href').catch(() => '');
|
|
399
|
+
// Extract total - look for price patterns in page
|
|
400
|
+
let totalText = '';
|
|
401
|
+
// Look for total amount in page text
|
|
402
|
+
const priceMatches = pageText.match(/€\s*[\d,]+\.?\d*/g) || [];
|
|
403
|
+
if (priceMatches.length > 0) {
|
|
404
|
+
// Get the largest price as total (usually the order total)
|
|
405
|
+
let maxPrice = 0;
|
|
406
|
+
let maxPriceText = '';
|
|
407
|
+
for (const priceText of priceMatches) {
|
|
408
|
+
const value = parseFloat(priceText.replace('€', '').replace(/\s/g, '').replace(',', '.'));
|
|
409
|
+
if (value > maxPrice) {
|
|
410
|
+
maxPrice = value;
|
|
411
|
+
maxPriceText = priceText.trim();
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
if (maxPriceText) {
|
|
415
|
+
totalText = maxPriceText;
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
// Fallback: try specific selectors
|
|
419
|
+
if (!totalText) {
|
|
420
|
+
const totalSelectors = [
|
|
421
|
+
'.order-totals tr:last-child td:last-child',
|
|
422
|
+
'.total-value',
|
|
423
|
+
'table tfoot td:last-child'
|
|
424
|
+
];
|
|
425
|
+
for (const selector of totalSelectors) {
|
|
426
|
+
const text = await page.locator(selector).last().textContent().catch(() => '');
|
|
427
|
+
if (text && text.includes('€')) {
|
|
428
|
+
totalText = text.trim();
|
|
429
|
+
break;
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
}
|
|
283
433
|
// Extract tracking number if available
|
|
284
|
-
const trackingNumber = await page.locator('.tracking-number, [href*="track"]').first().textContent().catch(() => '');
|
|
434
|
+
const trackingNumber = await page.locator('.tracking-number, [href*="track"], [data-tracking]').first().textContent().catch(() => '');
|
|
285
435
|
return {
|
|
286
436
|
id: orderId,
|
|
287
437
|
orderNumber: orderNumber?.trim() || orderId,
|