@ai-digital-factory/lmt-web-cli 0.1.3 → 0.1.5
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/README.md +139 -23
- package/dist/chunk-G5T7UDIK.js +3094 -0
- package/dist/index.js +58 -2567
- package/dist/interactive-main-ETL5PV4H.js +308 -0
- package/package.json +5 -3
|
@@ -0,0 +1,3094 @@
|
|
|
1
|
+
// ../cli/src/profile-store.ts
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import {
|
|
4
|
+
chmod,
|
|
5
|
+
constants,
|
|
6
|
+
lstat,
|
|
7
|
+
mkdir,
|
|
8
|
+
open,
|
|
9
|
+
rename,
|
|
10
|
+
unlink
|
|
11
|
+
} from "node:fs/promises";
|
|
12
|
+
import path from "node:path";
|
|
13
|
+
|
|
14
|
+
// ../cli/src/profile-name.ts
|
|
15
|
+
var CLIENT_PROFILE_NAME_ERROR = "Profile must be 1\u201364 letters, digits, dots, underscores, or hyphens, starting with a letter or digit.";
|
|
16
|
+
var clientProfilePattern = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
|
|
17
|
+
function assertClientProfileName(profile) {
|
|
18
|
+
if (!clientProfilePattern.test(profile)) throw new Error(CLIENT_PROFILE_NAME_ERROR);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// ../cli/src/profile-store.ts
|
|
22
|
+
var FILE_NAME = "secrets.pem";
|
|
23
|
+
var OWNER_ONLY_DIRECTORY = 448;
|
|
24
|
+
var OWNER_ONLY_FILE = 384;
|
|
25
|
+
var LOCK_WAIT_MS = 5e3;
|
|
26
|
+
var LOCK_RETRY_MS = 20;
|
|
27
|
+
function emptyRecord() {
|
|
28
|
+
return { version: 2, profiles: {} };
|
|
29
|
+
}
|
|
30
|
+
function targetsEqual(left, right) {
|
|
31
|
+
return left.environment === right.environment && left.origin === right.origin && left.operatorId === right.operatorId;
|
|
32
|
+
}
|
|
33
|
+
function assertCookie(value, name) {
|
|
34
|
+
if (value !== void 0 && !new RegExp(`^${name}=[A-Za-z0-9._~-]+$`).test(value)) {
|
|
35
|
+
throw new Error(`The ${name} cookie is not valid.`);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
function ownerId() {
|
|
39
|
+
return typeof process.getuid === "function" ? process.getuid() : void 0;
|
|
40
|
+
}
|
|
41
|
+
async function ensureOwnerOnly(pathname, kind) {
|
|
42
|
+
let info;
|
|
43
|
+
try {
|
|
44
|
+
info = await lstat(pathname);
|
|
45
|
+
} catch (error) {
|
|
46
|
+
if (error.code === "ENOENT") throw error;
|
|
47
|
+
throw new Error("Could not inspect the protected CLI profile storage.");
|
|
48
|
+
}
|
|
49
|
+
if (info.isSymbolicLink() || (kind === "directory" ? !info.isDirectory() : !info.isFile())) {
|
|
50
|
+
throw new Error(`The protected CLI profile ${kind} must be a regular ${kind}.`);
|
|
51
|
+
}
|
|
52
|
+
const uid = ownerId();
|
|
53
|
+
if (uid !== void 0 && info.uid !== uid) {
|
|
54
|
+
throw new Error(`The protected CLI profile ${kind} must be owned by the current user.`);
|
|
55
|
+
}
|
|
56
|
+
const expectedMode = kind === "directory" ? OWNER_ONLY_DIRECTORY : OWNER_ONLY_FILE;
|
|
57
|
+
if ((info.mode & 511) === expectedMode) return;
|
|
58
|
+
try {
|
|
59
|
+
await chmod(pathname, expectedMode);
|
|
60
|
+
} catch {
|
|
61
|
+
throw new Error(`Could not set protected CLI profile ${kind} permissions.`);
|
|
62
|
+
}
|
|
63
|
+
const updated = await lstat(pathname);
|
|
64
|
+
if ((updated.mode & 511) !== expectedMode) {
|
|
65
|
+
const mode = expectedMode.toString(8).padStart(4, "0");
|
|
66
|
+
throw new Error(`The protected CLI profile ${kind} must have mode ${mode}.`);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
function parseLegacyTargetKey(key) {
|
|
70
|
+
try {
|
|
71
|
+
const parsed = JSON.parse(key);
|
|
72
|
+
if (!Array.isArray(parsed) || parsed.length !== 3) return void 0;
|
|
73
|
+
const [environment, origin, operatorId] = parsed;
|
|
74
|
+
if (environment !== "local" && environment !== "dev") return void 0;
|
|
75
|
+
if (typeof origin !== "string" || !origin.trim()) return void 0;
|
|
76
|
+
if (operatorId !== "OPERATOR_1") return void 0;
|
|
77
|
+
return { environment, origin: origin.trim(), operatorId };
|
|
78
|
+
} catch {
|
|
79
|
+
return void 0;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
function migrateLegacyRecord(record) {
|
|
83
|
+
const migrated = emptyRecord();
|
|
84
|
+
if (record.smsCredential?.trim()) migrated.smsCredential = record.smsCredential;
|
|
85
|
+
for (const [profileName, profileRecord] of Object.entries(record.profiles ?? {})) {
|
|
86
|
+
const sessions = [];
|
|
87
|
+
for (const [targetKey, stored] of Object.entries(profileRecord.targets ?? {})) {
|
|
88
|
+
const target = parseLegacyTargetKey(targetKey);
|
|
89
|
+
if (!target) continue;
|
|
90
|
+
sessions.push({
|
|
91
|
+
...target,
|
|
92
|
+
...stored.operatorSessionCookie ? { operatorSessionCookie: stored.operatorSessionCookie } : {},
|
|
93
|
+
...stored.clientIdentityCookie ? { clientIdentityCookie: stored.clientIdentityCookie } : {},
|
|
94
|
+
updatedAt: stored.updatedAt ?? (/* @__PURE__ */ new Date()).toISOString()
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
if (sessions.length > 0) migrated.profiles[profileName] = { sessions };
|
|
98
|
+
}
|
|
99
|
+
return migrated;
|
|
100
|
+
}
|
|
101
|
+
function normalizeSession(value) {
|
|
102
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return void 0;
|
|
103
|
+
const session = value;
|
|
104
|
+
if (session.environment !== "local" && session.environment !== "dev") return void 0;
|
|
105
|
+
if (typeof session.origin !== "string" || !session.origin.trim()) return void 0;
|
|
106
|
+
if (session.operatorId !== "OPERATOR_1") return void 0;
|
|
107
|
+
if (typeof session.updatedAt !== "string" || !session.updatedAt.trim()) return void 0;
|
|
108
|
+
if (session.operatorSessionCookie !== void 0 && typeof session.operatorSessionCookie !== "string") return void 0;
|
|
109
|
+
if (session.clientIdentityCookie !== void 0 && typeof session.clientIdentityCookie !== "string") return void 0;
|
|
110
|
+
return {
|
|
111
|
+
environment: session.environment,
|
|
112
|
+
origin: session.origin.trim(),
|
|
113
|
+
operatorId: session.operatorId,
|
|
114
|
+
updatedAt: session.updatedAt,
|
|
115
|
+
...session.operatorSessionCookie ? { operatorSessionCookie: session.operatorSessionCookie } : {},
|
|
116
|
+
...session.clientIdentityCookie ? { clientIdentityCookie: session.clientIdentityCookie } : {}
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
function parseRecord(raw) {
|
|
120
|
+
let parsed;
|
|
121
|
+
try {
|
|
122
|
+
parsed = JSON.parse(raw);
|
|
123
|
+
} catch {
|
|
124
|
+
throw new Error("The protected CLI profile file is not valid JSON.");
|
|
125
|
+
}
|
|
126
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
127
|
+
throw new Error("The protected CLI profile file has an unsupported format.");
|
|
128
|
+
}
|
|
129
|
+
const record = parsed;
|
|
130
|
+
if (record.version === 1) {
|
|
131
|
+
return migrateLegacyRecord(parsed);
|
|
132
|
+
}
|
|
133
|
+
if (record.version !== 2 || !record.profiles || typeof record.profiles !== "object" || Array.isArray(record.profiles)) {
|
|
134
|
+
throw new Error("The protected CLI profile file has an unsupported version or format.");
|
|
135
|
+
}
|
|
136
|
+
if (record.smsCredential !== void 0 && (typeof record.smsCredential !== "string" || !record.smsCredential.trim())) {
|
|
137
|
+
throw new Error("The protected CLI profile file has an invalid SMS credential record.");
|
|
138
|
+
}
|
|
139
|
+
const profiles = {};
|
|
140
|
+
for (const [profileName, profileValue] of Object.entries(record.profiles)) {
|
|
141
|
+
if (!profileValue || typeof profileValue !== "object" || Array.isArray(profileValue)) {
|
|
142
|
+
throw new Error("The protected CLI profile file has an unsupported version or format.");
|
|
143
|
+
}
|
|
144
|
+
const sessionsValue = profileValue.sessions;
|
|
145
|
+
if (!Array.isArray(sessionsValue)) {
|
|
146
|
+
throw new Error("The protected CLI profile file has an unsupported version or format.");
|
|
147
|
+
}
|
|
148
|
+
const sessions = sessionsValue.map(normalizeSession).filter((session) => session !== void 0);
|
|
149
|
+
if (sessions.length !== sessionsValue.length) {
|
|
150
|
+
throw new Error("The protected CLI profile file has an unsupported version or format.");
|
|
151
|
+
}
|
|
152
|
+
if (sessions.length > 0) profiles[profileName] = { sessions };
|
|
153
|
+
}
|
|
154
|
+
return {
|
|
155
|
+
version: 2,
|
|
156
|
+
...typeof record.smsCredential === "string" && record.smsCredential.trim() ? { smsCredential: record.smsCredential } : {},
|
|
157
|
+
profiles
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
function findSessionIndex(sessions, target) {
|
|
161
|
+
return sessions.findIndex((session) => targetsEqual(session, target));
|
|
162
|
+
}
|
|
163
|
+
function sessionHasCookies(session) {
|
|
164
|
+
return Boolean(session.operatorSessionCookie || session.clientIdentityCookie);
|
|
165
|
+
}
|
|
166
|
+
var CliProfileStore = class {
|
|
167
|
+
constructor(homeDirectory = process.env.HOME ?? process.env.USERPROFILE ?? process.cwd(), lockWaitMs = LOCK_WAIT_MS, directoryName = ".lmt") {
|
|
168
|
+
this.lockWaitMs = lockWaitMs;
|
|
169
|
+
this.directory = path.join(homeDirectory, directoryName);
|
|
170
|
+
this.filePath = path.join(this.directory, FILE_NAME);
|
|
171
|
+
this.lockPath = path.join(this.directory, `${FILE_NAME}.lock`);
|
|
172
|
+
}
|
|
173
|
+
lockWaitMs;
|
|
174
|
+
directory;
|
|
175
|
+
filePath;
|
|
176
|
+
lockPath;
|
|
177
|
+
async getSmsCredential() {
|
|
178
|
+
return this.withLock(async () => (await this.readRecord()).smsCredential);
|
|
179
|
+
}
|
|
180
|
+
async saveSmsCredential(credential) {
|
|
181
|
+
if (!credential.trim() || /[\r\n]/.test(credential)) throw new Error("SMS credential input must be a non-empty single line.");
|
|
182
|
+
await this.withLock(async () => {
|
|
183
|
+
const record = await this.readRecordForSmsCredentialUpdate();
|
|
184
|
+
record.smsCredential = credential;
|
|
185
|
+
await this.writeRecord(record);
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
async getSession(profile, target) {
|
|
189
|
+
assertClientProfileName(profile);
|
|
190
|
+
return this.withLock(async () => {
|
|
191
|
+
const stored = (await this.readRecord()).profiles[profile]?.sessions.find((session) => targetsEqual(session, target));
|
|
192
|
+
if (!stored) return void 0;
|
|
193
|
+
return {
|
|
194
|
+
...stored.operatorSessionCookie ? { operatorSessionCookie: stored.operatorSessionCookie } : {},
|
|
195
|
+
...stored.clientIdentityCookie ? { clientIdentityCookie: stored.clientIdentityCookie } : {}
|
|
196
|
+
};
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
async saveSession(profile, target, cookies) {
|
|
200
|
+
assertClientProfileName(profile);
|
|
201
|
+
assertCookie(cookies.operatorSessionCookie, "lmt_operator_session");
|
|
202
|
+
assertCookie(cookies.clientIdentityCookie, "lmt_client_id");
|
|
203
|
+
if (!cookies.operatorSessionCookie && !cookies.clientIdentityCookie) return;
|
|
204
|
+
await this.update((record) => {
|
|
205
|
+
const profileRecord = record.profiles[profile] ?? { sessions: [] };
|
|
206
|
+
const index = findSessionIndex(profileRecord.sessions, target);
|
|
207
|
+
const next = {
|
|
208
|
+
...target,
|
|
209
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
210
|
+
...cookies.clientIdentityCookie ? { clientIdentityCookie: cookies.clientIdentityCookie } : {},
|
|
211
|
+
...cookies.operatorSessionCookie ? { operatorSessionCookie: cookies.operatorSessionCookie } : {}
|
|
212
|
+
};
|
|
213
|
+
if (!sessionHasCookies(next)) {
|
|
214
|
+
if (index >= 0) profileRecord.sessions.splice(index, 1);
|
|
215
|
+
} else if (index >= 0) {
|
|
216
|
+
profileRecord.sessions[index] = next;
|
|
217
|
+
} else {
|
|
218
|
+
profileRecord.sessions.push(next);
|
|
219
|
+
}
|
|
220
|
+
if (profileRecord.sessions.length === 0) delete record.profiles[profile];
|
|
221
|
+
else record.profiles[profile] = profileRecord;
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
async clearOperatorSession(profile, target) {
|
|
225
|
+
assertClientProfileName(profile);
|
|
226
|
+
await this.update((record) => {
|
|
227
|
+
const profileRecord = record.profiles[profile];
|
|
228
|
+
if (!profileRecord) return;
|
|
229
|
+
const index = findSessionIndex(profileRecord.sessions, target);
|
|
230
|
+
if (index < 0) return;
|
|
231
|
+
const stored = profileRecord.sessions[index];
|
|
232
|
+
delete stored.operatorSessionCookie;
|
|
233
|
+
stored.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
234
|
+
this.cleanEmptySession(profileRecord, index);
|
|
235
|
+
if (profileRecord.sessions.length === 0) delete record.profiles[profile];
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
async removeTarget(profile, target) {
|
|
239
|
+
assertClientProfileName(profile);
|
|
240
|
+
await this.update((record) => {
|
|
241
|
+
const profileRecord = record.profiles[profile];
|
|
242
|
+
if (!profileRecord) return;
|
|
243
|
+
profileRecord.sessions = profileRecord.sessions.filter((session) => !targetsEqual(session, target));
|
|
244
|
+
if (profileRecord.sessions.length === 0) delete record.profiles[profile];
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
cleanEmptySession(profileRecord, index) {
|
|
248
|
+
const stored = profileRecord.sessions[index];
|
|
249
|
+
if (stored && !sessionHasCookies(stored)) profileRecord.sessions.splice(index, 1);
|
|
250
|
+
}
|
|
251
|
+
async update(mutator) {
|
|
252
|
+
await this.withLock(async () => {
|
|
253
|
+
const record = await this.readRecord();
|
|
254
|
+
mutator(record);
|
|
255
|
+
await this.writeRecord(record);
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
async withLock(operation) {
|
|
259
|
+
await this.ensureDirectory();
|
|
260
|
+
const started = Date.now();
|
|
261
|
+
let lock;
|
|
262
|
+
while (!lock) {
|
|
263
|
+
try {
|
|
264
|
+
lock = await open(this.lockPath, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY | (constants.O_NOFOLLOW ?? 0), OWNER_ONLY_FILE);
|
|
265
|
+
await lock.chmod(OWNER_ONLY_FILE);
|
|
266
|
+
await lock.writeFile(`${process.pid}
|
|
267
|
+
`);
|
|
268
|
+
await lock.sync();
|
|
269
|
+
} catch (error) {
|
|
270
|
+
if (error.code !== "EEXIST") {
|
|
271
|
+
throw new Error("Could not acquire the protected CLI profile storage lock.");
|
|
272
|
+
}
|
|
273
|
+
try {
|
|
274
|
+
await ensureOwnerOnly(this.lockPath, "file");
|
|
275
|
+
} catch (lockError) {
|
|
276
|
+
if (lockError.code === "ENOENT") continue;
|
|
277
|
+
throw lockError;
|
|
278
|
+
}
|
|
279
|
+
if (Date.now() - started >= this.lockWaitMs) {
|
|
280
|
+
throw new Error("Timed out waiting for the protected CLI profile storage lock.");
|
|
281
|
+
}
|
|
282
|
+
await new Promise((resolve) => setTimeout(resolve, LOCK_RETRY_MS));
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
try {
|
|
286
|
+
return await operation();
|
|
287
|
+
} finally {
|
|
288
|
+
await lock.close().catch(() => void 0);
|
|
289
|
+
await unlink(this.lockPath).catch(() => void 0);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
async ensureDirectory() {
|
|
293
|
+
let created = false;
|
|
294
|
+
try {
|
|
295
|
+
await mkdir(this.directory, { mode: OWNER_ONLY_DIRECTORY });
|
|
296
|
+
created = true;
|
|
297
|
+
} catch (error) {
|
|
298
|
+
if (error.code !== "EEXIST") {
|
|
299
|
+
throw new Error("Could not create the protected CLI profile directory.");
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
if (created) {
|
|
303
|
+
try {
|
|
304
|
+
await chmod(this.directory, OWNER_ONLY_DIRECTORY);
|
|
305
|
+
} catch {
|
|
306
|
+
throw new Error("Could not set protected CLI profile directory permissions.");
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
await ensureOwnerOnly(this.directory, "directory");
|
|
310
|
+
}
|
|
311
|
+
isRecoverableStoreFormatError(error) {
|
|
312
|
+
if (!(error instanceof Error)) return false;
|
|
313
|
+
return error.message === "The protected CLI profile file is not valid JSON." || error.message === "The protected CLI profile file has an unsupported format." || error.message === "The protected CLI profile file has an unsupported version or format." || error.message === "The protected CLI profile file has an invalid SMS credential record.";
|
|
314
|
+
}
|
|
315
|
+
async archiveUnreadableStoreFile() {
|
|
316
|
+
const backupPath = `${this.filePath}.corrupt.${Date.now()}.bak`;
|
|
317
|
+
try {
|
|
318
|
+
await rename(this.filePath, backupPath);
|
|
319
|
+
} catch {
|
|
320
|
+
throw new Error("Could not repair the protected CLI profile file.");
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
async readRecordForSmsCredentialUpdate() {
|
|
324
|
+
try {
|
|
325
|
+
return await this.readRecord();
|
|
326
|
+
} catch (error) {
|
|
327
|
+
if (!this.isRecoverableStoreFormatError(error)) throw error;
|
|
328
|
+
await this.archiveUnreadableStoreFile();
|
|
329
|
+
return emptyRecord();
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
async readRecord() {
|
|
333
|
+
try {
|
|
334
|
+
const info = await lstat(this.filePath);
|
|
335
|
+
if (info.isSymbolicLink() || !info.isFile()) {
|
|
336
|
+
throw new Error("The protected CLI profile file must be a regular file.");
|
|
337
|
+
}
|
|
338
|
+
await ensureOwnerOnly(this.filePath, "file");
|
|
339
|
+
const handle = await open(this.filePath, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
|
|
340
|
+
try {
|
|
341
|
+
return parseRecord(await handle.readFile("utf8"));
|
|
342
|
+
} finally {
|
|
343
|
+
await handle.close();
|
|
344
|
+
}
|
|
345
|
+
} catch (error) {
|
|
346
|
+
if (error.code === "ENOENT") return emptyRecord();
|
|
347
|
+
if (error instanceof Error && error.message.startsWith("The protected CLI profile")) throw error;
|
|
348
|
+
throw new Error("Could not read the protected CLI profile file.");
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
async writeRecord(record) {
|
|
352
|
+
const normalized = {
|
|
353
|
+
version: 2,
|
|
354
|
+
...record.smsCredential ? { smsCredential: record.smsCredential } : {},
|
|
355
|
+
profiles: Object.fromEntries(
|
|
356
|
+
Object.entries(record.profiles).map(([profileName, profileRecord]) => [
|
|
357
|
+
profileName,
|
|
358
|
+
{
|
|
359
|
+
sessions: profileRecord.sessions.map((session) => ({
|
|
360
|
+
environment: session.environment,
|
|
361
|
+
origin: session.origin,
|
|
362
|
+
operatorId: session.operatorId,
|
|
363
|
+
...session.operatorSessionCookie ? { operatorSessionCookie: session.operatorSessionCookie } : {},
|
|
364
|
+
...session.clientIdentityCookie ? { clientIdentityCookie: session.clientIdentityCookie } : {},
|
|
365
|
+
updatedAt: session.updatedAt
|
|
366
|
+
}))
|
|
367
|
+
}
|
|
368
|
+
])
|
|
369
|
+
)
|
|
370
|
+
};
|
|
371
|
+
const temporaryPath = `${this.filePath}.${process.pid}.${randomUUID()}.tmp`;
|
|
372
|
+
const content = `${JSON.stringify(normalized, null, 2)}
|
|
373
|
+
`;
|
|
374
|
+
let handle;
|
|
375
|
+
try {
|
|
376
|
+
handle = await open(temporaryPath, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY | (constants.O_NOFOLLOW ?? 0), OWNER_ONLY_FILE);
|
|
377
|
+
await handle.chmod(OWNER_ONLY_FILE);
|
|
378
|
+
await handle.writeFile(content, "utf8");
|
|
379
|
+
await handle.sync();
|
|
380
|
+
await handle.close();
|
|
381
|
+
handle = void 0;
|
|
382
|
+
await rename(temporaryPath, this.filePath);
|
|
383
|
+
const directoryHandle = await open(this.directory, constants.O_RDONLY);
|
|
384
|
+
try {
|
|
385
|
+
await directoryHandle.sync();
|
|
386
|
+
} finally {
|
|
387
|
+
await directoryHandle.close();
|
|
388
|
+
}
|
|
389
|
+
} catch {
|
|
390
|
+
if (handle) await handle.close().catch(() => void 0);
|
|
391
|
+
await unlink(temporaryPath).catch(() => void 0);
|
|
392
|
+
throw new Error("Could not atomically update the protected CLI profile file.");
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
};
|
|
396
|
+
|
|
397
|
+
// ../cli/src/config.ts
|
|
398
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
399
|
+
import path2 from "node:path";
|
|
400
|
+
import { fileURLToPath } from "node:url";
|
|
401
|
+
var repoRoot = path2.resolve(
|
|
402
|
+
path2.dirname(fileURLToPath(import.meta.url)),
|
|
403
|
+
"../../../../"
|
|
404
|
+
);
|
|
405
|
+
var packagedConfigPath = fileURLToPath(new URL("../lmt.config.json", import.meta.url));
|
|
406
|
+
var DEFAULT_CONFIG_PATH = existsSync(packagedConfigPath) ? packagedConfigPath : path2.join(repoRoot, "lmt.config.json");
|
|
407
|
+
function resolveCanonicalOrigin(value, environment) {
|
|
408
|
+
if (typeof value !== "string" || !value.trim()) {
|
|
409
|
+
throw new Error(`lmt.config.json does not contain the ${environment} Origin.`);
|
|
410
|
+
}
|
|
411
|
+
let origin;
|
|
412
|
+
try {
|
|
413
|
+
origin = new URL(value.trim());
|
|
414
|
+
} catch {
|
|
415
|
+
throw new Error(`${environment} Origin must be an absolute HTTP URL.`);
|
|
416
|
+
}
|
|
417
|
+
if (!/^https?:$/.test(origin.protocol) || origin.username || origin.password || origin.hash) {
|
|
418
|
+
throw new Error(
|
|
419
|
+
`${environment} Origin must be an HTTP URL without credentials or a fragment.`
|
|
420
|
+
);
|
|
421
|
+
}
|
|
422
|
+
if (origin.pathname !== "/" || origin.search) {
|
|
423
|
+
throw new Error(`${environment} Origin must not include a path or query string.`);
|
|
424
|
+
}
|
|
425
|
+
return origin.origin;
|
|
426
|
+
}
|
|
427
|
+
function resolveCliTarget(env = process.env, configPath = DEFAULT_CONFIG_PATH) {
|
|
428
|
+
const environment = env.LMT_ENV?.trim();
|
|
429
|
+
if (environment !== "local" && environment !== "dev") {
|
|
430
|
+
if (environment === "prod") {
|
|
431
|
+
throw new Error("The CLI supports local or dev targets only; prod is not permitted.");
|
|
432
|
+
}
|
|
433
|
+
throw new Error("Target must explicitly select --env local or --env dev; prod is not permitted.");
|
|
434
|
+
}
|
|
435
|
+
let config;
|
|
436
|
+
try {
|
|
437
|
+
config = JSON.parse(readFileSync(configPath, "utf8"));
|
|
438
|
+
} catch {
|
|
439
|
+
throw new Error("Could not read the deployment configuration lmt.config.json.");
|
|
440
|
+
}
|
|
441
|
+
const selected = config.environments?.[environment];
|
|
442
|
+
if (!selected) {
|
|
443
|
+
throw new Error(`lmt.config.json does not contain the ${environment} environment.`);
|
|
444
|
+
}
|
|
445
|
+
const origin = resolveCanonicalOrigin(selected.origin, environment);
|
|
446
|
+
const smsEndpoint = typeof config.smsEndpoint === "string" ? config.smsEndpoint.trim() : "";
|
|
447
|
+
if (!smsEndpoint) throw new Error("lmt.config.json does not contain smsEndpoint.");
|
|
448
|
+
const gateScope = typeof selected.gateScope === "string" ? selected.gateScope.trim() : "";
|
|
449
|
+
if (!gateScope) throw new Error(`lmt.config.json does not contain the ${environment} Gate scope.`);
|
|
450
|
+
const operators = selected.brokerOperatorIds;
|
|
451
|
+
if (!Array.isArray(operators) || operators.length !== 1 || operators[0] !== "OPERATOR_1") {
|
|
452
|
+
throw new Error(`${environment} must have exactly one configured Operator: OPERATOR_1.`);
|
|
453
|
+
}
|
|
454
|
+
let smsUrl;
|
|
455
|
+
try {
|
|
456
|
+
smsUrl = new URL(smsEndpoint);
|
|
457
|
+
} catch {
|
|
458
|
+
throw new Error("smsEndpoint must be an absolute HTTP URL.");
|
|
459
|
+
}
|
|
460
|
+
if (!/^https?:$/.test(smsUrl.protocol) || smsUrl.username || smsUrl.password || smsUrl.hash) {
|
|
461
|
+
throw new Error("smsEndpoint must be an HTTP URL without credentials or a fragment.");
|
|
462
|
+
}
|
|
463
|
+
return {
|
|
464
|
+
environment,
|
|
465
|
+
origin,
|
|
466
|
+
gateScope,
|
|
467
|
+
smsEndpoint: smsUrl.origin,
|
|
468
|
+
operatorId: "OPERATOR_1"
|
|
469
|
+
};
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
// src/saved-session.ts
|
|
473
|
+
import { execFileSync } from "node:child_process";
|
|
474
|
+
import { createHash } from "node:crypto";
|
|
475
|
+
import { constants as constants2 } from "node:fs";
|
|
476
|
+
import { lstat as lstat2, mkdir as mkdir2, open as open2 } from "node:fs/promises";
|
|
477
|
+
import os from "node:os";
|
|
478
|
+
import path3 from "node:path";
|
|
479
|
+
import { DatabaseSync } from "node:sqlite";
|
|
480
|
+
var SavedSessionError = class extends Error {
|
|
481
|
+
constructor() {
|
|
482
|
+
super("Could not access protected Web CLI session storage. Check its ownership, permissions, and integrity.");
|
|
483
|
+
}
|
|
484
|
+
};
|
|
485
|
+
async function assertPrivate(filename, directory) {
|
|
486
|
+
const entry = await lstat2(filename);
|
|
487
|
+
if ((directory ? !entry.isDirectory() : !entry.isFile()) || entry.isSymbolicLink() || entry.uid !== process.getuid?.() || (entry.mode & 511) !== (directory ? 448 : 384)) {
|
|
488
|
+
throw new SavedSessionError();
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
var SavedSession = class _SavedSession {
|
|
492
|
+
constructor(database, target) {
|
|
493
|
+
this.database = database;
|
|
494
|
+
this.target = target;
|
|
495
|
+
}
|
|
496
|
+
database;
|
|
497
|
+
target;
|
|
498
|
+
static async open(target) {
|
|
499
|
+
let database;
|
|
500
|
+
try {
|
|
501
|
+
const root = process.env.LMT_WEB_STATE_DIRECTORY || path3.join(os.homedir(), ".lmt-web");
|
|
502
|
+
await mkdir2(root, { recursive: true, mode: 448 });
|
|
503
|
+
await assertPrivate(root, true);
|
|
504
|
+
const directory = path3.join(root, "sessions");
|
|
505
|
+
await mkdir2(directory, { mode: 448 }).catch((error) => {
|
|
506
|
+
if (error.code !== "EEXIST") throw error;
|
|
507
|
+
});
|
|
508
|
+
await assertPrivate(directory, true);
|
|
509
|
+
const key = createHash("sha256").update(JSON.stringify([target.environment, target.origin, target.mode])).digest("hex");
|
|
510
|
+
const filename = path3.join(directory, `${key}.sqlite`);
|
|
511
|
+
const file = await open2(filename, constants2.O_CREAT | constants2.O_EXCL | constants2.O_WRONLY | constants2.O_NOFOLLOW, 384).catch((error) => {
|
|
512
|
+
if (error.code !== "EEXIST") throw error;
|
|
513
|
+
});
|
|
514
|
+
await file?.close();
|
|
515
|
+
await assertPrivate(filename, false);
|
|
516
|
+
database = new DatabaseSync(filename);
|
|
517
|
+
database.exec("PRAGMA busy_timeout = 25; PRAGMA secure_delete = ON;");
|
|
518
|
+
const store = new _SavedSession(database, target);
|
|
519
|
+
await store.reserve(Date.now() + 5e3, () => {
|
|
520
|
+
database.exec("CREATE TABLE IF NOT EXISTS session (id INTEGER PRIMARY KEY CHECK(id=1), state TEXT, owner TEXT)");
|
|
521
|
+
database.exec("INSERT OR IGNORE INTO session VALUES (1, NULL, NULL)");
|
|
522
|
+
});
|
|
523
|
+
return store;
|
|
524
|
+
} catch {
|
|
525
|
+
database?.close();
|
|
526
|
+
throw new SavedSessionError();
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
async reserve(deadlineAt, action) {
|
|
530
|
+
while (true) {
|
|
531
|
+
try {
|
|
532
|
+
this.database.exec("BEGIN IMMEDIATE");
|
|
533
|
+
break;
|
|
534
|
+
} catch (error) {
|
|
535
|
+
if (!/locked|busy/i.test(String(error)) || Date.now() >= deadlineAt) throw new SavedSessionError();
|
|
536
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
try {
|
|
540
|
+
const result = action();
|
|
541
|
+
this.database.exec("COMMIT");
|
|
542
|
+
return result;
|
|
543
|
+
} catch (error) {
|
|
544
|
+
this.database.exec("ROLLBACK");
|
|
545
|
+
throw error;
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
owner() {
|
|
549
|
+
try {
|
|
550
|
+
const row = this.database.prepare("SELECT owner FROM session WHERE id=1").get();
|
|
551
|
+
if (!row?.owner) return void 0;
|
|
552
|
+
const owner = JSON.parse(String(row.owner));
|
|
553
|
+
if (!/^[a-f0-9]{32}$/.test(owner.sessionId) || !Number.isSafeInteger(owner.pid) || owner.pid < 1 || typeof owner.headless !== "boolean" || typeof owner.processStartedAt !== "string") throw new SavedSessionError();
|
|
554
|
+
try {
|
|
555
|
+
process.kill(owner.pid, 0);
|
|
556
|
+
} catch (error) {
|
|
557
|
+
if (error.code !== "ESRCH") throw error;
|
|
558
|
+
return void 0;
|
|
559
|
+
}
|
|
560
|
+
try {
|
|
561
|
+
return this.processStartedAt(owner.pid) === owner.processStartedAt ? owner : void 0;
|
|
562
|
+
} catch {
|
|
563
|
+
try {
|
|
564
|
+
process.kill(owner.pid, 0);
|
|
565
|
+
} catch (error) {
|
|
566
|
+
if (error.code === "ESRCH") return void 0;
|
|
567
|
+
}
|
|
568
|
+
throw new SavedSessionError();
|
|
569
|
+
}
|
|
570
|
+
} catch {
|
|
571
|
+
throw new SavedSessionError();
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
processStartedAt(pid) {
|
|
575
|
+
const startedAt = execFileSync("ps", ["-p", String(pid), "-o", "lstart="], {
|
|
576
|
+
encoding: "utf8",
|
|
577
|
+
timeout: 1e3,
|
|
578
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
579
|
+
env: { ...process.env, LC_ALL: "C", TZ: "UTC" }
|
|
580
|
+
}).trim();
|
|
581
|
+
if (!startedAt) throw new SavedSessionError();
|
|
582
|
+
return startedAt;
|
|
583
|
+
}
|
|
584
|
+
setOwner(owner) {
|
|
585
|
+
this.database.prepare("UPDATE session SET owner=? WHERE id=1").run(JSON.stringify({ ...owner, processStartedAt: this.processStartedAt(owner.pid) }));
|
|
586
|
+
}
|
|
587
|
+
load() {
|
|
588
|
+
try {
|
|
589
|
+
const row = this.database.prepare("SELECT state FROM session WHERE id=1").get();
|
|
590
|
+
if (!row?.state) return void 0;
|
|
591
|
+
const state = JSON.parse(String(row.state));
|
|
592
|
+
if (!Array.isArray(state.cookies) || !Array.isArray(state.origins)) throw new Error();
|
|
593
|
+
return state;
|
|
594
|
+
} catch {
|
|
595
|
+
throw new SavedSessionError();
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
async save(state) {
|
|
599
|
+
const hostname = new URL(this.target.origin).hostname;
|
|
600
|
+
const scoped = {
|
|
601
|
+
cookies: state.cookies.filter((cookie) => cookie.domain === hostname || cookie.domain === `.${hostname}`),
|
|
602
|
+
origins: state.origins.filter((origin) => origin.origin === this.target.origin)
|
|
603
|
+
};
|
|
604
|
+
await this.reserve(Date.now() + 2e3, () => {
|
|
605
|
+
this.database.prepare("UPDATE session SET state=? WHERE id=1").run(JSON.stringify(scoped));
|
|
606
|
+
});
|
|
607
|
+
}
|
|
608
|
+
close() {
|
|
609
|
+
this.database.close();
|
|
610
|
+
}
|
|
611
|
+
};
|
|
612
|
+
|
|
613
|
+
// src/actions.ts
|
|
614
|
+
var InvocationError = class extends Error {
|
|
615
|
+
constructor(message, code = "invalid_invocation", action) {
|
|
616
|
+
super(message);
|
|
617
|
+
this.code = code;
|
|
618
|
+
this.action = action;
|
|
619
|
+
}
|
|
620
|
+
code;
|
|
621
|
+
action;
|
|
622
|
+
};
|
|
623
|
+
function required(input, option) {
|
|
624
|
+
const value = input.values.get(option);
|
|
625
|
+
if (value === void 0 || option !== "--value" && !value) throw new InvocationError(`${option} is required for this action.`);
|
|
626
|
+
return value;
|
|
627
|
+
}
|
|
628
|
+
var ACTIONS = {
|
|
629
|
+
start: { control: false, flags: ["--headed", "--fresh"], values: ["--env", "--device"], build: () => ({ action: "start" }) },
|
|
630
|
+
inspect: { control: false, flags: [], values: [], build: () => ({ action: "inspect" }) },
|
|
631
|
+
close: { control: false, flags: [], values: [], build: () => ({ action: "close" }) },
|
|
632
|
+
stop: { control: false, flags: [], values: [], build: () => ({ action: "stop" }) },
|
|
633
|
+
"operator-login": { control: false, flags: [], values: [], build: () => ({ action: "operator-login" }) },
|
|
634
|
+
"operator-logout": { control: false, flags: [], values: [], build: () => ({ action: "operator-logout" }) },
|
|
635
|
+
"zerodha-login": { control: false, flags: [], values: [], build: () => ({ action: "zerodha-login" }) },
|
|
636
|
+
"zerodha-cancel": { control: false, flags: [], values: [], build: () => ({ action: "zerodha-cancel" }) },
|
|
637
|
+
"zerodha-logout": { control: false, flags: [], values: [], build: () => ({ action: "zerodha-logout" }) },
|
|
638
|
+
"zerodha-otp": {
|
|
639
|
+
control: false,
|
|
640
|
+
flags: ["--stdin"],
|
|
641
|
+
values: ["--attempt"],
|
|
642
|
+
build: async (input) => {
|
|
643
|
+
const attemptId = required(input, "--attempt");
|
|
644
|
+
if (!input.flags.has("--stdin")) throw new InvocationError("zerodha-otp requires --attempt ID and --stdin.");
|
|
645
|
+
return { action: "zerodha-otp", attemptId, otp: await input.readOtp() };
|
|
646
|
+
}
|
|
647
|
+
},
|
|
648
|
+
read: { control: true, flags: [], values: ["--ref"], build: (input) => ({ action: "read", reference: required(input, "--ref") }) },
|
|
649
|
+
clipboard: { control: true, flags: [], values: [], build: () => ({ action: "clipboard" }) },
|
|
650
|
+
paste: { control: true, flags: [], values: ["--ref"], build: (input) => ({ action: "paste", reference: required(input, "--ref") }) },
|
|
651
|
+
check: { control: true, flags: ["--stdin"], values: ["--ref"], build: async (input) => {
|
|
652
|
+
if (!input.flags.has("--stdin")) throw new InvocationError("check requires --stdin.");
|
|
653
|
+
return { action: "check", reference: required(input, "--ref"), expected: await input.readInput() };
|
|
654
|
+
} },
|
|
655
|
+
"clipboard-check": { control: true, flags: [], values: ["--ref"], build: (input) => ({ action: "clipboard-check", reference: required(input, "--ref") }) },
|
|
656
|
+
click: { control: true, flags: [], values: ["--ref"], build: (input) => ({ action: "click", reference: required(input, "--ref") }) },
|
|
657
|
+
fill: {
|
|
658
|
+
control: true,
|
|
659
|
+
flags: ["--secret"],
|
|
660
|
+
values: ["--ref", "--value"],
|
|
661
|
+
build: (input) => ({ action: "fill", reference: required(input, "--ref"), value: required(input, "--value"), secret: input.flags.has("--secret") })
|
|
662
|
+
},
|
|
663
|
+
select: {
|
|
664
|
+
control: true,
|
|
665
|
+
flags: [],
|
|
666
|
+
values: ["--ref", "--value"],
|
|
667
|
+
build: (input) => ({ action: "select", reference: required(input, "--ref"), value: required(input, "--value") })
|
|
668
|
+
},
|
|
669
|
+
scroll: {
|
|
670
|
+
control: true,
|
|
671
|
+
flags: [],
|
|
672
|
+
values: ["--ref", "--x", "--y"],
|
|
673
|
+
build: (input) => ({ action: "scroll", reference: input.values.get("--ref"), x: Number(input.values.get("--x") ?? 0), y: Number(input.values.get("--y") ?? 0) })
|
|
674
|
+
},
|
|
675
|
+
navigate: { control: true, flags: [], values: ["--url"], build: (input) => ({ action: "navigate", url: required(input, "--url") }) }
|
|
676
|
+
};
|
|
677
|
+
function isAction(value) {
|
|
678
|
+
return typeof value === "string" && Object.hasOwn(ACTIONS, value);
|
|
679
|
+
}
|
|
680
|
+
function isControlAction(action) {
|
|
681
|
+
return ACTIONS[action.action].control;
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
// src/config.ts
|
|
685
|
+
import { existsSync as existsSync2, readFileSync as readFileSync2 } from "node:fs";
|
|
686
|
+
import path4 from "node:path";
|
|
687
|
+
import { fileURLToPath as fileURLToPath2 } from "node:url";
|
|
688
|
+
var repositoryRoot = path4.resolve(
|
|
689
|
+
path4.dirname(fileURLToPath2(import.meta.url)),
|
|
690
|
+
"../../../../"
|
|
691
|
+
);
|
|
692
|
+
var packagedConfig = fileURLToPath2(new URL("../lmt-web.config.json", import.meta.url));
|
|
693
|
+
var DEFAULT_CONFIG_PATH2 = existsSync2(packagedConfig) ? packagedConfig : path4.join(repositoryRoot, "lmt.config.json");
|
|
694
|
+
function canonicalOrigin(value, environment) {
|
|
695
|
+
if (typeof value !== "string" || !value.trim()) {
|
|
696
|
+
throw new Error(`lmt-web.config.json does not contain the ${environment} Web URL.`);
|
|
697
|
+
}
|
|
698
|
+
let origin;
|
|
699
|
+
try {
|
|
700
|
+
origin = new URL(value.trim());
|
|
701
|
+
} catch {
|
|
702
|
+
throw new Error(`${environment} Web URL must be an absolute HTTP URL.`);
|
|
703
|
+
}
|
|
704
|
+
if (!/^https?:$/.test(origin.protocol) || origin.username || origin.password || origin.hash) {
|
|
705
|
+
throw new Error(
|
|
706
|
+
`${environment} Web URL must be an HTTP URL without credentials or a fragment.`
|
|
707
|
+
);
|
|
708
|
+
}
|
|
709
|
+
if (origin.pathname !== "/" || origin.search) {
|
|
710
|
+
throw new Error(`${environment} Web URL must not include a path or query string.`);
|
|
711
|
+
}
|
|
712
|
+
return origin.origin;
|
|
713
|
+
}
|
|
714
|
+
function resolveWebTarget(environmentValue, configPath = process.env.LMT_WEB_CONFIG_PATH || DEFAULT_CONFIG_PATH2) {
|
|
715
|
+
const environment = environmentValue?.trim();
|
|
716
|
+
if (environment !== "local" && environment !== "dev") {
|
|
717
|
+
if (environment === "prod") {
|
|
718
|
+
throw new Error("lmt-web supports local or dev targets only; prod is not permitted.");
|
|
719
|
+
}
|
|
720
|
+
throw new Error("Target must explicitly select --env local or --env dev; prod is not permitted.");
|
|
721
|
+
}
|
|
722
|
+
let config;
|
|
723
|
+
try {
|
|
724
|
+
config = JSON.parse(readFileSync2(configPath, "utf8"));
|
|
725
|
+
} catch {
|
|
726
|
+
throw new Error("Could not read the deployment configuration lmt-web.config.json.");
|
|
727
|
+
}
|
|
728
|
+
const selected = config.environments?.[environment];
|
|
729
|
+
if (!selected) {
|
|
730
|
+
throw new Error(`lmt-web.config.json does not contain the ${environment} environment.`);
|
|
731
|
+
}
|
|
732
|
+
return { environment, origin: canonicalOrigin(selected.origin, environment) };
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
// src/protocol.ts
|
|
736
|
+
var RESULT_VERSION = 1;
|
|
737
|
+
function failureResult(action, code, message, sessionId = null) {
|
|
738
|
+
return {
|
|
739
|
+
version: RESULT_VERSION,
|
|
740
|
+
sessionId,
|
|
741
|
+
action,
|
|
742
|
+
outcome: "failure",
|
|
743
|
+
authentication: { status: "unknown" },
|
|
744
|
+
url: null,
|
|
745
|
+
visibleState: null,
|
|
746
|
+
device: null,
|
|
747
|
+
screenshot: { outcome: "unavailable", reason: "No browser page was available." },
|
|
748
|
+
error: { code, message }
|
|
749
|
+
};
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
// src/session.ts
|
|
753
|
+
import { randomBytes as randomBytes2 } from "node:crypto";
|
|
754
|
+
import { chmod as chmod2, lstat as lstat3, mkdir as mkdir3, unlink as unlink2 } from "node:fs/promises";
|
|
755
|
+
import net from "node:net";
|
|
756
|
+
import os2 from "node:os";
|
|
757
|
+
import path5 from "node:path";
|
|
758
|
+
import { chromium as chromium2 } from "playwright";
|
|
759
|
+
|
|
760
|
+
// ../../packages/crawler/src/full-content.ts
|
|
761
|
+
async function captureFullContent(browserPage, options) {
|
|
762
|
+
const page = browserPage;
|
|
763
|
+
if (!page.evaluate) throw new Error("Full-content capture is unavailable.");
|
|
764
|
+
try {
|
|
765
|
+
await page.evaluate(() => {
|
|
766
|
+
const state = [];
|
|
767
|
+
window.__lmtCapture = state;
|
|
768
|
+
const scrolling = Array.from(document.querySelectorAll("body *")).filter((element) => {
|
|
769
|
+
const style = getComputedStyle(element);
|
|
770
|
+
return /(auto|scroll)/.test(style.overflowY) && element.scrollHeight > element.clientHeight;
|
|
771
|
+
});
|
|
772
|
+
const expand = /* @__PURE__ */ new Set();
|
|
773
|
+
for (const element of scrolling) {
|
|
774
|
+
for (let parent = element; parent; parent = parent.parentElement) expand.add(parent);
|
|
775
|
+
}
|
|
776
|
+
for (const element of expand) {
|
|
777
|
+
state.push({ element, style: element.getAttribute("style"), x: element.scrollLeft, y: element.scrollTop });
|
|
778
|
+
element.style.setProperty("height", "auto", "important");
|
|
779
|
+
element.style.setProperty("max-height", "none", "important");
|
|
780
|
+
element.style.setProperty("overflow", "visible", "important");
|
|
781
|
+
element.style.setProperty("flex-shrink", "0", "important");
|
|
782
|
+
element.scrollTop = 0;
|
|
783
|
+
}
|
|
784
|
+
});
|
|
785
|
+
await page.evaluate(() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve()))));
|
|
786
|
+
const complete = await page.evaluate(() => Array.from(document.querySelectorAll("body *")).every((element) => {
|
|
787
|
+
const style = getComputedStyle(element);
|
|
788
|
+
const assistiveText = element.getAttribute("data-lmt-capture") === "assistive-text" && style.position === "absolute" && element.clientWidth <= 1 && element.clientHeight <= 1;
|
|
789
|
+
return assistiveText || style.display === "none" || element.clientHeight === 0 || !/(auto|scroll|hidden|clip)/.test(style.overflowY) || element.scrollHeight <= element.clientHeight + 1;
|
|
790
|
+
}));
|
|
791
|
+
if (!complete) throw new Error("Full-content capture could not expand all clipped content.");
|
|
792
|
+
return await page.screenshot({ ...options, fullPage: true });
|
|
793
|
+
} finally {
|
|
794
|
+
await page.evaluate(() => {
|
|
795
|
+
const owner = window;
|
|
796
|
+
for (const { element, style, x, y } of (owner.__lmtCapture ?? []).reverse()) {
|
|
797
|
+
if (style === null) element.removeAttribute("style");
|
|
798
|
+
else element.setAttribute("style", style);
|
|
799
|
+
element.scrollLeft = x;
|
|
800
|
+
element.scrollTop = y;
|
|
801
|
+
}
|
|
802
|
+
delete owner.__lmtCapture;
|
|
803
|
+
}).catch(() => void 0);
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
// ../../packages/crawler/src/controls.ts
|
|
808
|
+
import { createHash as createHash2, randomBytes } from "node:crypto";
|
|
809
|
+
var ControlError = class extends Error {
|
|
810
|
+
constructor(code, message) {
|
|
811
|
+
super(message);
|
|
812
|
+
this.code = code;
|
|
813
|
+
}
|
|
814
|
+
code;
|
|
815
|
+
};
|
|
816
|
+
function sensitiveField(node) {
|
|
817
|
+
if (node.hasAttribute("data-lmt-sensitive")) return true;
|
|
818
|
+
if (node.getAttribute("data-lmt-readable") === "ordinary") return false;
|
|
819
|
+
const description = [
|
|
820
|
+
node.getAttribute("type"),
|
|
821
|
+
node.id,
|
|
822
|
+
node.getAttribute("name"),
|
|
823
|
+
node.getAttribute("aria-label"),
|
|
824
|
+
...Array.from(node.labels ?? []).map((label) => label.textContent)
|
|
825
|
+
].join(" ");
|
|
826
|
+
return /password|credential|secret|token|otp|one.time|username|user.?id|api.?key|pin/i.test(description);
|
|
827
|
+
}
|
|
828
|
+
function secretMetadata(value) {
|
|
829
|
+
return {
|
|
830
|
+
present: value.length > 0,
|
|
831
|
+
length: value.length,
|
|
832
|
+
fingerprint: value ? createHash2("sha256").update(value).digest("hex").slice(0, 12) : null
|
|
833
|
+
};
|
|
834
|
+
}
|
|
835
|
+
function controlSignature(node) {
|
|
836
|
+
if (!node.isConnected) return null;
|
|
837
|
+
return JSON.stringify([
|
|
838
|
+
node.tagName,
|
|
839
|
+
node.getAttribute("type"),
|
|
840
|
+
node.getAttribute("name"),
|
|
841
|
+
node.getAttribute("aria-label"),
|
|
842
|
+
node.getAttribute("href"),
|
|
843
|
+
node instanceof HTMLInputElement || node instanceof HTMLTextAreaElement || node instanceof HTMLSelectElement ? "" : node.textContent
|
|
844
|
+
]);
|
|
845
|
+
}
|
|
846
|
+
var PageControls = class {
|
|
847
|
+
constructor(page, origin) {
|
|
848
|
+
this.origin = origin;
|
|
849
|
+
this.page = page;
|
|
850
|
+
}
|
|
851
|
+
origin;
|
|
852
|
+
page;
|
|
853
|
+
references = /* @__PURE__ */ new Map();
|
|
854
|
+
secrets = /* @__PURE__ */ new Set();
|
|
855
|
+
prefix = randomBytes(8).toString("hex");
|
|
856
|
+
sequence = 0;
|
|
857
|
+
registerSecrets(...values) {
|
|
858
|
+
for (const value of values) if (value) this.secrets.add(value);
|
|
859
|
+
}
|
|
860
|
+
redact(value) {
|
|
861
|
+
let result = value;
|
|
862
|
+
for (const secret of [...this.secrets].sort((a, b) => b.length - a.length)) {
|
|
863
|
+
result = result.split(secret).join("[redacted]");
|
|
864
|
+
result = result.split(encodeURIComponent(secret)).join("[redacted]");
|
|
865
|
+
}
|
|
866
|
+
return result;
|
|
867
|
+
}
|
|
868
|
+
async learnSecrets() {
|
|
869
|
+
const fields = await this.page.$$("input, textarea, select, [data-lmt-sensitive]");
|
|
870
|
+
const values = [];
|
|
871
|
+
for (const field of fields) {
|
|
872
|
+
try {
|
|
873
|
+
if (await field.evaluate(sensitiveField)) values.push(await field.evaluate((node) => node instanceof HTMLInputElement || node instanceof HTMLTextAreaElement || node instanceof HTMLSelectElement ? node.value : node.textContent ?? ""));
|
|
874
|
+
} finally {
|
|
875
|
+
await field.dispose();
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
const textSecrets = await this.page.evaluate(() => Array.from((document.body?.innerText ?? "").matchAll(
|
|
879
|
+
/(?:access[\s_-]*token|request[\s_-]*token|api[\s_-]*(?:secret|key|token)|password|credential|otp)[\t ]*[:=][\t ]*([^\s,;]+)/gi
|
|
880
|
+
), (match) => match[1]).filter(Boolean));
|
|
881
|
+
values.push(...textSecrets);
|
|
882
|
+
values.push(...await this.page.evaluate(() => window.__lmtPasteSecrets ?? []));
|
|
883
|
+
this.registerSecrets(...values);
|
|
884
|
+
}
|
|
885
|
+
async screenshotMasks() {
|
|
886
|
+
const stable = this.page.locator("[data-lmt-sensitive]");
|
|
887
|
+
await this.learnSecrets();
|
|
888
|
+
const fields = await this.page.$$("input, textarea, select");
|
|
889
|
+
for (const field of fields) {
|
|
890
|
+
try {
|
|
891
|
+
const value = await field.inputValue();
|
|
892
|
+
const sensitive = value.length > 0 && (await field.evaluate(sensitiveField) || [...this.secrets].some((secret) => value.includes(secret)));
|
|
893
|
+
await field.evaluate((node, mask) => node.toggleAttribute("data-lmt-field-mask", mask), sensitive);
|
|
894
|
+
} finally {
|
|
895
|
+
await field.dispose();
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
await this.page.evaluate((secrets) => {
|
|
899
|
+
document.querySelectorAll("[data-lmt-secret-mask]").forEach((element) => element.removeAttribute("data-lmt-secret-mask"));
|
|
900
|
+
if (!secrets.length) return;
|
|
901
|
+
for (const element of document.querySelectorAll("body *")) {
|
|
902
|
+
const directText = Array.from(element.childNodes).filter((node) => node.nodeType === Node.TEXT_NODE).map((node) => node.textContent).join("");
|
|
903
|
+
if (secrets.some((secret) => directText.includes(secret) || (element.textContent ?? "").includes(secret) && !Array.from(element.children).some((child) => (child.textContent ?? "").includes(secret)))) element.setAttribute("data-lmt-secret-mask", "");
|
|
904
|
+
}
|
|
905
|
+
}, [...this.secrets]);
|
|
906
|
+
return [stable, this.page.locator("[data-lmt-field-mask], [data-lmt-secret-mask]")];
|
|
907
|
+
}
|
|
908
|
+
async inspect() {
|
|
909
|
+
await this.learnSecrets();
|
|
910
|
+
for (const { element } of this.references.values()) await element.dispose().catch(() => void 0);
|
|
911
|
+
this.references.clear();
|
|
912
|
+
const controls = [];
|
|
913
|
+
const elements = await this.page.$$("button, a[href], input, select, textarea, [role=button], [contenteditable=true]");
|
|
914
|
+
for (const element of elements) {
|
|
915
|
+
if (controls.length >= 100 || !await element.isVisible()) {
|
|
916
|
+
await element.dispose();
|
|
917
|
+
continue;
|
|
918
|
+
}
|
|
919
|
+
const description = await element.evaluate((node) => {
|
|
920
|
+
const labels = Array.from(node.labels ?? []).map((label) => {
|
|
921
|
+
const copy = label.cloneNode(true);
|
|
922
|
+
copy.querySelectorAll("input,select,textarea,button").forEach((control) => control.remove());
|
|
923
|
+
return copy.textContent ?? "";
|
|
924
|
+
}).join(" ");
|
|
925
|
+
return {
|
|
926
|
+
kind: node.getAttribute("role") || node.tagName.toLowerCase(),
|
|
927
|
+
label: (node.getAttribute("aria-label") || node.getAttribute("title") || labels || node.getAttribute("placeholder") || (node instanceof HTMLInputElement || node instanceof HTMLTextAreaElement ? "" : node.textContent) || "").replace(/\s+/g, " ").trim()
|
|
928
|
+
};
|
|
929
|
+
});
|
|
930
|
+
const reference = `${this.prefix}-${++this.sequence}`;
|
|
931
|
+
this.references.set(reference, { element, signature: await element.evaluate(controlSignature) });
|
|
932
|
+
controls.push({ reference, kind: description.kind, label: this.redact(description.label).slice(0, 160) });
|
|
933
|
+
}
|
|
934
|
+
return controls;
|
|
935
|
+
}
|
|
936
|
+
async resolve(reference) {
|
|
937
|
+
const saved = this.references.get(reference);
|
|
938
|
+
const element = saved?.element;
|
|
939
|
+
if (!element || !saved?.signature || await element.evaluate(controlSignature).catch(() => null) !== saved.signature) {
|
|
940
|
+
throw new ControlError("stale_reference", "The control reference is missing or stale. Inspect the page for current references.");
|
|
941
|
+
}
|
|
942
|
+
if (!await element.isVisible()) throw new ControlError("stale_reference", "The referenced control is no longer visible. Inspect the page again.");
|
|
943
|
+
return element;
|
|
944
|
+
}
|
|
945
|
+
async stopLoading() {
|
|
946
|
+
const session = await this.page.context().newCDPSession(this.page);
|
|
947
|
+
try {
|
|
948
|
+
await session.send("Page.stopLoading");
|
|
949
|
+
} finally {
|
|
950
|
+
await session.detach();
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
async act(command, timeoutMs, onSubmit = () => void 0) {
|
|
954
|
+
this.page.setDefaultTimeout(timeoutMs);
|
|
955
|
+
this.page.setDefaultNavigationTimeout(timeoutMs);
|
|
956
|
+
if (command.action === "read" || command.action === "check" || command.action === "clipboard-check" || command.action === "clipboard") {
|
|
957
|
+
await this.learnSecrets();
|
|
958
|
+
if (command.action === "check") this.registerSecrets(command.expected);
|
|
959
|
+
try {
|
|
960
|
+
const field = command.action === "clipboard" ? void 0 : await this.resolve(command.reference);
|
|
961
|
+
if (field && await field.getAttribute("data-lmt-value-state") === "concealed") {
|
|
962
|
+
throw new ControlError("value_unreadable", "This field contains a display placeholder. Reveal the value or read the clipboard after Copy.");
|
|
963
|
+
}
|
|
964
|
+
if (command.action === "clipboard-check" || command.action === "clipboard") await this.page.context().grantPermissions(["clipboard-read", "clipboard-write"], { origin: this.origin });
|
|
965
|
+
if (command.action === "clipboard-check") {
|
|
966
|
+
const matches = await field.evaluate(async (node) => {
|
|
967
|
+
if (!(node instanceof HTMLInputElement || node instanceof HTMLTextAreaElement || node instanceof HTMLSelectElement)) throw new Error("Unreadable field");
|
|
968
|
+
return await navigator.clipboard.readText() === node.value;
|
|
969
|
+
});
|
|
970
|
+
return { matches };
|
|
971
|
+
}
|
|
972
|
+
const value = command.action === "clipboard" ? await this.page.evaluate(() => navigator.clipboard.readText()) : await field.evaluate((node) => {
|
|
973
|
+
if (!(node instanceof HTMLInputElement || node instanceof HTMLTextAreaElement || node instanceof HTMLSelectElement)) throw new Error("Unreadable field");
|
|
974
|
+
return node.value;
|
|
975
|
+
});
|
|
976
|
+
if (command.action === "check") {
|
|
977
|
+
this.registerSecrets(value);
|
|
978
|
+
return { matches: value === command.expected };
|
|
979
|
+
}
|
|
980
|
+
const sensitive = field && await field.evaluate(sensitiveField);
|
|
981
|
+
if (command.action === "clipboard") {
|
|
982
|
+
const fields = await this.page.$$("input,textarea,select");
|
|
983
|
+
let ordinary = false;
|
|
984
|
+
for (const candidate of fields) {
|
|
985
|
+
try {
|
|
986
|
+
if (!await candidate.evaluate(sensitiveField) && await candidate.inputValue() === value) ordinary = true;
|
|
987
|
+
} finally {
|
|
988
|
+
await candidate.dispose();
|
|
989
|
+
}
|
|
990
|
+
}
|
|
991
|
+
if (!ordinary) {
|
|
992
|
+
this.registerSecrets(value);
|
|
993
|
+
return { secret: secretMetadata(value) };
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
if (sensitive || [...this.secrets].some((secret) => value.includes(secret))) {
|
|
997
|
+
this.registerSecrets(value);
|
|
998
|
+
return { secret: secretMetadata(value) };
|
|
999
|
+
}
|
|
1000
|
+
return { value };
|
|
1001
|
+
} catch (error) {
|
|
1002
|
+
if (error instanceof ControlError) throw error;
|
|
1003
|
+
throw new ControlError("value_unreadable", "The field or clipboard could not be read. No comparison result is available.");
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
1006
|
+
if (command.action === "paste") {
|
|
1007
|
+
await this.page.context().grantPermissions(["clipboard-read", "clipboard-write"], { origin: this.origin });
|
|
1008
|
+
const field = await this.resolve(command.reference);
|
|
1009
|
+
if (!await field.evaluate(sensitiveField)) throw new ControlError("protected_target_required", "Paste requires a protected input or textarea.");
|
|
1010
|
+
const editable = await field.evaluate((node) => (node instanceof HTMLInputElement || node instanceof HTMLTextAreaElement) && !node.readOnly && !node.disabled);
|
|
1011
|
+
if (!editable) throw new ControlError("value_unreadable", "The paste target is not editable.");
|
|
1012
|
+
try {
|
|
1013
|
+
const result = await field.evaluate(async (node) => {
|
|
1014
|
+
const input = node;
|
|
1015
|
+
const value = await navigator.clipboard.readText();
|
|
1016
|
+
const owner = window;
|
|
1017
|
+
const protectedValues = owner.__lmtPasteSecrets ??= [];
|
|
1018
|
+
if (value && !protectedValues.includes(value)) protectedValues.push(value);
|
|
1019
|
+
const prototype = input instanceof HTMLTextAreaElement ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype;
|
|
1020
|
+
Object.getOwnPropertyDescriptor(prototype, "value").set.call(input, value);
|
|
1021
|
+
input.dispatchEvent(new Event("input", { bubbles: true }));
|
|
1022
|
+
input.dispatchEvent(new Event("change", { bubbles: true }));
|
|
1023
|
+
await new Promise((resolve) => requestAnimationFrame(() => resolve()));
|
|
1024
|
+
if (!input.isConnected || input.value !== value) return null;
|
|
1025
|
+
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(value));
|
|
1026
|
+
const fingerprint = value ? Array.from(new Uint8Array(digest)).map((byte) => byte.toString(16).padStart(2, "0")).join("").slice(0, 12) : null;
|
|
1027
|
+
return { present: value.length > 0, length: value.length, fingerprint };
|
|
1028
|
+
});
|
|
1029
|
+
if (!result) throw new ControlError("paste_not_retained", "The field did not retain the pasted value.");
|
|
1030
|
+
await this.learnSecrets();
|
|
1031
|
+
return { secret: result, matches: true };
|
|
1032
|
+
} catch (error) {
|
|
1033
|
+
if (error instanceof ControlError) throw error;
|
|
1034
|
+
throw new ControlError("value_unreadable", "The clipboard could not be pasted into the protected field.");
|
|
1035
|
+
}
|
|
1036
|
+
}
|
|
1037
|
+
if (command.action === "fill") {
|
|
1038
|
+
const element = await this.resolve(command.reference);
|
|
1039
|
+
const sensitive = await element.evaluate(sensitiveField);
|
|
1040
|
+
if (command.secret || sensitive) this.registerSecrets(command.value);
|
|
1041
|
+
await element.fill(command.value, { timeout: timeoutMs });
|
|
1042
|
+
return;
|
|
1043
|
+
}
|
|
1044
|
+
if (command.action === "select") {
|
|
1045
|
+
await (await this.resolve(command.reference)).selectOption(command.value, { timeout: timeoutMs });
|
|
1046
|
+
return;
|
|
1047
|
+
}
|
|
1048
|
+
if (command.action === "click") {
|
|
1049
|
+
await this.page.context().grantPermissions(["clipboard-read", "clipboard-write"], { origin: this.origin });
|
|
1050
|
+
const element = await this.resolve(command.reference);
|
|
1051
|
+
const deadlineAt = Date.now() + timeoutMs;
|
|
1052
|
+
await element.click({ trial: true, timeout: timeoutMs });
|
|
1053
|
+
onSubmit();
|
|
1054
|
+
try {
|
|
1055
|
+
await element.click({ timeout: Math.max(1, deadlineAt - Date.now()) });
|
|
1056
|
+
} catch {
|
|
1057
|
+
throw new ControlError("action_uncertain", "The click may have been submitted but its completion was not confirmed. Inspect before deciding whether to retry.");
|
|
1058
|
+
}
|
|
1059
|
+
return;
|
|
1060
|
+
}
|
|
1061
|
+
if (command.action === "navigate") {
|
|
1062
|
+
const target = new URL(command.url, this.origin);
|
|
1063
|
+
if (target.origin !== new URL(this.origin).origin || target.username || target.password) {
|
|
1064
|
+
throw new ControlError("invalid_target", "Navigation must use the session's configured Origin.");
|
|
1065
|
+
}
|
|
1066
|
+
await this.page.goto(target.href, { timeout: timeoutMs, waitUntil: "domcontentloaded" });
|
|
1067
|
+
return;
|
|
1068
|
+
}
|
|
1069
|
+
if (command.action === "scroll") {
|
|
1070
|
+
if (!Number.isFinite(command.x) || !Number.isFinite(command.y)) throw new ControlError("invalid_invocation", "Scroll offsets must be finite pixel values.");
|
|
1071
|
+
const element = command.reference ? await this.resolve(command.reference) : void 0;
|
|
1072
|
+
const scroll = await this.page.evaluate(({ element: node, offsets }) => {
|
|
1073
|
+
const scrollable = (item) => {
|
|
1074
|
+
const style = getComputedStyle(item);
|
|
1075
|
+
return /(auto|scroll)/.test(style.overflow + style.overflowY + style.overflowX) && (item.scrollHeight > item.clientHeight || item.scrollWidth > item.clientWidth);
|
|
1076
|
+
};
|
|
1077
|
+
const containers = [];
|
|
1078
|
+
if (node) {
|
|
1079
|
+
for (let parent = node.parentElement; parent; parent = parent.parentElement) if (scrollable(parent)) containers.push(parent);
|
|
1080
|
+
if (document.scrollingElement && !containers.includes(document.scrollingElement)) containers.push(document.scrollingElement);
|
|
1081
|
+
} else {
|
|
1082
|
+
const candidates = Array.from(document.querySelectorAll("body *")).filter(scrollable);
|
|
1083
|
+
const root = document.scrollingElement;
|
|
1084
|
+
if (root && !/(hidden|clip)/.test(getComputedStyle(root).overflowY) && (root.scrollHeight > root.clientHeight || root.scrollWidth > root.clientWidth)) candidates.push(root);
|
|
1085
|
+
candidates.sort((a, b) => b.clientWidth * b.clientHeight - a.clientWidth * a.clientHeight);
|
|
1086
|
+
containers.push(candidates[0] ?? document.scrollingElement);
|
|
1087
|
+
}
|
|
1088
|
+
const positions = () => containers.map((item) => ({ x: item.scrollLeft, y: item.scrollTop }));
|
|
1089
|
+
const before = positions();
|
|
1090
|
+
if (node) node.scrollIntoView({ block: "nearest", inline: "nearest", behavior: "instant" });
|
|
1091
|
+
else containers[0].scrollBy({ left: offsets.x, top: offsets.y, behavior: "instant" });
|
|
1092
|
+
const after = positions();
|
|
1093
|
+
const moved = before.some((position, i) => position.x !== after[i].x || position.y !== after[i].y);
|
|
1094
|
+
if (node) {
|
|
1095
|
+
const rect = node.getBoundingClientRect();
|
|
1096
|
+
let left = Math.max(0, rect.left), right = Math.min(innerWidth, rect.right);
|
|
1097
|
+
let top = Math.max(0, rect.top), bottom = Math.min(innerHeight, rect.bottom);
|
|
1098
|
+
for (let parent = node.parentElement; parent; parent = parent.parentElement) {
|
|
1099
|
+
const style = getComputedStyle(parent), clip = parent.getBoundingClientRect();
|
|
1100
|
+
if (/(auto|scroll|hidden|clip)/.test(style.overflowX)) {
|
|
1101
|
+
left = Math.max(left, clip.left);
|
|
1102
|
+
right = Math.min(right, clip.right);
|
|
1103
|
+
}
|
|
1104
|
+
if (/(auto|scroll|hidden|clip)/.test(style.overflowY)) {
|
|
1105
|
+
top = Math.max(top, clip.top);
|
|
1106
|
+
bottom = Math.min(bottom, clip.bottom);
|
|
1107
|
+
}
|
|
1108
|
+
}
|
|
1109
|
+
if (right <= left || bottom <= top) return null;
|
|
1110
|
+
}
|
|
1111
|
+
return { outcome: moved ? "moved" : node ? "already_visible" : "at_boundary", before, after };
|
|
1112
|
+
}, { element: element ?? null, offsets: command });
|
|
1113
|
+
await this.page.evaluate(() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve()))));
|
|
1114
|
+
if (!scroll) throw new ControlError("target_unreachable", "The referenced control remains outside the visible scroll area.");
|
|
1115
|
+
return { scroll };
|
|
1116
|
+
}
|
|
1117
|
+
}
|
|
1118
|
+
};
|
|
1119
|
+
|
|
1120
|
+
// ../../packages/crawler/src/index.ts
|
|
1121
|
+
import {
|
|
1122
|
+
chromium
|
|
1123
|
+
} from "playwright";
|
|
1124
|
+
var DEFAULT_BASE_URL = "http://127.0.0.1:5173";
|
|
1125
|
+
var DEFAULT_TIMEOUT_MS = 3e4;
|
|
1126
|
+
var DEFAULT_INTERACTIVE_LOGIN_TIMEOUT_MS = 5 * 6e4;
|
|
1127
|
+
var OPERATOR_PAGE_SELECTOR = '[data-operator-page="true"]';
|
|
1128
|
+
var MARKET_WATCH_SELECTOR = '[data-market-watch="true"]';
|
|
1129
|
+
var OPERATOR_LOGIN_GATE_SELECTOR = '[data-operator-login-gate="true"]';
|
|
1130
|
+
var OPERATOR_LOGIN_USERNAME_SELECTOR = "[data-operator-login-username]";
|
|
1131
|
+
var OPERATOR_LOGIN_PASSWORD_SELECTOR = "[data-operator-login-password]";
|
|
1132
|
+
var OPERATOR_LOGIN_CONFIRM_SELECTOR = "[data-operator-login-confirm]";
|
|
1133
|
+
var CREDENTIALS_STATUS_SELECTOR = "[data-credentials-status]";
|
|
1134
|
+
var ZERODHA_LOGIN_PAGE_SELECTOR = '[data-zerodha-login-page="true"]';
|
|
1135
|
+
var OPERATOR_LOGIN_ATTEMPT_SELECTOR = "[data-operator-login-attempt]";
|
|
1136
|
+
var OPERATOR_LOGIN_STATUS_ATTRIBUTE = "data-operator-login-status";
|
|
1137
|
+
var OPERATOR_LOGIN_STATUS_MESSAGE_SELECTOR = '[data-operator-login-message-kind="status"]';
|
|
1138
|
+
var OPERATOR_LOGIN_ERROR_MESSAGE_SELECTOR = '[data-operator-login-message-kind="error"]';
|
|
1139
|
+
var OPERATOR_LOGIN_MESSAGE_ATTRIBUTE = "data-operator-login-message";
|
|
1140
|
+
var OPERATOR_LOGIN_OTP_SELECTOR = "[data-operator-login-otp]";
|
|
1141
|
+
var OPERATOR_LOGIN_OTP_SUBMIT_SELECTOR = "[data-operator-login-otp-submit]";
|
|
1142
|
+
var OPERATOR_LOGIN_CANCEL_SELECTOR = "[data-operator-login-cancel]";
|
|
1143
|
+
var AUTHENTICATED_OPERATOR_LOGOUT_SELECTOR = "[data-authenticated-operator-logout]";
|
|
1144
|
+
var UNAUTHENTICATED_OPERATOR_LOGOUT_SELECTOR = "[data-unauthenticated-operator-logout]";
|
|
1145
|
+
var SETTINGS_SELECTOR = "[data-zc-settings]";
|
|
1146
|
+
var OPERATOR_EGRESS_SELECTOR = "[data-operator-egress-value]";
|
|
1147
|
+
var PEEK_TIMEOUT_MS = 250;
|
|
1148
|
+
var MARKET_WATCH_LIVE_GRACE_MS = 3e3;
|
|
1149
|
+
function sleep(ms) {
|
|
1150
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
1151
|
+
}
|
|
1152
|
+
async function isAttached(page, selector, attributeName) {
|
|
1153
|
+
if (typeof page.waitForSelector === "function") {
|
|
1154
|
+
try {
|
|
1155
|
+
await page.waitForSelector(selector, { timeout: PEEK_TIMEOUT_MS });
|
|
1156
|
+
return true;
|
|
1157
|
+
} catch {
|
|
1158
|
+
return false;
|
|
1159
|
+
}
|
|
1160
|
+
}
|
|
1161
|
+
const value = await peekAttribute(page, selector, attributeName);
|
|
1162
|
+
return value !== null;
|
|
1163
|
+
}
|
|
1164
|
+
async function peekAttribute(page, selector, name) {
|
|
1165
|
+
try {
|
|
1166
|
+
return await page.locator(selector).getAttribute(name, { timeout: PEEK_TIMEOUT_MS });
|
|
1167
|
+
} catch {
|
|
1168
|
+
return null;
|
|
1169
|
+
}
|
|
1170
|
+
}
|
|
1171
|
+
function marketWatchRowSelector(sno) {
|
|
1172
|
+
return `[data-market-watch-row="${sno}"]`;
|
|
1173
|
+
}
|
|
1174
|
+
var MARKET_WATCH_PANEL_SELECTOR = "[data-market-watch-panel]";
|
|
1175
|
+
var MARKET_WATCH_ADD_INSTRUMENT_SELECTOR = "[data-market-watch-add-instrument]";
|
|
1176
|
+
function marketWatchPanelSelector(sno) {
|
|
1177
|
+
return `[data-market-watch-panel="${sno}"]`;
|
|
1178
|
+
}
|
|
1179
|
+
function marketWatchExchangeSelector(sno) {
|
|
1180
|
+
return `${marketWatchPanelSelector(sno)} [data-market-watch-exchange="${sno}"]`;
|
|
1181
|
+
}
|
|
1182
|
+
function marketWatchTradingSymbolSelector(sno) {
|
|
1183
|
+
return `${marketWatchPanelSelector(sno)} [data-market-watch-trading-symbol="${sno}"]`;
|
|
1184
|
+
}
|
|
1185
|
+
function marketWatchApplySelector(sno) {
|
|
1186
|
+
return `${marketWatchPanelSelector(sno)} [data-market-watch-apply="${sno}"]`;
|
|
1187
|
+
}
|
|
1188
|
+
function marketWatchPanelCancelSelector(sno) {
|
|
1189
|
+
return `${marketWatchPanelSelector(sno)} [data-market-watch-panel-cancel="${sno}"]`;
|
|
1190
|
+
}
|
|
1191
|
+
function marketWatchDraftErrorSelector(scope, sno) {
|
|
1192
|
+
return `${scope} [data-market-watch-draft-error="${sno}"]`;
|
|
1193
|
+
}
|
|
1194
|
+
function marketWatchMenuSelector(sno) {
|
|
1195
|
+
return `[data-market-watch-menu="${sno}"]`;
|
|
1196
|
+
}
|
|
1197
|
+
function marketWatchMenuListSelector(sno) {
|
|
1198
|
+
return `[data-market-watch-menu-list="${sno}"]`;
|
|
1199
|
+
}
|
|
1200
|
+
function marketWatchChangeSelector(sno) {
|
|
1201
|
+
return `[data-market-watch-change="${sno}"]`;
|
|
1202
|
+
}
|
|
1203
|
+
function marketWatchClearSelector(sno) {
|
|
1204
|
+
return `[data-market-watch-clear="${sno}"]`;
|
|
1205
|
+
}
|
|
1206
|
+
function marketWatchCommittedSelector(sno) {
|
|
1207
|
+
return `[data-market-watch-committed="${sno}"]`;
|
|
1208
|
+
}
|
|
1209
|
+
function marketWatchQuoteSelector(sno) {
|
|
1210
|
+
return `[data-market-watch-quote="${sno}"]`;
|
|
1211
|
+
}
|
|
1212
|
+
function marketWatchQuoteFieldSelector(sno, field) {
|
|
1213
|
+
return `${marketWatchQuoteSelector(sno)} [data-market-watch-quote-field="${field}"]`;
|
|
1214
|
+
}
|
|
1215
|
+
var QUOTE_FIELDS = {
|
|
1216
|
+
ltp: "lastPrice",
|
|
1217
|
+
open: "open",
|
|
1218
|
+
high: "high",
|
|
1219
|
+
low: "low",
|
|
1220
|
+
close: "close",
|
|
1221
|
+
volume: "volume",
|
|
1222
|
+
"percentage-change": "percentageChange"
|
|
1223
|
+
};
|
|
1224
|
+
function toMarketWatchRowStatus(raw) {
|
|
1225
|
+
switch (raw) {
|
|
1226
|
+
case "Empty":
|
|
1227
|
+
return "empty";
|
|
1228
|
+
case "Validating":
|
|
1229
|
+
return "validating";
|
|
1230
|
+
case "Live":
|
|
1231
|
+
return "live";
|
|
1232
|
+
case "Error":
|
|
1233
|
+
return "error";
|
|
1234
|
+
case "Configured":
|
|
1235
|
+
return "stopped";
|
|
1236
|
+
case null:
|
|
1237
|
+
throw new Error("Market Watch row did not expose a state");
|
|
1238
|
+
default:
|
|
1239
|
+
throw new Error(`Unknown Market Watch row state: ${raw}`);
|
|
1240
|
+
}
|
|
1241
|
+
}
|
|
1242
|
+
var PlaywrightOperatorPage = class {
|
|
1243
|
+
constructor(page, timeoutMs = DEFAULT_TIMEOUT_MS, interactiveLoginTimeoutMs = DEFAULT_INTERACTIVE_LOGIN_TIMEOUT_MS, headless = true, registerCleanup = () => void 0) {
|
|
1244
|
+
this.page = page;
|
|
1245
|
+
this.timeoutMs = timeoutMs;
|
|
1246
|
+
this.interactiveLoginTimeoutMs = interactiveLoginTimeoutMs;
|
|
1247
|
+
this.headless = headless;
|
|
1248
|
+
this.registerCleanup = registerCleanup;
|
|
1249
|
+
}
|
|
1250
|
+
page;
|
|
1251
|
+
timeoutMs;
|
|
1252
|
+
interactiveLoginTimeoutMs;
|
|
1253
|
+
headless;
|
|
1254
|
+
registerCleanup;
|
|
1255
|
+
async getOperatorInfo() {
|
|
1256
|
+
await this.page.waitForSelector?.(OPERATOR_PAGE_SELECTOR, {
|
|
1257
|
+
timeout: this.timeoutMs
|
|
1258
|
+
});
|
|
1259
|
+
const root = this.page.locator(OPERATOR_PAGE_SELECTOR);
|
|
1260
|
+
const userId = (await root.getAttribute("data-user-id"))?.trim() ?? "";
|
|
1261
|
+
const userName = (await root.getAttribute("data-user-name"))?.trim() ?? "";
|
|
1262
|
+
if (!userId) {
|
|
1263
|
+
throw new Error("Operator Page did not expose a user id");
|
|
1264
|
+
}
|
|
1265
|
+
return {
|
|
1266
|
+
userId,
|
|
1267
|
+
userName: userName || userId
|
|
1268
|
+
};
|
|
1269
|
+
}
|
|
1270
|
+
async getMarketWatchRowCount() {
|
|
1271
|
+
await this.page.waitForSelector?.(OPERATOR_PAGE_SELECTOR, {
|
|
1272
|
+
timeout: this.timeoutMs
|
|
1273
|
+
});
|
|
1274
|
+
const raw = await this.page.locator(MARKET_WATCH_SELECTOR).getAttribute("data-market-watch-row-count", { timeout: this.timeoutMs });
|
|
1275
|
+
const rowCount = Number(raw);
|
|
1276
|
+
if (!Number.isInteger(rowCount) || rowCount < 1) {
|
|
1277
|
+
throw new Error(`Market Watch exposed invalid row count: ${String(raw)}`);
|
|
1278
|
+
}
|
|
1279
|
+
return rowCount;
|
|
1280
|
+
}
|
|
1281
|
+
async getMarketWatchRowNumbers() {
|
|
1282
|
+
const rowCount = await this.getMarketWatchRowCount();
|
|
1283
|
+
return Array.from({ length: rowCount }, (_, index) => index + 1);
|
|
1284
|
+
}
|
|
1285
|
+
async getMarketWatchRow(sno) {
|
|
1286
|
+
await this.waitForMarketWatch();
|
|
1287
|
+
const rowSelector = marketWatchRowSelector(sno);
|
|
1288
|
+
const listed = await isAttached(this.page, rowSelector, "data-market-watch-row-state");
|
|
1289
|
+
const editing = await this.panelTargets(sno);
|
|
1290
|
+
const draftError = (await peekAttribute(
|
|
1291
|
+
this.page,
|
|
1292
|
+
marketWatchDraftErrorSelector(editing ? marketWatchPanelSelector(sno) : rowSelector, sno),
|
|
1293
|
+
"data-market-watch-draft-error-message"
|
|
1294
|
+
))?.trim();
|
|
1295
|
+
const draft = editing ? {
|
|
1296
|
+
exchange: await this.page.locator(marketWatchExchangeSelector(sno)).inputValue(),
|
|
1297
|
+
tradingSymbol: await this.page.locator(marketWatchTradingSymbolSelector(sno)).inputValue()
|
|
1298
|
+
} : void 0;
|
|
1299
|
+
if (!listed) {
|
|
1300
|
+
return {
|
|
1301
|
+
sno,
|
|
1302
|
+
exchange: draft?.exchange ?? "NSE",
|
|
1303
|
+
tradingSymbol: draft?.tradingSymbol ?? "",
|
|
1304
|
+
status: draftError ? "error" : "empty",
|
|
1305
|
+
...draftError ? { draftError } : {},
|
|
1306
|
+
failureMarked: Boolean(draftError)
|
|
1307
|
+
};
|
|
1308
|
+
}
|
|
1309
|
+
const row = this.page.locator(rowSelector);
|
|
1310
|
+
const committed = this.page.locator(marketWatchCommittedSelector(sno));
|
|
1311
|
+
const committedAttribute = (name2) => committed.getAttribute(name2, { timeout: PEEK_TIMEOUT_MS }).catch(() => null);
|
|
1312
|
+
const match = (await committedAttribute("data-market-watch-match"))?.trim();
|
|
1313
|
+
const token = (await committedAttribute("data-market-watch-token"))?.trim();
|
|
1314
|
+
const name = (await committedAttribute("data-market-watch-name"))?.trim();
|
|
1315
|
+
const segment = (await committedAttribute("data-market-watch-segment"))?.trim();
|
|
1316
|
+
const quote = match && token ? await this.readMarketWatchQuote(sno, token) : void 0;
|
|
1317
|
+
const separator = match?.indexOf(":") ?? -1;
|
|
1318
|
+
return {
|
|
1319
|
+
sno,
|
|
1320
|
+
exchange: draft?.exchange ?? (separator > 0 ? match.slice(0, separator) : ""),
|
|
1321
|
+
tradingSymbol: draft?.tradingSymbol ?? (separator > 0 ? match.slice(separator + 1) : ""),
|
|
1322
|
+
status: toMarketWatchRowStatus(
|
|
1323
|
+
await row.getAttribute("data-market-watch-row-state")
|
|
1324
|
+
),
|
|
1325
|
+
...draftError ? { draftError } : {},
|
|
1326
|
+
failureMarked: await row.getAttribute("data-market-watch-row-failure") === "true",
|
|
1327
|
+
...quote ? { quote } : {},
|
|
1328
|
+
...match && token ? {
|
|
1329
|
+
instrumentInfo: {
|
|
1330
|
+
match,
|
|
1331
|
+
token,
|
|
1332
|
+
name: name ?? "",
|
|
1333
|
+
segment: segment ?? ""
|
|
1334
|
+
},
|
|
1335
|
+
onTick: (listener) => {
|
|
1336
|
+
const unsubscribe = this.subscribeLiveTicks(sno, token, listener);
|
|
1337
|
+
this.registerCleanup(unsubscribe);
|
|
1338
|
+
return unsubscribe;
|
|
1339
|
+
}
|
|
1340
|
+
} : {}
|
|
1341
|
+
};
|
|
1342
|
+
}
|
|
1343
|
+
async setMarketWatchRow(sno, exchange, tradingSymbol) {
|
|
1344
|
+
await this.waitForMarketWatch();
|
|
1345
|
+
if (!await this.panelTargets(sno)) {
|
|
1346
|
+
if (await isAttached(this.page, marketWatchRowSelector(sno), "data-market-watch-row-state")) {
|
|
1347
|
+
await this.openRowMenu(sno);
|
|
1348
|
+
await this.page.locator(marketWatchChangeSelector(sno)).click();
|
|
1349
|
+
} else {
|
|
1350
|
+
await this.page.locator(MARKET_WATCH_ADD_INSTRUMENT_SELECTOR).click();
|
|
1351
|
+
}
|
|
1352
|
+
const target = await this.waitForPanel();
|
|
1353
|
+
if (target !== String(sno)) {
|
|
1354
|
+
await this.page.locator(marketWatchPanelCancelSelector(Number(target))).click();
|
|
1355
|
+
throw new Error(`Add Instrument targets slot ${target}, not ${sno}`);
|
|
1356
|
+
}
|
|
1357
|
+
}
|
|
1358
|
+
await this.page.locator(marketWatchExchangeSelector(sno)).selectOption(exchange.trim().toUpperCase());
|
|
1359
|
+
await this.page.locator(marketWatchTradingSymbolSelector(sno)).fill(tradingSymbol);
|
|
1360
|
+
}
|
|
1361
|
+
async applyMarketWatchRow(sno) {
|
|
1362
|
+
await this.waitForMarketWatch();
|
|
1363
|
+
await this.page.locator(marketWatchApplySelector(sno)).click();
|
|
1364
|
+
const rowState = () => peekAttribute(this.page, marketWatchRowSelector(sno), "data-market-watch-row-state");
|
|
1365
|
+
const deadline = Date.now() + this.timeoutMs;
|
|
1366
|
+
let configuredSince = null;
|
|
1367
|
+
while (Date.now() < deadline) {
|
|
1368
|
+
if (await isAttached(this.page, marketWatchDraftErrorSelector(marketWatchPanelSelector(sno), sno), "data-market-watch-draft-error-message")) {
|
|
1369
|
+
return;
|
|
1370
|
+
}
|
|
1371
|
+
if (!await this.panelTargets(sno)) {
|
|
1372
|
+
const state = await rowState();
|
|
1373
|
+
if (state === "Live" || state === "Error") return;
|
|
1374
|
+
if (state === "Configured") {
|
|
1375
|
+
configuredSince ??= Date.now();
|
|
1376
|
+
if (Date.now() - configuredSince >= MARKET_WATCH_LIVE_GRACE_MS) return;
|
|
1377
|
+
} else {
|
|
1378
|
+
configuredSince = null;
|
|
1379
|
+
}
|
|
1380
|
+
}
|
|
1381
|
+
await sleep(50);
|
|
1382
|
+
}
|
|
1383
|
+
if (configuredSince !== null && !await this.panelTargets(sno) && await rowState() === "Configured") return;
|
|
1384
|
+
throw new Error(`Timed out waiting for Market Watch row ${sno} state`);
|
|
1385
|
+
}
|
|
1386
|
+
async clearMarketWatchRow(sno) {
|
|
1387
|
+
await this.waitForMarketWatch();
|
|
1388
|
+
await this.openRowMenu(sno);
|
|
1389
|
+
await this.page.locator(marketWatchClearSelector(sno)).click();
|
|
1390
|
+
await this.waitForAttribute(
|
|
1391
|
+
marketWatchRowSelector(sno),
|
|
1392
|
+
"data-market-watch-row-state",
|
|
1393
|
+
(state) => state === null,
|
|
1394
|
+
`Market Watch row ${sno} to be cleared`
|
|
1395
|
+
);
|
|
1396
|
+
}
|
|
1397
|
+
/** Market Watch drafts are loaded once the container exposes its row count. */
|
|
1398
|
+
async waitForMarketWatch() {
|
|
1399
|
+
await this.page.waitForSelector?.(OPERATOR_PAGE_SELECTOR, {
|
|
1400
|
+
timeout: this.timeoutMs
|
|
1401
|
+
});
|
|
1402
|
+
await this.waitForAttribute(
|
|
1403
|
+
MARKET_WATCH_SELECTOR,
|
|
1404
|
+
"data-market-watch-row-count",
|
|
1405
|
+
(value) => Number(value) >= 1,
|
|
1406
|
+
"Market Watch rows"
|
|
1407
|
+
);
|
|
1408
|
+
}
|
|
1409
|
+
async panelTargets(sno) {
|
|
1410
|
+
return await peekAttribute(this.page, MARKET_WATCH_PANEL_SELECTOR, "data-market-watch-panel") === String(sno);
|
|
1411
|
+
}
|
|
1412
|
+
async waitForPanel() {
|
|
1413
|
+
let target = null;
|
|
1414
|
+
await this.waitForAttribute(
|
|
1415
|
+
MARKET_WATCH_PANEL_SELECTOR,
|
|
1416
|
+
"data-market-watch-panel",
|
|
1417
|
+
(value) => (target = value) !== null,
|
|
1418
|
+
"the Market Watch instrument panel"
|
|
1419
|
+
);
|
|
1420
|
+
return target;
|
|
1421
|
+
}
|
|
1422
|
+
async openRowMenu(sno) {
|
|
1423
|
+
if (!await isAttached(this.page, marketWatchMenuListSelector(sno), "data-market-watch-menu-list")) {
|
|
1424
|
+
await this.page.locator(marketWatchMenuSelector(sno)).click();
|
|
1425
|
+
}
|
|
1426
|
+
}
|
|
1427
|
+
async refresh() {
|
|
1428
|
+
await this.page.reload();
|
|
1429
|
+
await this.page.waitForSelector?.(OPERATOR_PAGE_SELECTOR, {
|
|
1430
|
+
timeout: this.timeoutMs
|
|
1431
|
+
});
|
|
1432
|
+
await this.page.waitForSelector?.(MARKET_WATCH_SELECTOR, {
|
|
1433
|
+
timeout: this.timeoutMs
|
|
1434
|
+
});
|
|
1435
|
+
}
|
|
1436
|
+
async waitForAttribute(selector, attribute, predicate, description) {
|
|
1437
|
+
const element = this.page.locator(selector);
|
|
1438
|
+
const deadline = Date.now() + this.timeoutMs;
|
|
1439
|
+
while (Date.now() < deadline) {
|
|
1440
|
+
const value = await element.getAttribute(attribute, { timeout: PEEK_TIMEOUT_MS }).catch(() => null);
|
|
1441
|
+
if (predicate(value)) {
|
|
1442
|
+
return;
|
|
1443
|
+
}
|
|
1444
|
+
await sleep(50);
|
|
1445
|
+
}
|
|
1446
|
+
throw new Error(`Timed out waiting for ${description}`);
|
|
1447
|
+
}
|
|
1448
|
+
async readMarketWatchQuote(sno, fallbackToken) {
|
|
1449
|
+
const root = this.page.locator(marketWatchQuoteSelector(sno));
|
|
1450
|
+
const receivedAt = await root.getAttribute("data-market-watch-quote-received-at", {
|
|
1451
|
+
timeout: PEEK_TIMEOUT_MS
|
|
1452
|
+
}).catch(() => null);
|
|
1453
|
+
if (!receivedAt) return void 0;
|
|
1454
|
+
const receivedOn = new Date(receivedAt);
|
|
1455
|
+
if (Number.isNaN(receivedOn.getTime())) {
|
|
1456
|
+
throw new Error(`Market Watch row ${sno} exposed an invalid quote timestamp`);
|
|
1457
|
+
}
|
|
1458
|
+
const exchangeTimestampRaw = await root.getAttribute("data-market-watch-quote-exchange-time", {
|
|
1459
|
+
timeout: PEEK_TIMEOUT_MS
|
|
1460
|
+
}).catch(() => null);
|
|
1461
|
+
const exchangeTimestamp = exchangeTimestampRaw ? new Date(exchangeTimestampRaw) : void 0;
|
|
1462
|
+
if (exchangeTimestamp && Number.isNaN(exchangeTimestamp.getTime())) {
|
|
1463
|
+
throw new Error(
|
|
1464
|
+
`Market Watch row ${sno} exposed an invalid exchange tick timestamp`
|
|
1465
|
+
);
|
|
1466
|
+
}
|
|
1467
|
+
const timestampStatusRaw = await root.getAttribute("data-market-watch-quote-timestamp-status", {
|
|
1468
|
+
timeout: PEEK_TIMEOUT_MS
|
|
1469
|
+
}).catch(() => null);
|
|
1470
|
+
const missingTimestampCountRaw = await root.getAttribute("data-market-watch-quote-missing-timestamp-count", {
|
|
1471
|
+
timeout: PEEK_TIMEOUT_MS
|
|
1472
|
+
}).catch(() => null);
|
|
1473
|
+
const updateTypeRaw = await root.getAttribute("data-market-watch-quote-update-type", {
|
|
1474
|
+
timeout: PEEK_TIMEOUT_MS
|
|
1475
|
+
}).catch(() => null);
|
|
1476
|
+
const missingTimestampCount = Number(missingTimestampCountRaw);
|
|
1477
|
+
if (missingTimestampCountRaw !== null && (!Number.isInteger(missingTimestampCount) || missingTimestampCount < 0)) {
|
|
1478
|
+
throw new Error(
|
|
1479
|
+
`Market Watch row ${sno} exposed an invalid missing timestamp count`
|
|
1480
|
+
);
|
|
1481
|
+
}
|
|
1482
|
+
return {
|
|
1483
|
+
receivedAt: receivedOn,
|
|
1484
|
+
token: (await root.getAttribute("data-market-watch-quote-token", {
|
|
1485
|
+
timeout: PEEK_TIMEOUT_MS
|
|
1486
|
+
}).catch(() => null))?.trim() || fallbackToken,
|
|
1487
|
+
mode: (await root.getAttribute("data-market-watch-quote-mode", {
|
|
1488
|
+
timeout: PEEK_TIMEOUT_MS
|
|
1489
|
+
}).catch(() => null))?.trim() || "full",
|
|
1490
|
+
...updateTypeRaw === "liveTick" || updateTypeRaw === "quoteSnapshot" || updateTypeRaw === "reconnectSnapshot" ? { latestUpdateType: updateTypeRaw } : {},
|
|
1491
|
+
...exchangeTimestamp ? { exchangeTimestamp } : {},
|
|
1492
|
+
...timestampStatusRaw === "available" || timestampStatusRaw === "missing" || timestampStatusRaw === "invalid" ? { exchangeTimestampStatus: timestampStatusRaw } : {},
|
|
1493
|
+
...missingTimestampCountRaw !== null ? { missingExchangeTimestampCount: missingTimestampCount } : {},
|
|
1494
|
+
fields: await this.readQuoteFields(sno)
|
|
1495
|
+
};
|
|
1496
|
+
}
|
|
1497
|
+
async readQuoteFields(sno) {
|
|
1498
|
+
const fields = {};
|
|
1499
|
+
for (const [domField, property] of Object.entries(QUOTE_FIELDS)) {
|
|
1500
|
+
const raw = await this.page.locator(marketWatchQuoteFieldSelector(sno, domField)).getAttribute("data-market-watch-quote-value", {
|
|
1501
|
+
timeout: PEEK_TIMEOUT_MS
|
|
1502
|
+
}).catch(() => null);
|
|
1503
|
+
if (raw === null || raw.trim() === "") continue;
|
|
1504
|
+
const value = Number(raw);
|
|
1505
|
+
if (!Number.isFinite(value)) {
|
|
1506
|
+
throw new Error(
|
|
1507
|
+
`Market Watch row ${sno} exposed a non-numeric ${domField} quote value`
|
|
1508
|
+
);
|
|
1509
|
+
}
|
|
1510
|
+
fields[property] = value;
|
|
1511
|
+
}
|
|
1512
|
+
return fields;
|
|
1513
|
+
}
|
|
1514
|
+
subscribeLiveTicks(sno, expectedToken, listener) {
|
|
1515
|
+
let stopped = false;
|
|
1516
|
+
let lastFingerprint = "";
|
|
1517
|
+
const poll = async () => {
|
|
1518
|
+
while (!stopped) {
|
|
1519
|
+
const quoteRoot = this.page.locator(marketWatchQuoteSelector(sno));
|
|
1520
|
+
const receivedOnRaw = await quoteRoot.getAttribute("data-market-watch-quote-received-at", {
|
|
1521
|
+
timeout: PEEK_TIMEOUT_MS
|
|
1522
|
+
}).catch(() => null);
|
|
1523
|
+
const token = await quoteRoot.getAttribute("data-market-watch-quote-token", {
|
|
1524
|
+
timeout: PEEK_TIMEOUT_MS
|
|
1525
|
+
}).catch(() => null);
|
|
1526
|
+
const mode = await quoteRoot.getAttribute("data-market-watch-quote-mode", {
|
|
1527
|
+
timeout: PEEK_TIMEOUT_MS
|
|
1528
|
+
}).catch(() => null);
|
|
1529
|
+
const exchangeTimestampRaw = await quoteRoot.getAttribute("data-market-watch-quote-exchange-time", {
|
|
1530
|
+
timeout: PEEK_TIMEOUT_MS
|
|
1531
|
+
}).catch(() => null);
|
|
1532
|
+
const timestampStatusRaw = await quoteRoot.getAttribute("data-market-watch-quote-timestamp-status", {
|
|
1533
|
+
timeout: PEEK_TIMEOUT_MS
|
|
1534
|
+
}).catch(() => null);
|
|
1535
|
+
const missingTimestampCountRaw = await quoteRoot.getAttribute("data-market-watch-quote-missing-timestamp-count", {
|
|
1536
|
+
timeout: PEEK_TIMEOUT_MS
|
|
1537
|
+
}).catch(() => null);
|
|
1538
|
+
const updateTypeRaw = await quoteRoot.getAttribute("data-market-watch-quote-update-type", {
|
|
1539
|
+
timeout: PEEK_TIMEOUT_MS
|
|
1540
|
+
}).catch(() => null);
|
|
1541
|
+
if (receivedOnRaw && token === expectedToken) {
|
|
1542
|
+
const actualMode = mode?.trim() || "";
|
|
1543
|
+
const fingerprint = `${receivedOnRaw}|${token}|${actualMode}|${updateTypeRaw ?? ""}|${exchangeTimestampRaw ?? ""}|${timestampStatusRaw ?? ""}`;
|
|
1544
|
+
if (fingerprint !== lastFingerprint) {
|
|
1545
|
+
lastFingerprint = fingerprint;
|
|
1546
|
+
const fields = await this.readQuoteFields(sno);
|
|
1547
|
+
const exchangeTimestamp = exchangeTimestampRaw ? new Date(exchangeTimestampRaw) : void 0;
|
|
1548
|
+
const missingExchangeTimestampCount = Number(
|
|
1549
|
+
missingTimestampCountRaw
|
|
1550
|
+
);
|
|
1551
|
+
const tick = {
|
|
1552
|
+
receivedOn: new Date(receivedOnRaw),
|
|
1553
|
+
token,
|
|
1554
|
+
mode: actualMode,
|
|
1555
|
+
...exchangeTimestamp && !Number.isNaN(exchangeTimestamp.getTime()) ? { exchangeTimestamp } : {},
|
|
1556
|
+
...timestampStatusRaw === "available" || timestampStatusRaw === "missing" || timestampStatusRaw === "invalid" ? { exchangeTimestampStatus: timestampStatusRaw } : {},
|
|
1557
|
+
...Number.isInteger(missingExchangeTimestampCount) && missingExchangeTimestampCount >= 0 ? { missingExchangeTimestampCount } : {},
|
|
1558
|
+
...fields
|
|
1559
|
+
};
|
|
1560
|
+
if (tick.mode === "full" && updateTypeRaw === "liveTick" && timestampStatusRaw === "available" && tick.exchangeTimestamp && typeof tick.lastPrice === "number" && Number.isFinite(tick.lastPrice) && tick.lastPrice > 0) {
|
|
1561
|
+
listener(tick);
|
|
1562
|
+
}
|
|
1563
|
+
}
|
|
1564
|
+
}
|
|
1565
|
+
await sleep(50);
|
|
1566
|
+
}
|
|
1567
|
+
};
|
|
1568
|
+
void poll();
|
|
1569
|
+
return () => {
|
|
1570
|
+
stopped = true;
|
|
1571
|
+
};
|
|
1572
|
+
}
|
|
1573
|
+
async clickLogoutZerodha(onSubmit = () => void 0) {
|
|
1574
|
+
await this.page.waitForSelector?.(OPERATOR_PAGE_SELECTOR, {
|
|
1575
|
+
timeout: this.timeoutMs
|
|
1576
|
+
});
|
|
1577
|
+
await this.page.locator("[data-operator-info-toggle]").click();
|
|
1578
|
+
onSubmit();
|
|
1579
|
+
await this.page.locator(AUTHENTICATED_OPERATOR_LOGOUT_SELECTOR).click();
|
|
1580
|
+
await this.page.waitForSelector?.(ZERODHA_LOGIN_PAGE_SELECTOR, {
|
|
1581
|
+
timeout: this.timeoutMs
|
|
1582
|
+
});
|
|
1583
|
+
return new PlaywrightZerodhaLoginPage(
|
|
1584
|
+
this.page,
|
|
1585
|
+
this.timeoutMs,
|
|
1586
|
+
this.interactiveLoginTimeoutMs,
|
|
1587
|
+
this.headless,
|
|
1588
|
+
this.registerCleanup
|
|
1589
|
+
);
|
|
1590
|
+
}
|
|
1591
|
+
};
|
|
1592
|
+
var LoginAttemptError = class extends Error {
|
|
1593
|
+
constructor(code, message) {
|
|
1594
|
+
super(message);
|
|
1595
|
+
this.code = code;
|
|
1596
|
+
}
|
|
1597
|
+
code;
|
|
1598
|
+
};
|
|
1599
|
+
var PlaywrightZerodhaLoginPage = class {
|
|
1600
|
+
constructor(page, timeoutMs = DEFAULT_TIMEOUT_MS, interactiveLoginTimeoutMs = DEFAULT_INTERACTIVE_LOGIN_TIMEOUT_MS, headless = true, registerCleanup = () => void 0) {
|
|
1601
|
+
this.page = page;
|
|
1602
|
+
this.timeoutMs = timeoutMs;
|
|
1603
|
+
this.interactiveLoginTimeoutMs = interactiveLoginTimeoutMs;
|
|
1604
|
+
this.headless = headless;
|
|
1605
|
+
this.registerCleanup = registerCleanup;
|
|
1606
|
+
}
|
|
1607
|
+
page;
|
|
1608
|
+
timeoutMs;
|
|
1609
|
+
interactiveLoginTimeoutMs;
|
|
1610
|
+
headless;
|
|
1611
|
+
registerCleanup;
|
|
1612
|
+
async getOperatorEgress() {
|
|
1613
|
+
await this.page.waitForSelector?.(ZERODHA_LOGIN_PAGE_SELECTOR, {
|
|
1614
|
+
timeout: this.timeoutMs
|
|
1615
|
+
});
|
|
1616
|
+
if (!await isAttached(
|
|
1617
|
+
this.page,
|
|
1618
|
+
OPERATOR_EGRESS_SELECTOR,
|
|
1619
|
+
"data-operator-egress-value"
|
|
1620
|
+
)) {
|
|
1621
|
+
await this.page.locator(SETTINGS_SELECTOR).click();
|
|
1622
|
+
}
|
|
1623
|
+
await this.page.waitForSelector?.(OPERATOR_EGRESS_SELECTOR, {
|
|
1624
|
+
timeout: this.timeoutMs
|
|
1625
|
+
});
|
|
1626
|
+
const field = this.page.locator(OPERATOR_EGRESS_SELECTOR);
|
|
1627
|
+
return {
|
|
1628
|
+
publicIp: await field.inputValue(),
|
|
1629
|
+
readOnly: await field.getAttribute("readonly") !== null
|
|
1630
|
+
};
|
|
1631
|
+
}
|
|
1632
|
+
async peekLoginStatus() {
|
|
1633
|
+
return peekAttribute(
|
|
1634
|
+
this.page,
|
|
1635
|
+
OPERATOR_LOGIN_ATTEMPT_SELECTOR,
|
|
1636
|
+
OPERATOR_LOGIN_STATUS_ATTRIBUTE
|
|
1637
|
+
);
|
|
1638
|
+
}
|
|
1639
|
+
async peekLoginMessage() {
|
|
1640
|
+
return await this.peekLoginStatusMessage() ?? await this.peekLoginErrorMessage();
|
|
1641
|
+
}
|
|
1642
|
+
async peekLoginStatusMessage() {
|
|
1643
|
+
const message = await peekAttribute(
|
|
1644
|
+
this.page,
|
|
1645
|
+
OPERATOR_LOGIN_STATUS_MESSAGE_SELECTOR,
|
|
1646
|
+
OPERATOR_LOGIN_MESSAGE_ATTRIBUTE
|
|
1647
|
+
);
|
|
1648
|
+
return message?.trim() || null;
|
|
1649
|
+
}
|
|
1650
|
+
async peekLoginErrorMessage() {
|
|
1651
|
+
const message = await peekAttribute(
|
|
1652
|
+
this.page,
|
|
1653
|
+
OPERATOR_LOGIN_ERROR_MESSAGE_SELECTOR,
|
|
1654
|
+
OPERATOR_LOGIN_MESSAGE_ATTRIBUTE
|
|
1655
|
+
);
|
|
1656
|
+
return message?.trim() || null;
|
|
1657
|
+
}
|
|
1658
|
+
async waitForLoginStatus(predicate, description) {
|
|
1659
|
+
const deadline = Date.now() + this.interactiveLoginTimeoutMs;
|
|
1660
|
+
while (Date.now() < deadline) {
|
|
1661
|
+
const status = await this.peekLoginStatus();
|
|
1662
|
+
if (predicate(status)) {
|
|
1663
|
+
return;
|
|
1664
|
+
}
|
|
1665
|
+
const message = await this.peekLoginMessage();
|
|
1666
|
+
if (status === "failed" || status === null && message) {
|
|
1667
|
+
throw new Error(message ?? "Zerodha Login Attempt failed.");
|
|
1668
|
+
}
|
|
1669
|
+
await sleep(50);
|
|
1670
|
+
}
|
|
1671
|
+
throw new Error(`Timed out waiting for ${description}`);
|
|
1672
|
+
}
|
|
1673
|
+
async waitForLoginAttempt() {
|
|
1674
|
+
const deadline = Date.now() + this.interactiveLoginTimeoutMs;
|
|
1675
|
+
while (Date.now() < deadline) {
|
|
1676
|
+
if (await isAttached(
|
|
1677
|
+
this.page,
|
|
1678
|
+
OPERATOR_LOGIN_ATTEMPT_SELECTOR,
|
|
1679
|
+
"data-operator-login-attempt"
|
|
1680
|
+
)) {
|
|
1681
|
+
return;
|
|
1682
|
+
}
|
|
1683
|
+
const message = await this.peekLoginMessage();
|
|
1684
|
+
if (message) {
|
|
1685
|
+
throw new Error(message);
|
|
1686
|
+
}
|
|
1687
|
+
await sleep(50);
|
|
1688
|
+
}
|
|
1689
|
+
throw new Error("Timed out waiting for the Zerodha Login Attempt");
|
|
1690
|
+
}
|
|
1691
|
+
/** Start Zerodha Login Attempt for this Operator Session (always OTP path). */
|
|
1692
|
+
async clickLoginWithZerodha() {
|
|
1693
|
+
await this.page.waitForSelector?.(ZERODHA_LOGIN_PAGE_SELECTOR, {
|
|
1694
|
+
timeout: this.timeoutMs
|
|
1695
|
+
});
|
|
1696
|
+
if (await isAttached(
|
|
1697
|
+
this.page,
|
|
1698
|
+
OPERATOR_LOGIN_ATTEMPT_SELECTOR,
|
|
1699
|
+
"data-operator-login-attempt"
|
|
1700
|
+
)) {
|
|
1701
|
+
return;
|
|
1702
|
+
}
|
|
1703
|
+
await this.page.locator(OPERATOR_LOGIN_CONFIRM_SELECTOR).click();
|
|
1704
|
+
await this.waitForLoginAttempt();
|
|
1705
|
+
}
|
|
1706
|
+
async enterOtp(otp) {
|
|
1707
|
+
await this.clickLoginWithZerodha();
|
|
1708
|
+
await this.waitForLoginStatus(
|
|
1709
|
+
(status) => status === "awaiting_otp",
|
|
1710
|
+
"the Zerodha OTP Challenge"
|
|
1711
|
+
);
|
|
1712
|
+
await this.page.locator(OPERATOR_LOGIN_OTP_SELECTOR).fill(otp);
|
|
1713
|
+
}
|
|
1714
|
+
async submitOtp() {
|
|
1715
|
+
const previousStatusMessage = await this.peekLoginStatusMessage();
|
|
1716
|
+
await this.page.locator(OPERATOR_LOGIN_OTP_SUBMIT_SELECTOR).click();
|
|
1717
|
+
const deadline = Date.now() + this.interactiveLoginTimeoutMs;
|
|
1718
|
+
while (Date.now() < deadline) {
|
|
1719
|
+
if (await isAttached(this.page, OPERATOR_PAGE_SELECTOR, "data-operator-page")) {
|
|
1720
|
+
return {
|
|
1721
|
+
status: "SUCCESS",
|
|
1722
|
+
operatorPage: new PlaywrightOperatorPage(
|
|
1723
|
+
this.page,
|
|
1724
|
+
this.timeoutMs,
|
|
1725
|
+
this.interactiveLoginTimeoutMs,
|
|
1726
|
+
this.headless,
|
|
1727
|
+
this.registerCleanup
|
|
1728
|
+
)
|
|
1729
|
+
};
|
|
1730
|
+
}
|
|
1731
|
+
const status = await this.peekLoginStatus();
|
|
1732
|
+
const errorMessage = await this.peekLoginErrorMessage();
|
|
1733
|
+
if (errorMessage) {
|
|
1734
|
+
return { status: "FAILED", message: errorMessage };
|
|
1735
|
+
}
|
|
1736
|
+
const statusMessage = await this.peekLoginStatusMessage();
|
|
1737
|
+
if (status === "failed") {
|
|
1738
|
+
return {
|
|
1739
|
+
status: "FAILED",
|
|
1740
|
+
message: statusMessage ?? "Zerodha Login Attempt failed."
|
|
1741
|
+
};
|
|
1742
|
+
}
|
|
1743
|
+
if (statusMessage && statusMessage !== previousStatusMessage) {
|
|
1744
|
+
return { status: "FAILED", message: statusMessage };
|
|
1745
|
+
}
|
|
1746
|
+
await sleep(50);
|
|
1747
|
+
}
|
|
1748
|
+
throw new Error("Timed out waiting for the Zerodha OTP submission result");
|
|
1749
|
+
}
|
|
1750
|
+
async observeLoginState() {
|
|
1751
|
+
if (!this.page.evaluate) throw new Error("Browser inspection is unavailable.");
|
|
1752
|
+
return this.page.evaluate(() => {
|
|
1753
|
+
const operatorPage = !!document.querySelector('[data-operator-page="true"]');
|
|
1754
|
+
const transitioning = !!document.querySelector('[data-session-loading], [data-zerodha-restoration="pending"]');
|
|
1755
|
+
const attempt = document.querySelector("[data-operator-login-attempt]");
|
|
1756
|
+
const attemptId = attempt?.getAttribute("data-operator-login-attempt-id");
|
|
1757
|
+
return { operatorPage, transitioning, attempt: !attempt || !attemptId ? null : {
|
|
1758
|
+
attemptId,
|
|
1759
|
+
status: attempt.getAttribute("data-operator-login-status") ?? "unavailable",
|
|
1760
|
+
failedOtpAttempts: Number(attempt.getAttribute("data-operator-login-failed-otp-attempts") ?? 0),
|
|
1761
|
+
submitting: attempt.getAttribute("data-operator-login-submitting-otp") === "true",
|
|
1762
|
+
message: document.querySelector('[data-operator-login-message-kind="error"]')?.getAttribute("data-operator-login-message") ?? null
|
|
1763
|
+
} };
|
|
1764
|
+
});
|
|
1765
|
+
}
|
|
1766
|
+
async observeLoginAttempt() {
|
|
1767
|
+
return (await this.observeLoginState()).attempt;
|
|
1768
|
+
}
|
|
1769
|
+
async startLoginAttempt(onSubmit = () => void 0) {
|
|
1770
|
+
const deadline = Date.now() + this.timeoutMs;
|
|
1771
|
+
const existing = await this.observeLoginAttempt();
|
|
1772
|
+
let observedId = existing?.attemptId;
|
|
1773
|
+
if (!existing) {
|
|
1774
|
+
await this.page.waitForSelector?.(ZERODHA_LOGIN_PAGE_SELECTOR, { timeout: this.timeoutMs });
|
|
1775
|
+
onSubmit();
|
|
1776
|
+
await this.page.locator(OPERATOR_LOGIN_CONFIRM_SELECTOR).click();
|
|
1777
|
+
}
|
|
1778
|
+
while (Date.now() < deadline) {
|
|
1779
|
+
const current = await this.observeLoginAttempt();
|
|
1780
|
+
if (observedId && current?.attemptId !== observedId) throw new LoginAttemptError("attempt_replaced", "The Zerodha Login Attempt was replaced. Inspect the page before starting another attempt.");
|
|
1781
|
+
observedId ??= current?.attemptId;
|
|
1782
|
+
if (current?.status === "awaiting_otp") return current;
|
|
1783
|
+
if (current && !["loading", "awaiting_otp", "succeeded", "failed"].includes(current.status)) throw new LoginAttemptError("attempt_unavailable", "The Zerodha Login Attempt is expired or unavailable. No replacement was started.");
|
|
1784
|
+
if (current?.status === "failed") throw new LoginAttemptError("attempt_failed", "The Zerodha Login Attempt failed. Inspect the visible error before starting another attempt.");
|
|
1785
|
+
await sleep(50);
|
|
1786
|
+
}
|
|
1787
|
+
throw new Error("Timed out waiting for the Zerodha OTP Challenge.");
|
|
1788
|
+
}
|
|
1789
|
+
/** Cancels the visible Zerodha Login Attempt through its Cancel login control; a page without one is left unchanged. */
|
|
1790
|
+
async cancelLoginAttempt(onSubmit = () => void 0) {
|
|
1791
|
+
const deadline = Date.now() + this.timeoutMs;
|
|
1792
|
+
const existing = await this.observeLoginAttempt();
|
|
1793
|
+
if (!existing) return;
|
|
1794
|
+
if (!this.page.evaluate) throw new Error("Browser inspection is unavailable.");
|
|
1795
|
+
const selector = `${OPERATOR_LOGIN_ATTEMPT_SELECTOR} ${OPERATOR_LOGIN_CANCEL_SELECTOR}`;
|
|
1796
|
+
const cancellable = await this.page.evaluate(() => {
|
|
1797
|
+
const button = document.querySelector("[data-operator-login-attempt] [data-operator-login-cancel]");
|
|
1798
|
+
return !!button && !button.disabled;
|
|
1799
|
+
});
|
|
1800
|
+
if (!cancellable) {
|
|
1801
|
+
throw new LoginAttemptError("attempt_not_cancellable", "The Zerodha Login Attempt has no Cancel login control in its current state. Inspect the page.");
|
|
1802
|
+
}
|
|
1803
|
+
onSubmit();
|
|
1804
|
+
await this.page.locator(selector).click();
|
|
1805
|
+
while (Date.now() < deadline) {
|
|
1806
|
+
const current = await this.observeLoginAttempt();
|
|
1807
|
+
if (current?.attemptId !== existing.attemptId) return;
|
|
1808
|
+
await sleep(50);
|
|
1809
|
+
}
|
|
1810
|
+
throw new Error("Timed out waiting for the Zerodha Login Attempt to be cancelled.");
|
|
1811
|
+
}
|
|
1812
|
+
async submitActiveOtp(attemptId, otp, onSubmit = () => void 0) {
|
|
1813
|
+
const deadline = Date.now() + this.timeoutMs;
|
|
1814
|
+
const assertActive = async () => {
|
|
1815
|
+
if (Date.now() >= deadline) throw new Error("OTP submission exceeded its deadline before submission.");
|
|
1816
|
+
const current = await this.observeLoginAttempt();
|
|
1817
|
+
if (!current) throw new LoginAttemptError("attempt_unavailable", "The requested OTP Challenge is unavailable. Inspect the page; no login was started.");
|
|
1818
|
+
if (current.attemptId !== attemptId) throw new LoginAttemptError("attempt_replaced", "The requested OTP Challenge has been replaced. No OTP was submitted.");
|
|
1819
|
+
if (current.status !== "awaiting_otp" || current.submitting) throw new LoginAttemptError("attempt_unavailable", "The requested OTP Challenge is expired, failed, or not accepting an OTP.");
|
|
1820
|
+
return current;
|
|
1821
|
+
};
|
|
1822
|
+
const before = await assertActive();
|
|
1823
|
+
const attemptSelector = `[data-operator-login-attempt-id=${JSON.stringify(attemptId)}]`;
|
|
1824
|
+
await this.page.locator(`${attemptSelector} ${OPERATOR_LOGIN_OTP_SELECTOR}`).fill(otp);
|
|
1825
|
+
await assertActive();
|
|
1826
|
+
onSubmit();
|
|
1827
|
+
await this.page.locator(`${attemptSelector} ${OPERATOR_LOGIN_OTP_SUBMIT_SELECTOR}`).click();
|
|
1828
|
+
while (Date.now() < deadline) {
|
|
1829
|
+
const state = await this.observeLoginState();
|
|
1830
|
+
if (state.operatorPage) {
|
|
1831
|
+
return { status: "SUCCESS", operatorPage: new PlaywrightOperatorPage(this.page, this.timeoutMs) };
|
|
1832
|
+
}
|
|
1833
|
+
const current = state.attempt;
|
|
1834
|
+
if (state.transitioning || current?.status === "succeeded") {
|
|
1835
|
+
await sleep(50);
|
|
1836
|
+
continue;
|
|
1837
|
+
}
|
|
1838
|
+
if (!current) throw new LoginAttemptError("attempt_unavailable", "The OTP Challenge disappeared after submission. Inspect the page before taking another action.");
|
|
1839
|
+
if (current.attemptId !== attemptId) throw new LoginAttemptError("attempt_replaced", "The OTP Challenge was replaced after submission. No retry was performed.");
|
|
1840
|
+
if (current.status === "failed") return { status: "FAILED", message: current.message ?? "Zerodha Login Attempt failed." };
|
|
1841
|
+
if (!current.submitting && current.failedOtpAttempts > before.failedOtpAttempts) return { status: "FAILED", message: current.message ?? "OTP rejected." };
|
|
1842
|
+
await sleep(50);
|
|
1843
|
+
}
|
|
1844
|
+
throw new Error("Timed out waiting for the OTP submission result. No retry was performed.");
|
|
1845
|
+
}
|
|
1846
|
+
async clickLogoutAccount(onSubmit = () => void 0) {
|
|
1847
|
+
await this.page.waitForSelector?.(ZERODHA_LOGIN_PAGE_SELECTOR, {
|
|
1848
|
+
timeout: this.timeoutMs
|
|
1849
|
+
});
|
|
1850
|
+
onSubmit();
|
|
1851
|
+
await this.page.locator(UNAUTHENTICATED_OPERATOR_LOGOUT_SELECTOR).click();
|
|
1852
|
+
await this.page.waitForSelector?.(OPERATOR_LOGIN_GATE_SELECTOR, {
|
|
1853
|
+
timeout: this.timeoutMs
|
|
1854
|
+
});
|
|
1855
|
+
return new PlaywrightHomePage(
|
|
1856
|
+
this.page,
|
|
1857
|
+
this.timeoutMs,
|
|
1858
|
+
this.interactiveLoginTimeoutMs,
|
|
1859
|
+
this.headless,
|
|
1860
|
+
this.registerCleanup
|
|
1861
|
+
);
|
|
1862
|
+
}
|
|
1863
|
+
};
|
|
1864
|
+
var PlaywrightHomePage = class {
|
|
1865
|
+
constructor(page, timeoutMs = DEFAULT_TIMEOUT_MS, interactiveLoginTimeoutMs = DEFAULT_INTERACTIVE_LOGIN_TIMEOUT_MS, headless = true, registerCleanup = () => void 0) {
|
|
1866
|
+
this.page = page;
|
|
1867
|
+
this.timeoutMs = timeoutMs;
|
|
1868
|
+
this.interactiveLoginTimeoutMs = interactiveLoginTimeoutMs;
|
|
1869
|
+
this.headless = headless;
|
|
1870
|
+
this.registerCleanup = registerCleanup;
|
|
1871
|
+
}
|
|
1872
|
+
page;
|
|
1873
|
+
timeoutMs;
|
|
1874
|
+
interactiveLoginTimeoutMs;
|
|
1875
|
+
headless;
|
|
1876
|
+
registerCleanup;
|
|
1877
|
+
async enterUsername(username) {
|
|
1878
|
+
await this.page.waitForSelector?.(OPERATOR_LOGIN_GATE_SELECTOR, {
|
|
1879
|
+
timeout: this.timeoutMs
|
|
1880
|
+
});
|
|
1881
|
+
await this.page.locator(OPERATOR_LOGIN_USERNAME_SELECTOR).fill(username);
|
|
1882
|
+
}
|
|
1883
|
+
async enterPassword(password) {
|
|
1884
|
+
await this.page.waitForSelector?.(OPERATOR_LOGIN_GATE_SELECTOR, {
|
|
1885
|
+
timeout: this.timeoutMs
|
|
1886
|
+
});
|
|
1887
|
+
await this.page.locator(OPERATOR_LOGIN_PASSWORD_SELECTOR).fill(password);
|
|
1888
|
+
}
|
|
1889
|
+
async clickConfirm() {
|
|
1890
|
+
await this.page.locator(OPERATOR_LOGIN_CONFIRM_SELECTOR).click();
|
|
1891
|
+
const deadline = Date.now() + this.timeoutMs;
|
|
1892
|
+
while (Date.now() < deadline) {
|
|
1893
|
+
const credentialsStatus = await peekAttribute(
|
|
1894
|
+
this.page,
|
|
1895
|
+
CREDENTIALS_STATUS_SELECTOR,
|
|
1896
|
+
"data-credentials-status"
|
|
1897
|
+
);
|
|
1898
|
+
if (credentialsStatus === "INCORRECT_CREDENTIALS") {
|
|
1899
|
+
return { status: "INCORRECT_CREDENTIALS" };
|
|
1900
|
+
}
|
|
1901
|
+
if (await isAttached(
|
|
1902
|
+
this.page,
|
|
1903
|
+
ZERODHA_LOGIN_PAGE_SELECTOR,
|
|
1904
|
+
"data-zerodha-login-page"
|
|
1905
|
+
)) {
|
|
1906
|
+
return {
|
|
1907
|
+
status: "SUCCESS",
|
|
1908
|
+
zerodhaLoginPage: new PlaywrightZerodhaLoginPage(
|
|
1909
|
+
this.page,
|
|
1910
|
+
this.timeoutMs,
|
|
1911
|
+
this.interactiveLoginTimeoutMs,
|
|
1912
|
+
this.headless,
|
|
1913
|
+
this.registerCleanup
|
|
1914
|
+
)
|
|
1915
|
+
};
|
|
1916
|
+
}
|
|
1917
|
+
await sleep(50);
|
|
1918
|
+
}
|
|
1919
|
+
throw new Error(
|
|
1920
|
+
"Timed out waiting for the Zerodha Login Page after Operator Login confirm"
|
|
1921
|
+
);
|
|
1922
|
+
}
|
|
1923
|
+
};
|
|
1924
|
+
var PlaywrightCrawler = class {
|
|
1925
|
+
constructor(browser, page, baseUrl, timeoutMs, interactiveLoginTimeoutMs, headless) {
|
|
1926
|
+
this.browser = browser;
|
|
1927
|
+
this.page = page;
|
|
1928
|
+
this.baseUrl = baseUrl;
|
|
1929
|
+
this.timeoutMs = timeoutMs;
|
|
1930
|
+
this.interactiveLoginTimeoutMs = interactiveLoginTimeoutMs;
|
|
1931
|
+
this.headless = headless;
|
|
1932
|
+
}
|
|
1933
|
+
browser;
|
|
1934
|
+
page;
|
|
1935
|
+
baseUrl;
|
|
1936
|
+
timeoutMs;
|
|
1937
|
+
interactiveLoginTimeoutMs;
|
|
1938
|
+
headless;
|
|
1939
|
+
cleanups = /* @__PURE__ */ new Set();
|
|
1940
|
+
pageControls;
|
|
1941
|
+
async openHomePage() {
|
|
1942
|
+
await this.page.goto(this.baseUrl);
|
|
1943
|
+
return new PlaywrightHomePage(
|
|
1944
|
+
this.page,
|
|
1945
|
+
this.timeoutMs,
|
|
1946
|
+
this.interactiveLoginTimeoutMs,
|
|
1947
|
+
this.headless,
|
|
1948
|
+
(cleanup) => this.cleanups.add(cleanup)
|
|
1949
|
+
);
|
|
1950
|
+
}
|
|
1951
|
+
controls() {
|
|
1952
|
+
return this.pageControls ??= new PageControls(this.page, this.baseUrl);
|
|
1953
|
+
}
|
|
1954
|
+
getPage() {
|
|
1955
|
+
return this.page;
|
|
1956
|
+
}
|
|
1957
|
+
async close() {
|
|
1958
|
+
for (const cleanup of this.cleanups) {
|
|
1959
|
+
cleanup();
|
|
1960
|
+
}
|
|
1961
|
+
this.cleanups.clear();
|
|
1962
|
+
await this.browser.close();
|
|
1963
|
+
}
|
|
1964
|
+
async forceClose() {
|
|
1965
|
+
if (this.browser.forceClose) {
|
|
1966
|
+
await this.browser.forceClose();
|
|
1967
|
+
return;
|
|
1968
|
+
}
|
|
1969
|
+
await this.browser.close();
|
|
1970
|
+
}
|
|
1971
|
+
};
|
|
1972
|
+
async function createCrawler(options = {}) {
|
|
1973
|
+
const headless = options.headless ?? true;
|
|
1974
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
1975
|
+
const launchTimeoutMs = options.launchTimeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
1976
|
+
const interactiveLoginTimeoutMs = options.interactiveLoginTimeoutMs ?? DEFAULT_INTERACTIVE_LOGIN_TIMEOUT_MS;
|
|
1977
|
+
const browser = await (options.launch ?? (() => chromium.launch({ headless, timeout: launchTimeoutMs })))();
|
|
1978
|
+
const page = await browser.newPage(options.contextOptions);
|
|
1979
|
+
page.setDefaultTimeout?.(timeoutMs);
|
|
1980
|
+
page.setDefaultNavigationTimeout?.(timeoutMs);
|
|
1981
|
+
return new PlaywrightCrawler(
|
|
1982
|
+
browser,
|
|
1983
|
+
page,
|
|
1984
|
+
options.baseUrl ?? DEFAULT_BASE_URL,
|
|
1985
|
+
timeoutMs,
|
|
1986
|
+
interactiveLoginTimeoutMs,
|
|
1987
|
+
headless
|
|
1988
|
+
);
|
|
1989
|
+
}
|
|
1990
|
+
|
|
1991
|
+
// ../cli/src/sms.ts
|
|
1992
|
+
import { spawn } from "node:child_process";
|
|
1993
|
+
var SmsReadError = class extends Error {
|
|
1994
|
+
constructor(code, message) {
|
|
1995
|
+
super(message);
|
|
1996
|
+
this.code = code;
|
|
1997
|
+
this.name = "SmsReadError";
|
|
1998
|
+
}
|
|
1999
|
+
code;
|
|
2000
|
+
};
|
|
2001
|
+
async function readSmsSecret(options) {
|
|
2002
|
+
if (!options.credential.trim()) {
|
|
2003
|
+
throw new SmsReadError("sms_credential_missing", "No protected SMS credential has been provisioned.");
|
|
2004
|
+
}
|
|
2005
|
+
if (!options.name.trim() || !options.scope.trim() || !options.endpoint.trim()) {
|
|
2006
|
+
throw new SmsReadError("sms_unavailable", "SMS configuration is incomplete.");
|
|
2007
|
+
}
|
|
2008
|
+
if (options.signal?.aborted) throw new SmsReadError("sms_unavailable", "SMS access was cancelled.");
|
|
2009
|
+
const command = options.command ?? "npx";
|
|
2010
|
+
const args = options.command ? ["read", options.name] : ["--yes", "--package=@ai-digital-factory/sms-cli@0.1.2", "sms", "read", options.name];
|
|
2011
|
+
return new Promise((resolve, reject) => {
|
|
2012
|
+
let child;
|
|
2013
|
+
try {
|
|
2014
|
+
child = spawn(command, args, {
|
|
2015
|
+
env: {
|
|
2016
|
+
...process.env,
|
|
2017
|
+
SMS_ENDPOINT: options.endpoint,
|
|
2018
|
+
SMS_CREDENTIAL: options.credential,
|
|
2019
|
+
SMS_SCOPE: options.scope
|
|
2020
|
+
},
|
|
2021
|
+
stdio: ["ignore", "pipe", "ignore"]
|
|
2022
|
+
});
|
|
2023
|
+
} catch {
|
|
2024
|
+
reject(new SmsReadError("sms_unavailable", "SMS access is unavailable."));
|
|
2025
|
+
return;
|
|
2026
|
+
}
|
|
2027
|
+
const chunks = [];
|
|
2028
|
+
let length = 0;
|
|
2029
|
+
let settled = false;
|
|
2030
|
+
const finish = (error, result) => {
|
|
2031
|
+
if (settled) return;
|
|
2032
|
+
settled = true;
|
|
2033
|
+
clearTimeout(timer);
|
|
2034
|
+
options.signal?.removeEventListener("abort", onAbort);
|
|
2035
|
+
if (error) reject(error);
|
|
2036
|
+
else resolve(result ?? "");
|
|
2037
|
+
};
|
|
2038
|
+
const onAbort = () => {
|
|
2039
|
+
child.kill("SIGTERM");
|
|
2040
|
+
finish(new SmsReadError("sms_unavailable", "SMS access exceeded the action deadline."));
|
|
2041
|
+
};
|
|
2042
|
+
const timer = setTimeout(() => {
|
|
2043
|
+
child.kill("SIGTERM");
|
|
2044
|
+
finish(new SmsReadError("sms_unavailable", "SMS access exceeded the action deadline."));
|
|
2045
|
+
}, options.timeoutMs ?? 3e4);
|
|
2046
|
+
options.signal?.addEventListener("abort", onAbort, { once: true });
|
|
2047
|
+
child.stdout.on("data", (chunk) => {
|
|
2048
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
2049
|
+
length += buffer.length;
|
|
2050
|
+
if (length > 1048576) {
|
|
2051
|
+
child.kill("SIGTERM");
|
|
2052
|
+
finish(new SmsReadError("sms_unavailable", "SMS returned an oversized secret record."));
|
|
2053
|
+
return;
|
|
2054
|
+
}
|
|
2055
|
+
chunks.push(buffer);
|
|
2056
|
+
});
|
|
2057
|
+
child.on("error", () => finish(new SmsReadError("sms_unavailable", "SMS access is unavailable.")));
|
|
2058
|
+
child.on("close", (code) => {
|
|
2059
|
+
if (code !== 0) {
|
|
2060
|
+
finish(new SmsReadError("sms_unavailable", "SMS access is unavailable for the selected Gate scope."));
|
|
2061
|
+
return;
|
|
2062
|
+
}
|
|
2063
|
+
const value = Buffer.concat(chunks).toString("utf8").trimEnd();
|
|
2064
|
+
if (!value.trim()) {
|
|
2065
|
+
finish(new SmsReadError("sms_unavailable", "SMS returned an empty secret record."));
|
|
2066
|
+
return;
|
|
2067
|
+
}
|
|
2068
|
+
finish(void 0, value);
|
|
2069
|
+
});
|
|
2070
|
+
});
|
|
2071
|
+
}
|
|
2072
|
+
async function readOperatorLoginCredentials(input) {
|
|
2073
|
+
let raw;
|
|
2074
|
+
try {
|
|
2075
|
+
raw = await (input.secretReader ?? readSmsSecret)({
|
|
2076
|
+
name: "live-market-terminal-operator-directory",
|
|
2077
|
+
scope: input.gateScope,
|
|
2078
|
+
endpoint: input.smsEndpoint,
|
|
2079
|
+
credential: input.smsCredential,
|
|
2080
|
+
...input.timeoutMs === void 0 ? {} : { timeoutMs: input.timeoutMs },
|
|
2081
|
+
...input.signal ? { signal: input.signal } : {},
|
|
2082
|
+
...input.command ? { command: input.command } : {}
|
|
2083
|
+
});
|
|
2084
|
+
} catch (error) {
|
|
2085
|
+
if (error instanceof SmsReadError) throw error;
|
|
2086
|
+
throw new SmsReadError("sms_unavailable", "Operator Directory is unavailable from the selected Gate scope.");
|
|
2087
|
+
}
|
|
2088
|
+
try {
|
|
2089
|
+
const parsed = JSON.parse(raw);
|
|
2090
|
+
if (!parsed || typeof parsed !== "object" || !("operators" in parsed) || !Array.isArray(parsed.operators)) {
|
|
2091
|
+
throw new Error();
|
|
2092
|
+
}
|
|
2093
|
+
const matches = parsed.operators.filter(
|
|
2094
|
+
(entry2) => entry2 && typeof entry2 === "object" && "operatorId" in entry2 && entry2.operatorId === input.operatorId
|
|
2095
|
+
);
|
|
2096
|
+
if (matches.length !== 1) throw new Error();
|
|
2097
|
+
const entry = matches[0];
|
|
2098
|
+
if (typeof entry.loginUsername !== "string" || !entry.loginUsername.trim() || typeof entry.loginPassword !== "string" || !entry.loginPassword.trim()) throw new Error();
|
|
2099
|
+
return { username: entry.loginUsername.trim(), password: entry.loginPassword };
|
|
2100
|
+
} catch {
|
|
2101
|
+
throw new SmsReadError("invalid_operator_directory", "The selected Gate Operator Directory is invalid or does not contain exactly one configured Operator.");
|
|
2102
|
+
}
|
|
2103
|
+
}
|
|
2104
|
+
|
|
2105
|
+
// src/operator.ts
|
|
2106
|
+
var OperatorActionError = class extends Error {
|
|
2107
|
+
constructor(code, message) {
|
|
2108
|
+
super(message);
|
|
2109
|
+
this.code = code;
|
|
2110
|
+
}
|
|
2111
|
+
code;
|
|
2112
|
+
};
|
|
2113
|
+
async function operatorLogin(input) {
|
|
2114
|
+
const deadlineAt = Date.now() + input.timeoutMs;
|
|
2115
|
+
const remaining = () => {
|
|
2116
|
+
const timeout = deadlineAt - Date.now();
|
|
2117
|
+
if (timeout < 1) throw new Error("Operator login exceeded its deadline before submission.");
|
|
2118
|
+
return timeout;
|
|
2119
|
+
};
|
|
2120
|
+
let target;
|
|
2121
|
+
try {
|
|
2122
|
+
target = resolveCliTarget({ LMT_ENV: input.environment }, process.env.LMT_WEB_CONFIG_PATH || DEFAULT_CONFIG_PATH2);
|
|
2123
|
+
if (target.origin !== input.origin) throw new Error("The configured Web URL changed. Start a new browser session.");
|
|
2124
|
+
} catch {
|
|
2125
|
+
throw new OperatorActionError("invalid_sms_configuration", "Check smsEndpoint, Gate scope, and OPERATOR_1 in the selected Web configuration, then start a fresh session.");
|
|
2126
|
+
}
|
|
2127
|
+
let credential;
|
|
2128
|
+
try {
|
|
2129
|
+
credential = await new CliProfileStore(void 0, void 0, ".lmt-web").getSmsCredential();
|
|
2130
|
+
} catch {
|
|
2131
|
+
throw new OperatorActionError("sms_credential_unavailable", "Could not read protected SMS storage. Provision the SMS read credential again.");
|
|
2132
|
+
}
|
|
2133
|
+
if (!credential) throw new OperatorActionError("sms_credential_missing", `Provision the SMS read credential with \`lmt-web credential provision --env ${input.environment} --credential-stdin\` before Operator login.`);
|
|
2134
|
+
input.registerSecrets(credential);
|
|
2135
|
+
let credentials;
|
|
2136
|
+
try {
|
|
2137
|
+
credentials = await readOperatorLoginCredentials({
|
|
2138
|
+
operatorId: target.operatorId,
|
|
2139
|
+
gateScope: target.gateScope,
|
|
2140
|
+
smsEndpoint: target.smsEndpoint,
|
|
2141
|
+
smsCredential: credential,
|
|
2142
|
+
timeoutMs: remaining(),
|
|
2143
|
+
...process.env.LMT_WEB_SMS_COMMAND ? { command: process.env.LMT_WEB_SMS_COMMAND } : {}
|
|
2144
|
+
});
|
|
2145
|
+
} catch (error) {
|
|
2146
|
+
throw new OperatorActionError(error instanceof SmsReadError ? error.code : "sms_unavailable", "Could not load Operator credentials from the selected Gate SMS scope. Check provisioning and SMS reachability.");
|
|
2147
|
+
}
|
|
2148
|
+
input.registerSecrets(credentials.username, credentials.password);
|
|
2149
|
+
const home = new PlaywrightHomePage(input.page, remaining());
|
|
2150
|
+
await home.enterUsername(credentials.username);
|
|
2151
|
+
await home.enterPassword(credentials.password);
|
|
2152
|
+
remaining();
|
|
2153
|
+
input.onSubmit?.();
|
|
2154
|
+
const result = await home.clickConfirm();
|
|
2155
|
+
if (result.status !== "SUCCESS") throw new OperatorActionError("incorrect_credentials", "The Home Page rejected the configured Operator credentials. Check the selected Gate Operator Directory.");
|
|
2156
|
+
}
|
|
2157
|
+
async function operatorLogout(page, timeoutMs, onSubmit = () => void 0) {
|
|
2158
|
+
const isOperatorPage = await page.evaluate?.(() => !!document.querySelector('[data-operator-page="true"]'));
|
|
2159
|
+
if (isOperatorPage) {
|
|
2160
|
+
await new PlaywrightOperatorPage(page, timeoutMs).clickLogoutZerodha(onSubmit);
|
|
2161
|
+
}
|
|
2162
|
+
await new PlaywrightZerodhaLoginPage(page, timeoutMs).clickLogoutAccount(onSubmit);
|
|
2163
|
+
}
|
|
2164
|
+
|
|
2165
|
+
// src/session.ts
|
|
2166
|
+
var SOCKET_DIRECTORY = path5.join(os2.tmpdir(), `lmt-web-${process.getuid?.() ?? "user"}`);
|
|
2167
|
+
var SESSION_ID_PATTERN = /^[a-f0-9]{32}$/;
|
|
2168
|
+
var OWNER_RESPONSE_GRACE_MS = 1e3;
|
|
2169
|
+
var OWNER_CLEANUP_GRACE_MS = 2e3;
|
|
2170
|
+
var IPHONE_13_USER_AGENT = "Mozilla/5.0 (iPhone; CPU iPhone OS 15_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.5 Mobile/15E148 Safari/604.1";
|
|
2171
|
+
function createSessionId() {
|
|
2172
|
+
return randomBytes2(16).toString("hex");
|
|
2173
|
+
}
|
|
2174
|
+
function isSessionId(value) {
|
|
2175
|
+
return !!value && SESSION_ID_PATTERN.test(value);
|
|
2176
|
+
}
|
|
2177
|
+
async function ensureSessionDirectory() {
|
|
2178
|
+
if (process.platform === "win32") {
|
|
2179
|
+
throw new Error("lmt-web session sockets are supported on macOS and Linux.");
|
|
2180
|
+
}
|
|
2181
|
+
await mkdir3(SOCKET_DIRECTORY, { recursive: true, mode: 448 });
|
|
2182
|
+
await chmod2(SOCKET_DIRECTORY, 448);
|
|
2183
|
+
const directory = await lstat3(SOCKET_DIRECTORY);
|
|
2184
|
+
const uid = process.getuid?.();
|
|
2185
|
+
if (!directory.isDirectory() || uid !== void 0 && directory.uid !== uid) {
|
|
2186
|
+
throw new Error("The lmt-web session directory is not owned by the invoking user.");
|
|
2187
|
+
}
|
|
2188
|
+
}
|
|
2189
|
+
function socketPathFor(sessionId) {
|
|
2190
|
+
return path5.join(SOCKET_DIRECTORY, `${sessionId}.sock`);
|
|
2191
|
+
}
|
|
2192
|
+
function artifactsDirectory(sessionId) {
|
|
2193
|
+
return path5.join(SOCKET_DIRECTORY, "artifacts", sessionId);
|
|
2194
|
+
}
|
|
2195
|
+
function sanitizeUrls(value) {
|
|
2196
|
+
return value.replace(/https?:\/\/[^\s)'"<>]+/gi, (candidate) => safeUrl(candidate) ?? "[url]");
|
|
2197
|
+
}
|
|
2198
|
+
function safeUrl(value) {
|
|
2199
|
+
try {
|
|
2200
|
+
const url = new URL(value);
|
|
2201
|
+
if (!/^https?:$/.test(url.protocol) || url.origin === "null") return null;
|
|
2202
|
+
return `${url.origin}${url.pathname}`;
|
|
2203
|
+
} catch {
|
|
2204
|
+
return null;
|
|
2205
|
+
}
|
|
2206
|
+
}
|
|
2207
|
+
function failureCode(error) {
|
|
2208
|
+
if (error instanceof SavedSessionError) return "saved_session_unavailable";
|
|
2209
|
+
if (error instanceof ControlError) return error.code;
|
|
2210
|
+
const message = error instanceof Error ? error.message : "";
|
|
2211
|
+
if (/timed out|timeout|deadline/i.test(message)) return "deadline_exceeded";
|
|
2212
|
+
if (/executable doesn't exist|browser.*not.*installed|could not find.*browser/i.test(message)) {
|
|
2213
|
+
return "browser_unavailable";
|
|
2214
|
+
}
|
|
2215
|
+
if (/net::ERR_(?:CONNECTION_REFUSED|CONNECTION_TIMED_OUT|NAME_NOT_RESOLVED|ADDRESS_UNREACHABLE|INTERNET_DISCONNECTED|EMPTY_RESPONSE)/i.test(message)) {
|
|
2216
|
+
return "service_unavailable";
|
|
2217
|
+
}
|
|
2218
|
+
return "action_failed";
|
|
2219
|
+
}
|
|
2220
|
+
function messageFor(error) {
|
|
2221
|
+
const message = error instanceof Error ? error.message : "The browser action failed.";
|
|
2222
|
+
return sanitizeUrls(message).slice(0, 500);
|
|
2223
|
+
}
|
|
2224
|
+
function deviceContext(mode) {
|
|
2225
|
+
if (mode === "mobile") {
|
|
2226
|
+
return {
|
|
2227
|
+
name: "iPhone 13",
|
|
2228
|
+
contextOptions: {
|
|
2229
|
+
viewport: { width: 390, height: 844 },
|
|
2230
|
+
deviceScaleFactor: 3,
|
|
2231
|
+
isMobile: true,
|
|
2232
|
+
hasTouch: true,
|
|
2233
|
+
userAgent: IPHONE_13_USER_AGENT
|
|
2234
|
+
}
|
|
2235
|
+
};
|
|
2236
|
+
}
|
|
2237
|
+
return {
|
|
2238
|
+
name: "Desktop",
|
|
2239
|
+
contextOptions: {
|
|
2240
|
+
viewport: { width: 1440, height: 900 },
|
|
2241
|
+
deviceScaleFactor: 1,
|
|
2242
|
+
isMobile: false,
|
|
2243
|
+
hasTouch: false
|
|
2244
|
+
}
|
|
2245
|
+
};
|
|
2246
|
+
}
|
|
2247
|
+
async function launchSessionBrowser(headless, deadlineAt, onServerReady, onBrowserClosed) {
|
|
2248
|
+
const browserServer = await chromium2.launchServer({
|
|
2249
|
+
headless,
|
|
2250
|
+
timeout: Math.max(1, deadlineAt - Date.now())
|
|
2251
|
+
});
|
|
2252
|
+
let forceClosePromise;
|
|
2253
|
+
const forceClose = () => {
|
|
2254
|
+
forceClosePromise ??= browserServer.kill();
|
|
2255
|
+
return forceClosePromise;
|
|
2256
|
+
};
|
|
2257
|
+
onServerReady(forceClose);
|
|
2258
|
+
try {
|
|
2259
|
+
const connectTimeoutMs = Math.max(1, deadlineAt - Date.now());
|
|
2260
|
+
if (deadlineAt <= Date.now()) throw new Error("The browser action exceeded its deadline.");
|
|
2261
|
+
const browser = await chromium2.connect(browserServer.wsEndpoint(), { timeout: connectTimeoutMs });
|
|
2262
|
+
browser.once("disconnected", onBrowserClosed);
|
|
2263
|
+
let closePromise;
|
|
2264
|
+
return {
|
|
2265
|
+
newPage: async (options) => {
|
|
2266
|
+
const page = await browser.newPage(options);
|
|
2267
|
+
page.once("close", onBrowserClosed);
|
|
2268
|
+
return page;
|
|
2269
|
+
},
|
|
2270
|
+
close: () => {
|
|
2271
|
+
closePromise ??= (async () => {
|
|
2272
|
+
let closeError;
|
|
2273
|
+
try {
|
|
2274
|
+
await browser.close();
|
|
2275
|
+
} catch (error) {
|
|
2276
|
+
closeError = error;
|
|
2277
|
+
}
|
|
2278
|
+
try {
|
|
2279
|
+
await browserServer.close();
|
|
2280
|
+
} catch (error) {
|
|
2281
|
+
closeError ??= error;
|
|
2282
|
+
}
|
|
2283
|
+
if (closeError) throw closeError;
|
|
2284
|
+
})();
|
|
2285
|
+
return closePromise;
|
|
2286
|
+
},
|
|
2287
|
+
forceClose
|
|
2288
|
+
};
|
|
2289
|
+
} catch (error) {
|
|
2290
|
+
await forceClose().catch(() => void 0);
|
|
2291
|
+
throw error;
|
|
2292
|
+
}
|
|
2293
|
+
}
|
|
2294
|
+
async function waitForRenderedPage(page) {
|
|
2295
|
+
if (!page.evaluate) throw new Error("The browser page does not support inspection.");
|
|
2296
|
+
let settled = false;
|
|
2297
|
+
while (true) {
|
|
2298
|
+
const ready = await page.evaluate(() => {
|
|
2299
|
+
const visible = (element) => {
|
|
2300
|
+
const rect = element.getBoundingClientRect();
|
|
2301
|
+
const style = getComputedStyle(element);
|
|
2302
|
+
return rect.width > 0 && rect.height > 0 && style.visibility !== "hidden" && style.display !== "none";
|
|
2303
|
+
};
|
|
2304
|
+
const loading = Array.from(document.querySelectorAll('[data-session-loading], [aria-busy="true"]')).some(visible);
|
|
2305
|
+
const animating = document.getAnimations().some(
|
|
2306
|
+
(animation) => (animation.pending || animation.playState === "running") && Number.isFinite(Number(animation.effect?.getComputedTiming().endTime))
|
|
2307
|
+
);
|
|
2308
|
+
const imagesPending = Array.from(document.images).some((image) => visible(image) && !image.complete);
|
|
2309
|
+
const hasContent = !!document.body?.innerText.trim() || Array.from(
|
|
2310
|
+
document.querySelectorAll('button, a[href], input:not([type="hidden"]), select, textarea, canvas, svg, img, video, iframe')
|
|
2311
|
+
).some(visible);
|
|
2312
|
+
return document.readyState === "complete" && document.fonts.status === "loaded" && hasContent && !loading && !animating && !imagesPending;
|
|
2313
|
+
});
|
|
2314
|
+
if (ready && settled) return;
|
|
2315
|
+
settled = ready;
|
|
2316
|
+
await wait(50);
|
|
2317
|
+
}
|
|
2318
|
+
}
|
|
2319
|
+
async function inspectPage(page, mode, crawler) {
|
|
2320
|
+
await waitForRenderedPage(page);
|
|
2321
|
+
if (!page.evaluate) throw new Error("The browser page does not support inspection.");
|
|
2322
|
+
const result = await page.evaluate(() => {
|
|
2323
|
+
const visible = (element) => {
|
|
2324
|
+
const rect = element.getBoundingClientRect();
|
|
2325
|
+
const style = getComputedStyle(element);
|
|
2326
|
+
return rect.width > 0 && rect.height > 0 && style.visibility !== "hidden" && style.display !== "none";
|
|
2327
|
+
};
|
|
2328
|
+
const hasVisible = (selector) => Array.from(document.querySelectorAll(selector)).some(visible);
|
|
2329
|
+
const operatorLogin2 = hasVisible('[data-operator-login-gate="true"]');
|
|
2330
|
+
const zerodhaLogin = hasVisible('[data-zerodha-login-page="true"]');
|
|
2331
|
+
const operatorPage = hasVisible('[data-operator-page="true"]');
|
|
2332
|
+
const restoring = hasVisible('[data-zerodha-restoration="pending"]');
|
|
2333
|
+
let authentication = { status: "unknown" };
|
|
2334
|
+
if (Number(operatorLogin2) + Number(zerodhaLogin) + Number(operatorPage) + Number(restoring) === 1 && !hasVisible('[data-session-loading], [aria-busy="true"]')) {
|
|
2335
|
+
if (operatorLogin2) authentication = { status: "operator_login_required", nextAction: "operator-login" };
|
|
2336
|
+
if (operatorPage) authentication = { status: "authenticated" };
|
|
2337
|
+
if (restoring) authentication = { status: "zerodha_session_restoring" };
|
|
2338
|
+
if (zerodhaLogin) {
|
|
2339
|
+
authentication = { status: "zerodha_login_required" };
|
|
2340
|
+
const attempt = document.querySelector('[data-zerodha-login-page="true"] [data-operator-login-attempt]');
|
|
2341
|
+
if (!attempt) authentication = { status: "zerodha_login_required", nextAction: "zerodha-login" };
|
|
2342
|
+
else if (visible(attempt) && attempt.getAttribute("data-operator-login-status") === "awaiting_otp" && attempt.getAttribute("data-operator-login-submitting-otp") !== "true") {
|
|
2343
|
+
const attemptId = attempt.getAttribute("data-operator-login-attempt-id");
|
|
2344
|
+
if (attemptId) authentication = { status: "zerodha_login_required", nextAction: "zerodha-otp", attemptId };
|
|
2345
|
+
}
|
|
2346
|
+
}
|
|
2347
|
+
}
|
|
2348
|
+
const controls2 = Array.from(
|
|
2349
|
+
document.querySelectorAll("button, a[href], input, select, textarea, [role=button]")
|
|
2350
|
+
).filter(visible).slice(0, 100).map((element, index) => {
|
|
2351
|
+
const labels = element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement || element instanceof HTMLSelectElement ? Array.from(element.labels ?? []).map((item) => item.innerText).join(" ") : "";
|
|
2352
|
+
const label = element.getAttribute("aria-label") || element.getAttribute("title") || labels || (element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement ? element.placeholder : "") || (element instanceof HTMLSelectElement ? element.selectedOptions[0]?.innerText : "") || element.textContent || "";
|
|
2353
|
+
const kind = element.getAttribute("role") || element.tagName.toLowerCase();
|
|
2354
|
+
return {
|
|
2355
|
+
reference: `control-${index + 1}`,
|
|
2356
|
+
kind,
|
|
2357
|
+
label: label.replace(/\s+/g, " ").trim().slice(0, 160)
|
|
2358
|
+
};
|
|
2359
|
+
});
|
|
2360
|
+
return {
|
|
2361
|
+
authentication,
|
|
2362
|
+
title: document.title,
|
|
2363
|
+
text: document.body?.innerText ?? "",
|
|
2364
|
+
controls: controls2,
|
|
2365
|
+
viewport: { width: window.innerWidth, height: window.innerHeight },
|
|
2366
|
+
deviceScaleFactor: window.devicePixelRatio,
|
|
2367
|
+
hasTouch: navigator.maxTouchPoints > 0,
|
|
2368
|
+
userAgent: navigator.userAgent
|
|
2369
|
+
};
|
|
2370
|
+
});
|
|
2371
|
+
const controls = crawler ? await crawler.controls().inspect() : result.controls;
|
|
2372
|
+
const redact = (value) => sanitizeUrls(crawler?.controls().redact(value) ?? value);
|
|
2373
|
+
const viewport = page.viewportSize?.() ?? result.viewport;
|
|
2374
|
+
return {
|
|
2375
|
+
authentication: "attemptId" in result.authentication ? { ...result.authentication, attemptId: redact(result.authentication.attemptId) } : result.authentication,
|
|
2376
|
+
url: safeUrl(redact(page.url())),
|
|
2377
|
+
visibleState: {
|
|
2378
|
+
title: redact(result.title).slice(0, 300),
|
|
2379
|
+
text: redact(result.text).slice(0, 4e3),
|
|
2380
|
+
controls: controls.map((control) => ({ ...control, label: redact(control.label) }))
|
|
2381
|
+
},
|
|
2382
|
+
device: {
|
|
2383
|
+
name: deviceContext(mode).name,
|
|
2384
|
+
mode,
|
|
2385
|
+
viewport,
|
|
2386
|
+
deviceScaleFactor: result.deviceScaleFactor,
|
|
2387
|
+
hasTouch: result.hasTouch,
|
|
2388
|
+
userAgent: sanitizeUrls(result.userAgent).slice(0, 300)
|
|
2389
|
+
}
|
|
2390
|
+
};
|
|
2391
|
+
}
|
|
2392
|
+
var ACCESS_TOKEN_SCREENSHOT_MASK = '[data-lmt-sensitive="zerodha-access-token"]';
|
|
2393
|
+
async function screenshotMaskOptions(page) {
|
|
2394
|
+
const playwrightPage = page;
|
|
2395
|
+
const locator = playwrightPage.locator(ACCESS_TOKEN_SCREENSHOT_MASK);
|
|
2396
|
+
if (!await locator.count()) return {};
|
|
2397
|
+
return { mask: [locator], maskColor: "#000000" };
|
|
2398
|
+
}
|
|
2399
|
+
async function captureScreenshot(page, sessionId, action, fullPage, timeoutMs, onTimeout, crawler) {
|
|
2400
|
+
if (!page) return { outcome: "unavailable", reason: "No browser page was available." };
|
|
2401
|
+
if (!page.screenshot) return { outcome: "failed", error: "The browser does not support screenshots." };
|
|
2402
|
+
const directory = artifactsDirectory(sessionId);
|
|
2403
|
+
const filename = `${Date.now()}-${action}-${randomBytes2(4).toString("hex")}.png`;
|
|
2404
|
+
const screenshotPath = path5.resolve(directory, filename);
|
|
2405
|
+
if (timeoutMs < 1) {
|
|
2406
|
+
return { outcome: "failed", error: "The action deadline elapsed before screenshot capture." };
|
|
2407
|
+
}
|
|
2408
|
+
try {
|
|
2409
|
+
await mkdir3(directory, { recursive: true, mode: 448 });
|
|
2410
|
+
await chmod2(directory, 448);
|
|
2411
|
+
await withDeadline(
|
|
2412
|
+
(async () => {
|
|
2413
|
+
await waitForRenderedPage(page);
|
|
2414
|
+
const maskOptions = crawler ? { mask: await crawler.controls().screenshotMasks(), maskColor: "#000000" } : await screenshotMaskOptions(page);
|
|
2415
|
+
const options = {
|
|
2416
|
+
path: screenshotPath,
|
|
2417
|
+
fullPage,
|
|
2418
|
+
animations: "disabled",
|
|
2419
|
+
timeout: timeoutMs,
|
|
2420
|
+
...maskOptions
|
|
2421
|
+
};
|
|
2422
|
+
return fullPage ? captureFullContent(page, options) : page.screenshot(options);
|
|
2423
|
+
})(),
|
|
2424
|
+
timeoutMs,
|
|
2425
|
+
onTimeout
|
|
2426
|
+
);
|
|
2427
|
+
await chmod2(screenshotPath, 384);
|
|
2428
|
+
return { outcome: "captured", path: screenshotPath, fullPage };
|
|
2429
|
+
} catch (error) {
|
|
2430
|
+
await unlink2(screenshotPath).catch(() => void 0);
|
|
2431
|
+
return { outcome: "failed", error: messageFor(error) };
|
|
2432
|
+
}
|
|
2433
|
+
}
|
|
2434
|
+
function remainingMs(deadlineAt) {
|
|
2435
|
+
return Math.max(0, deadlineAt - Date.now());
|
|
2436
|
+
}
|
|
2437
|
+
function withDeadline(promise, timeoutMs, onTimeout) {
|
|
2438
|
+
return new Promise((resolve, reject) => {
|
|
2439
|
+
let settled = false;
|
|
2440
|
+
const finish = (callback) => {
|
|
2441
|
+
if (settled) return;
|
|
2442
|
+
settled = true;
|
|
2443
|
+
clearTimeout(timeout);
|
|
2444
|
+
callback();
|
|
2445
|
+
};
|
|
2446
|
+
const timeout = setTimeout(() => {
|
|
2447
|
+
onTimeout?.();
|
|
2448
|
+
finish(() => reject(new Error("The browser action exceeded its deadline.")));
|
|
2449
|
+
}, Math.max(1, timeoutMs));
|
|
2450
|
+
promise.then(
|
|
2451
|
+
(value) => finish(() => resolve(value)),
|
|
2452
|
+
(error) => finish(() => reject(error))
|
|
2453
|
+
);
|
|
2454
|
+
});
|
|
2455
|
+
}
|
|
2456
|
+
async function runSessionOwner(options) {
|
|
2457
|
+
await ensureSessionDirectory();
|
|
2458
|
+
const socketPath = socketPathFor(options.sessionId);
|
|
2459
|
+
const device = deviceContext(options.mode);
|
|
2460
|
+
const saved = options.persist ? await SavedSession.open({ environment: options.environment ?? "local", origin: options.origin, mode: options.mode }) : void 0;
|
|
2461
|
+
const storageState = saved?.load();
|
|
2462
|
+
let checkpointQueue = Promise.resolve();
|
|
2463
|
+
let checkpointError;
|
|
2464
|
+
function checkpoint() {
|
|
2465
|
+
if (!saved || !page) return Promise.resolve();
|
|
2466
|
+
const context = page.context();
|
|
2467
|
+
const save = async () => {
|
|
2468
|
+
const cookies = await context.cookies(options.origin);
|
|
2469
|
+
await saved.save({ cookies, origins: [] });
|
|
2470
|
+
};
|
|
2471
|
+
checkpointQueue = checkpointQueue.then(save, save);
|
|
2472
|
+
checkpointQueue.catch(() => {
|
|
2473
|
+
checkpointError = new SavedSessionError();
|
|
2474
|
+
});
|
|
2475
|
+
return checkpointQueue;
|
|
2476
|
+
}
|
|
2477
|
+
let crawler;
|
|
2478
|
+
let page;
|
|
2479
|
+
let startupError;
|
|
2480
|
+
let startComplete = false;
|
|
2481
|
+
let closing = false;
|
|
2482
|
+
let abortOnShutdown = false;
|
|
2483
|
+
let operationQueue = Promise.resolve();
|
|
2484
|
+
let shutdownPromise;
|
|
2485
|
+
let initializationPromise;
|
|
2486
|
+
let startupLease;
|
|
2487
|
+
let crawlerClosePromise;
|
|
2488
|
+
let expirationCleanup;
|
|
2489
|
+
let forceClosePendingBrowser;
|
|
2490
|
+
let pendingBrowserForceClosePromise;
|
|
2491
|
+
const sockets = /* @__PURE__ */ new Set();
|
|
2492
|
+
function closeCrawler() {
|
|
2493
|
+
if (!crawler) return Promise.resolve();
|
|
2494
|
+
crawlerClosePromise ??= crawler.close();
|
|
2495
|
+
return crawlerClosePromise;
|
|
2496
|
+
}
|
|
2497
|
+
function forceCloseBrowser() {
|
|
2498
|
+
if (crawler?.forceClose) return crawler.forceClose();
|
|
2499
|
+
if (!forceClosePendingBrowser) return Promise.resolve();
|
|
2500
|
+
pendingBrowserForceClosePromise ??= forceClosePendingBrowser();
|
|
2501
|
+
return pendingBrowserForceClosePromise;
|
|
2502
|
+
}
|
|
2503
|
+
function expireSession() {
|
|
2504
|
+
closing = true;
|
|
2505
|
+
abortOnShutdown = true;
|
|
2506
|
+
expirationCleanup ??= (async () => {
|
|
2507
|
+
if (initializationPromise) {
|
|
2508
|
+
await Promise.race([
|
|
2509
|
+
initializationPromise,
|
|
2510
|
+
wait(OWNER_CLEANUP_GRACE_MS)
|
|
2511
|
+
]).catch(() => void 0);
|
|
2512
|
+
}
|
|
2513
|
+
if (crawler) await closeCrawler();
|
|
2514
|
+
else await forceCloseBrowser();
|
|
2515
|
+
})();
|
|
2516
|
+
void expirationCleanup.catch(() => void 0);
|
|
2517
|
+
}
|
|
2518
|
+
const server = net.createServer((socket) => {
|
|
2519
|
+
sockets.add(socket);
|
|
2520
|
+
socket.once("close", () => sockets.delete(socket));
|
|
2521
|
+
let payload = "";
|
|
2522
|
+
let handled = false;
|
|
2523
|
+
socket.on("data", (chunk) => {
|
|
2524
|
+
if (handled) return;
|
|
2525
|
+
payload += chunk.toString("utf8");
|
|
2526
|
+
const newline = payload.indexOf("\n");
|
|
2527
|
+
if (newline < 0) return;
|
|
2528
|
+
handled = true;
|
|
2529
|
+
const line = payload.slice(0, newline);
|
|
2530
|
+
let request;
|
|
2531
|
+
try {
|
|
2532
|
+
request = JSON.parse(line);
|
|
2533
|
+
} catch {
|
|
2534
|
+
socket.end(`${JSON.stringify(failureResult("inspect", "invalid_request", "The session request was invalid.", options.sessionId))}
|
|
2535
|
+
`);
|
|
2536
|
+
return;
|
|
2537
|
+
}
|
|
2538
|
+
const run = async () => {
|
|
2539
|
+
let response;
|
|
2540
|
+
try {
|
|
2541
|
+
response = await handleRequest(request);
|
|
2542
|
+
} catch (error) {
|
|
2543
|
+
response = failureResult(request.action, error instanceof SavedSessionError ? "saved_session_unavailable" : "action_failed", "The browser action could not complete safely. Inspect before retrying.", options.sessionId);
|
|
2544
|
+
}
|
|
2545
|
+
if (isAction(request.action) && isControlAction(request)) {
|
|
2546
|
+
response.audit = { outcome: "unavailable" };
|
|
2547
|
+
if (page?.evaluate && new URL(page.url()).origin === options.origin) {
|
|
2548
|
+
try {
|
|
2549
|
+
const recorded = await withDeadline(page.evaluate(async (event) => {
|
|
2550
|
+
const response2 = await fetch("/api/journey-records", {
|
|
2551
|
+
method: "POST",
|
|
2552
|
+
headers: { "content-type": "application/json" },
|
|
2553
|
+
body: JSON.stringify({ records: [{
|
|
2554
|
+
eventType: "journey_step",
|
|
2555
|
+
eventValue: `Web CLI ${event.action}`,
|
|
2556
|
+
session: event.session,
|
|
2557
|
+
eventMeta: {
|
|
2558
|
+
step: `Web CLI ${event.action}`,
|
|
2559
|
+
outcome: event.outcome === "success" ? "success" : "failure",
|
|
2560
|
+
...event.outcome === "success" ? {} : { error_code: event.outcome === "uncertain" ? "action_uncertain" : "browser_action_failed" },
|
|
2561
|
+
web_visit: event.session,
|
|
2562
|
+
journey_id: event.session,
|
|
2563
|
+
client_kind: "cli",
|
|
2564
|
+
client_profile: event.profile,
|
|
2565
|
+
action_outcome: event.outcome,
|
|
2566
|
+
screenshot_outcome: event.screenshot
|
|
2567
|
+
}
|
|
2568
|
+
}] }),
|
|
2569
|
+
signal: AbortSignal.timeout(500)
|
|
2570
|
+
});
|
|
2571
|
+
return response2.status === 202;
|
|
2572
|
+
}, { action: request.action, session: options.sessionId, profile: `web-cli-${options.mode}`, outcome: response.outcome, screenshot: response.screenshot.outcome }), 750);
|
|
2573
|
+
response.audit = { outcome: recorded ? "recorded" : "failed" };
|
|
2574
|
+
} catch {
|
|
2575
|
+
response.audit = { outcome: "failed" };
|
|
2576
|
+
}
|
|
2577
|
+
}
|
|
2578
|
+
}
|
|
2579
|
+
socket.end(`${JSON.stringify(response)}
|
|
2580
|
+
`);
|
|
2581
|
+
if (closing) socket.once("close", () => void shutdown());
|
|
2582
|
+
};
|
|
2583
|
+
operationQueue = operationQueue.then(run, run).catch(() => void 0);
|
|
2584
|
+
});
|
|
2585
|
+
});
|
|
2586
|
+
async function initialize(deadlineAt) {
|
|
2587
|
+
try {
|
|
2588
|
+
crawler = await createCrawler({
|
|
2589
|
+
baseUrl: options.origin,
|
|
2590
|
+
headless: options.headless,
|
|
2591
|
+
timeoutMs: Math.min(options.timeoutMs, Math.max(1, remainingMs(deadlineAt))),
|
|
2592
|
+
contextOptions: { ...device.contextOptions, ...storageState ? { storageState } : {} },
|
|
2593
|
+
launch: () => {
|
|
2594
|
+
const onServerReady = (forceClose) => {
|
|
2595
|
+
forceClosePendingBrowser = forceClose;
|
|
2596
|
+
if (abortOnShutdown) void forceCloseBrowser().catch(() => void 0);
|
|
2597
|
+
};
|
|
2598
|
+
return options.launch ? options.launch(deadlineAt, onServerReady) : launchSessionBrowser(options.headless, deadlineAt, onServerReady, () => {
|
|
2599
|
+
if (closing) return;
|
|
2600
|
+
closing = true;
|
|
2601
|
+
abortOnShutdown = true;
|
|
2602
|
+
void shutdown();
|
|
2603
|
+
});
|
|
2604
|
+
}
|
|
2605
|
+
});
|
|
2606
|
+
page = crawler.getPage();
|
|
2607
|
+
const browserContext = page.context?.();
|
|
2608
|
+
if (browserContext?.route) await browserContext.route("**/*", async (route) => {
|
|
2609
|
+
const request = route.request();
|
|
2610
|
+
if (new URL(request.url()).origin !== options.origin) return route.continue();
|
|
2611
|
+
await route.continue({ headers: { ...request.headers(), "x-lmt-client-kind": "cli", "x-lmt-client-profile": `web-cli-${options.mode}` } });
|
|
2612
|
+
});
|
|
2613
|
+
if (saved) {
|
|
2614
|
+
page.context().on("requestfinished", (request) => {
|
|
2615
|
+
if (new URL(request.url()).origin !== options.origin) return;
|
|
2616
|
+
void request.response().then(async (response) => {
|
|
2617
|
+
const headers = await response?.allHeaders();
|
|
2618
|
+
if (headers?.["set-cookie"] && !closing) await checkpoint();
|
|
2619
|
+
}).catch(() => {
|
|
2620
|
+
checkpointError = new SavedSessionError();
|
|
2621
|
+
});
|
|
2622
|
+
});
|
|
2623
|
+
}
|
|
2624
|
+
if (closing) {
|
|
2625
|
+
await closeCrawler();
|
|
2626
|
+
return;
|
|
2627
|
+
}
|
|
2628
|
+
await crawler.openHomePage();
|
|
2629
|
+
await withDeadline(checkpoint(), remainingMs(deadlineAt), expireSession);
|
|
2630
|
+
} catch (error) {
|
|
2631
|
+
startupError = error;
|
|
2632
|
+
}
|
|
2633
|
+
}
|
|
2634
|
+
async function handleRequest(request) {
|
|
2635
|
+
if (!request || request.sessionId !== options.sessionId || !isAction(request.action) || !Number.isFinite(request.timeoutMs) || request.timeoutMs < 1 || !Number.isFinite(request.deadlineAt)) {
|
|
2636
|
+
return failureResult("inspect", "invalid_request", "The session request was invalid.", options.sessionId);
|
|
2637
|
+
}
|
|
2638
|
+
if (closing) return failureResult(request.action, "session_unavailable", "The browser owner is closing. Start to restore the saved session.", options.sessionId);
|
|
2639
|
+
const deadlineAt = request.deadlineAt;
|
|
2640
|
+
if (remainingMs(deadlineAt) === 0) {
|
|
2641
|
+
return failureResult(request.action, "deadline_exceeded", "The browser action exceeded its deadline.", options.sessionId);
|
|
2642
|
+
}
|
|
2643
|
+
if (request.action === "start" && startComplete) {
|
|
2644
|
+
return { ...await handleRequest({ ...request, action: "inspect" }), action: "start" };
|
|
2645
|
+
}
|
|
2646
|
+
if (request.action === "stop") {
|
|
2647
|
+
await withDeadline(checkpoint(), Math.min(500, Math.max(1, remainingMs(deadlineAt)))).catch(() => void 0);
|
|
2648
|
+
closing = true;
|
|
2649
|
+
abortOnShutdown = true;
|
|
2650
|
+
try {
|
|
2651
|
+
crawlerClosePromise = forceCloseBrowser();
|
|
2652
|
+
await withDeadline(crawlerClosePromise, remainingMs(deadlineAt), expireSession);
|
|
2653
|
+
return {
|
|
2654
|
+
version: RESULT_VERSION,
|
|
2655
|
+
sessionId: options.sessionId,
|
|
2656
|
+
action: "stop",
|
|
2657
|
+
outcome: "success",
|
|
2658
|
+
authentication: { status: "unknown" },
|
|
2659
|
+
url: null,
|
|
2660
|
+
visibleState: null,
|
|
2661
|
+
device: null,
|
|
2662
|
+
screenshot: { outcome: "unavailable", reason: "Stop terminates the browser without capturing a screenshot." }
|
|
2663
|
+
};
|
|
2664
|
+
} catch (error2) {
|
|
2665
|
+
return failureResult("stop", failureCode(error2), messageFor(error2), options.sessionId);
|
|
2666
|
+
}
|
|
2667
|
+
}
|
|
2668
|
+
if (!startComplete && request.action !== "start") {
|
|
2669
|
+
return failureResult(request.action, "session_starting", "The browser session is not ready yet.", options.sessionId);
|
|
2670
|
+
}
|
|
2671
|
+
if (checkpointError) throw checkpointError;
|
|
2672
|
+
if (!startComplete) {
|
|
2673
|
+
clearTimeout(startupLease);
|
|
2674
|
+
initializationPromise ??= initialize(deadlineAt);
|
|
2675
|
+
try {
|
|
2676
|
+
await withDeadline(initializationPromise, remainingMs(deadlineAt), expireSession);
|
|
2677
|
+
} catch (error2) {
|
|
2678
|
+
startupError = error2;
|
|
2679
|
+
}
|
|
2680
|
+
}
|
|
2681
|
+
if (request.action === "start") {
|
|
2682
|
+
startComplete = true;
|
|
2683
|
+
let inspectionError;
|
|
2684
|
+
const inspected2 = page && !startupError && remainingMs(deadlineAt) > 0 ? await withDeadline(inspectPage(page, options.mode, crawler), remainingMs(deadlineAt), expireSession).catch((error2) => {
|
|
2685
|
+
inspectionError = error2;
|
|
2686
|
+
return null;
|
|
2687
|
+
}) : null;
|
|
2688
|
+
const screenshot2 = startupError || inspectionError ? { outcome: "unavailable", reason: "The page did not finish loading and inspection." } : await captureScreenshot(
|
|
2689
|
+
page,
|
|
2690
|
+
options.sessionId,
|
|
2691
|
+
"start",
|
|
2692
|
+
false,
|
|
2693
|
+
remainingMs(deadlineAt),
|
|
2694
|
+
expireSession,
|
|
2695
|
+
crawler
|
|
2696
|
+
);
|
|
2697
|
+
const actionError2 = startupError ?? inspectionError;
|
|
2698
|
+
const result = {
|
|
2699
|
+
version: RESULT_VERSION,
|
|
2700
|
+
sessionId: options.sessionId,
|
|
2701
|
+
action: "start",
|
|
2702
|
+
outcome: actionError2 ? "failure" : "success",
|
|
2703
|
+
authentication: inspected2?.authentication ?? { status: "unknown" },
|
|
2704
|
+
url: inspected2?.url ?? (startupError ? safeUrl(options.origin) : page ? safeUrl(crawler?.controls().redact(page.url()) ?? page.url()) : null),
|
|
2705
|
+
visibleState: inspected2?.visibleState ?? null,
|
|
2706
|
+
device: inspected2?.device ?? null,
|
|
2707
|
+
screenshot: screenshot2,
|
|
2708
|
+
...startupError ? {
|
|
2709
|
+
error: {
|
|
2710
|
+
code: failureCode(startupError),
|
|
2711
|
+
message: page ? failureCode(startupError) === "service_unavailable" ? "Could not load the configured Web URL. Confirm that the selected service is running and reachable." : failureCode(startupError) === "deadline_exceeded" ? "Opening the Home Page exceeded its deadline. Start a fresh session and retry." : messageFor(startupError) : failureCode(startupError) === "browser_unavailable" ? "Could not launch the browser. Install its Chromium runtime with `lmt-web install-browser` and retry." : messageFor(startupError)
|
|
2712
|
+
}
|
|
2713
|
+
} : inspectionError ? {
|
|
2714
|
+
error: {
|
|
2715
|
+
code: failureCode(inspectionError),
|
|
2716
|
+
message: "The Home Page loaded but its visible state could not be inspected."
|
|
2717
|
+
}
|
|
2718
|
+
} : {}
|
|
2719
|
+
};
|
|
2720
|
+
if (startupError) {
|
|
2721
|
+
closing = true;
|
|
2722
|
+
}
|
|
2723
|
+
return result;
|
|
2724
|
+
}
|
|
2725
|
+
if (startupError || !crawler || !page) {
|
|
2726
|
+
return failureResult(request.action, "session_unavailable", "The browser session is no longer available. Start a new session.", options.sessionId);
|
|
2727
|
+
}
|
|
2728
|
+
let guidedError;
|
|
2729
|
+
let submitted = false;
|
|
2730
|
+
const actionDeadlineAt = deadlineAt - Math.min(1500, Math.floor(remainingMs(deadlineAt) / 2));
|
|
2731
|
+
const pendingActions = /* @__PURE__ */ new Set();
|
|
2732
|
+
const runAction = (operation, timeoutMs) => {
|
|
2733
|
+
pendingActions.add(operation);
|
|
2734
|
+
operation.then(() => pendingActions.delete(operation), () => pendingActions.delete(operation));
|
|
2735
|
+
return withDeadline(operation, timeoutMs);
|
|
2736
|
+
};
|
|
2737
|
+
const actionTimeout = () => Math.max(1, remainingMs(actionDeadlineAt));
|
|
2738
|
+
page.setDefaultTimeout?.(actionTimeout());
|
|
2739
|
+
page.setDefaultNavigationTimeout?.(actionTimeout());
|
|
2740
|
+
let loginAttempt;
|
|
2741
|
+
const onSubmit = () => {
|
|
2742
|
+
submitted = true;
|
|
2743
|
+
};
|
|
2744
|
+
const zerodha = new PlaywrightZerodhaLoginPage(page, actionTimeout());
|
|
2745
|
+
try {
|
|
2746
|
+
if (request.action === "operator-login") await runAction(operatorLogin({ page, environment: options.environment ?? "local", origin: options.origin, timeoutMs: actionTimeout(), onSubmit, registerSecrets: (...values) => crawler.controls().registerSecrets(...values) }), actionTimeout());
|
|
2747
|
+
if (request.action === "operator-logout") await runAction(operatorLogout(page, actionTimeout(), onSubmit), actionTimeout());
|
|
2748
|
+
if (request.action === "zerodha-login") loginAttempt = await runAction(zerodha.startLoginAttempt(onSubmit), actionTimeout());
|
|
2749
|
+
if (request.action === "zerodha-cancel") await runAction(zerodha.cancelLoginAttempt(onSubmit), actionTimeout());
|
|
2750
|
+
if (request.action === "zerodha-otp") {
|
|
2751
|
+
if (!request.attemptId || !request.otp || !/^\d{6}$/.test(request.otp)) throw new OperatorActionError("invalid_otp", "Supply a six-digit OTP and the attempt ID returned by zerodha-login.");
|
|
2752
|
+
crawler.controls().registerSecrets(request.otp);
|
|
2753
|
+
const result = await runAction(zerodha.submitActiveOtp(request.attemptId, request.otp, onSubmit), actionTimeout());
|
|
2754
|
+
if (result.status !== "SUCCESS") throw new OperatorActionError("otp_rejected", result.message);
|
|
2755
|
+
}
|
|
2756
|
+
if (request.action === "zerodha-logout") await runAction(new PlaywrightOperatorPage(page, actionTimeout()).clickLogoutZerodha(onSubmit), actionTimeout());
|
|
2757
|
+
} catch (error2) {
|
|
2758
|
+
guidedError = error2;
|
|
2759
|
+
}
|
|
2760
|
+
let inspected = null;
|
|
2761
|
+
let controlResult;
|
|
2762
|
+
let actionError;
|
|
2763
|
+
try {
|
|
2764
|
+
if (isControlAction(request)) {
|
|
2765
|
+
const budget = Math.max(1, remainingMs(deadlineAt) - Math.min(2e3, request.timeoutMs / 4));
|
|
2766
|
+
controlResult = await runAction(crawler.controls().act(request, budget, () => {
|
|
2767
|
+
submitted = true;
|
|
2768
|
+
}), budget);
|
|
2769
|
+
}
|
|
2770
|
+
} catch (error2) {
|
|
2771
|
+
actionError = request.action === "click" && submitted && failureCode(error2) === "deadline_exceeded" ? new ControlError("action_uncertain", "The click may have been submitted but its completion was not confirmed. Inspect before deciding whether to retry.") : error2;
|
|
2772
|
+
if (request.action === "click" && failureCode(actionError) === "action_uncertain") {
|
|
2773
|
+
await withDeadline(crawler.controls().stopLoading(), Math.min(500, remainingMs(deadlineAt))).catch(() => void 0);
|
|
2774
|
+
}
|
|
2775
|
+
}
|
|
2776
|
+
if (pendingActions.size || failureCode(actionError ?? guidedError) === "deadline_exceeded") {
|
|
2777
|
+
try {
|
|
2778
|
+
await withDeadline((async () => {
|
|
2779
|
+
await crawler.controls().stopLoading();
|
|
2780
|
+
await Promise.allSettled([...pendingActions]);
|
|
2781
|
+
await page.evaluate?.(() => document.readyState);
|
|
2782
|
+
})(), Math.min(500, Math.max(1, remainingMs(deadlineAt))), expireSession);
|
|
2783
|
+
} catch {
|
|
2784
|
+
expireSession();
|
|
2785
|
+
return { ...failureResult(request.action, submitted ? "action_uncertain" : "deadline_exceeded", "The browser could not safely recover from the action deadline. Start to restore the saved session; do not replay an uncertain action.", options.sessionId), outcome: submitted ? "uncertain" : "failure" };
|
|
2786
|
+
}
|
|
2787
|
+
}
|
|
2788
|
+
if (saved) {
|
|
2789
|
+
try {
|
|
2790
|
+
await withDeadline(checkpoint(), Math.max(1, remainingMs(deadlineAt)), expireSession);
|
|
2791
|
+
} catch (error2) {
|
|
2792
|
+
actionError ??= error2;
|
|
2793
|
+
}
|
|
2794
|
+
}
|
|
2795
|
+
try {
|
|
2796
|
+
inspected = await withDeadline(inspectPage(page, options.mode, crawler), remainingMs(deadlineAt), expireSession);
|
|
2797
|
+
} catch (error2) {
|
|
2798
|
+
actionError ??= error2;
|
|
2799
|
+
}
|
|
2800
|
+
const screenshot = await captureScreenshot(
|
|
2801
|
+
page,
|
|
2802
|
+
options.sessionId,
|
|
2803
|
+
request.action,
|
|
2804
|
+
request.fullPage === true,
|
|
2805
|
+
remainingMs(deadlineAt),
|
|
2806
|
+
expireSession,
|
|
2807
|
+
crawler
|
|
2808
|
+
);
|
|
2809
|
+
let closeError;
|
|
2810
|
+
if (request.action === "close") {
|
|
2811
|
+
closing = true;
|
|
2812
|
+
try {
|
|
2813
|
+
await withDeadline(closeCrawler(), remainingMs(deadlineAt), expireSession);
|
|
2814
|
+
} catch (error2) {
|
|
2815
|
+
closeError = error2;
|
|
2816
|
+
if (!abortOnShutdown) {
|
|
2817
|
+
await crawler.forceClose?.().catch(() => void 0);
|
|
2818
|
+
}
|
|
2819
|
+
}
|
|
2820
|
+
}
|
|
2821
|
+
const error = guidedError ?? actionError ?? closeError;
|
|
2822
|
+
return {
|
|
2823
|
+
version: RESULT_VERSION,
|
|
2824
|
+
sessionId: options.sessionId,
|
|
2825
|
+
action: request.action,
|
|
2826
|
+
outcome: error instanceof ControlError && error.code === "action_uncertain" ? "uncertain" : error ? submitted && !(error instanceof OperatorActionError) && !(error instanceof LoginAttemptError) ? "uncertain" : "failure" : "success",
|
|
2827
|
+
authentication: inspected?.authentication ?? { status: "unknown" },
|
|
2828
|
+
...!actionError ? controlResult : {},
|
|
2829
|
+
...loginAttempt ? { loginAttempt: { attemptId: loginAttempt.attemptId, status: loginAttempt.status } } : {},
|
|
2830
|
+
url: inspected?.url ?? (page ? safeUrl(crawler?.controls().redact(page.url()) ?? page.url()) : null),
|
|
2831
|
+
visibleState: inspected?.visibleState ?? null,
|
|
2832
|
+
device: inspected?.device ?? null,
|
|
2833
|
+
screenshot,
|
|
2834
|
+
...error ? { error: { code: error instanceof OperatorActionError || error instanceof LoginAttemptError ? error.code : failureCode(error), message: crawler.controls().redact(error instanceof OperatorActionError || error instanceof LoginAttemptError ? error.message : messageFor(error)) } } : {}
|
|
2835
|
+
};
|
|
2836
|
+
}
|
|
2837
|
+
function shutdown() {
|
|
2838
|
+
if (!closing) return Promise.resolve();
|
|
2839
|
+
if (shutdownPromise) return shutdownPromise;
|
|
2840
|
+
clearTimeout(startupLease);
|
|
2841
|
+
shutdownPromise = (async () => {
|
|
2842
|
+
for (const socket of sockets) socket.destroy();
|
|
2843
|
+
await unlink2(socketPath).catch(() => void 0);
|
|
2844
|
+
server.close();
|
|
2845
|
+
if (abortOnShutdown) {
|
|
2846
|
+
const cleanup = crawler ? closeCrawler() : forceClosePendingBrowser ? forceCloseBrowser() : expirationCleanup ?? Promise.resolve();
|
|
2847
|
+
let cleanupFinished = false;
|
|
2848
|
+
let cleanupFailed = false;
|
|
2849
|
+
const observedCleanup = cleanup.then(
|
|
2850
|
+
() => {
|
|
2851
|
+
cleanupFinished = true;
|
|
2852
|
+
},
|
|
2853
|
+
() => {
|
|
2854
|
+
cleanupFinished = true;
|
|
2855
|
+
cleanupFailed = true;
|
|
2856
|
+
}
|
|
2857
|
+
);
|
|
2858
|
+
await Promise.race([observedCleanup, wait(OWNER_CLEANUP_GRACE_MS)]);
|
|
2859
|
+
if (!cleanupFinished || cleanupFailed) {
|
|
2860
|
+
await forceCloseBrowser().catch(() => void 0);
|
|
2861
|
+
}
|
|
2862
|
+
(options.exit ?? process.exit)(0);
|
|
2863
|
+
return;
|
|
2864
|
+
}
|
|
2865
|
+
if (crawler) {
|
|
2866
|
+
try {
|
|
2867
|
+
await withDeadline(checkpoint(), OWNER_CLEANUP_GRACE_MS);
|
|
2868
|
+
await closeCrawler();
|
|
2869
|
+
} catch {
|
|
2870
|
+
await crawler.forceClose?.().catch(() => void 0);
|
|
2871
|
+
}
|
|
2872
|
+
} else if (forceClosePendingBrowser) {
|
|
2873
|
+
await forceCloseBrowser().catch(() => void 0);
|
|
2874
|
+
}
|
|
2875
|
+
await withDeadline(checkpointQueue, OWNER_CLEANUP_GRACE_MS).catch(() => void 0);
|
|
2876
|
+
saved?.close();
|
|
2877
|
+
process.removeListener("SIGTERM", onSignal);
|
|
2878
|
+
process.removeListener("SIGINT", onSignal);
|
|
2879
|
+
})();
|
|
2880
|
+
return shutdownPromise;
|
|
2881
|
+
}
|
|
2882
|
+
await new Promise((resolve, reject) => {
|
|
2883
|
+
server.once("error", reject);
|
|
2884
|
+
server.listen(socketPath, () => {
|
|
2885
|
+
server.removeListener("error", reject);
|
|
2886
|
+
void chmod2(socketPath, 384).then(resolve, reject);
|
|
2887
|
+
});
|
|
2888
|
+
});
|
|
2889
|
+
startupLease = setTimeout(() => {
|
|
2890
|
+
if (initializationPromise) return;
|
|
2891
|
+
expireSession();
|
|
2892
|
+
void shutdown();
|
|
2893
|
+
}, options.timeoutMs);
|
|
2894
|
+
const onSignal = () => {
|
|
2895
|
+
closing = true;
|
|
2896
|
+
void shutdown();
|
|
2897
|
+
};
|
|
2898
|
+
process.once("SIGTERM", onSignal);
|
|
2899
|
+
process.once("SIGINT", onSignal);
|
|
2900
|
+
}
|
|
2901
|
+
async function wait(milliseconds) {
|
|
2902
|
+
return new Promise((resolve) => setTimeout(resolve, milliseconds));
|
|
2903
|
+
}
|
|
2904
|
+
function assertOwnedSocket(socketPath) {
|
|
2905
|
+
return lstat3(socketPath).then((entry) => {
|
|
2906
|
+
const uid = process.getuid?.();
|
|
2907
|
+
if (!entry.isSocket() || uid !== void 0 && entry.uid !== uid) {
|
|
2908
|
+
throw new Error("The lmt-web session socket is not owned by the invoking user.");
|
|
2909
|
+
}
|
|
2910
|
+
});
|
|
2911
|
+
}
|
|
2912
|
+
async function requestSession(request, options = {}) {
|
|
2913
|
+
if (!isSessionId(request.sessionId)) {
|
|
2914
|
+
return failureResult(request.action, "invalid_session_id", "A valid session ID is required.", request.sessionId || null);
|
|
2915
|
+
}
|
|
2916
|
+
const socketPath = socketPathFor(request.sessionId);
|
|
2917
|
+
const deadlineAt = Date.now() + request.timeoutMs;
|
|
2918
|
+
const wireRequest = { ...request, deadlineAt };
|
|
2919
|
+
if (options.waitForSocket) {
|
|
2920
|
+
let ready = false;
|
|
2921
|
+
while (Date.now() < deadlineAt) {
|
|
2922
|
+
try {
|
|
2923
|
+
await assertOwnedSocket(socketPath);
|
|
2924
|
+
ready = true;
|
|
2925
|
+
break;
|
|
2926
|
+
} catch {
|
|
2927
|
+
if (options.ownerAlive && !options.ownerAlive()) {
|
|
2928
|
+
return failureResult(request.action, "browser_start_failed", "The browser session owner exited before it became ready.", request.sessionId);
|
|
2929
|
+
}
|
|
2930
|
+
await wait(40);
|
|
2931
|
+
}
|
|
2932
|
+
}
|
|
2933
|
+
if (!ready) {
|
|
2934
|
+
return failureResult(request.action, "deadline_exceeded", "The browser session did not become ready before its deadline.", request.sessionId);
|
|
2935
|
+
}
|
|
2936
|
+
}
|
|
2937
|
+
try {
|
|
2938
|
+
await assertOwnedSocket(socketPath);
|
|
2939
|
+
} catch {
|
|
2940
|
+
return failureResult(request.action, "session_not_found", "The browser session is not live. Start a new session.", request.sessionId);
|
|
2941
|
+
}
|
|
2942
|
+
return new Promise((resolve) => {
|
|
2943
|
+
const socket = net.createConnection(socketPath);
|
|
2944
|
+
let received = "";
|
|
2945
|
+
let settled = false;
|
|
2946
|
+
const finish = (result) => {
|
|
2947
|
+
if (settled) return;
|
|
2948
|
+
settled = true;
|
|
2949
|
+
clearTimeout(timer);
|
|
2950
|
+
socket.destroy();
|
|
2951
|
+
resolve(result);
|
|
2952
|
+
};
|
|
2953
|
+
const timer = setTimeout(() => {
|
|
2954
|
+
finish(failureResult(request.action, "deadline_exceeded", "The browser action exceeded its deadline.", request.sessionId));
|
|
2955
|
+
}, Math.max(1, deadlineAt + OWNER_RESPONSE_GRACE_MS - Date.now()));
|
|
2956
|
+
socket.once("connect", () => socket.write(`${JSON.stringify(wireRequest)}
|
|
2957
|
+
`));
|
|
2958
|
+
socket.on("data", (chunk) => {
|
|
2959
|
+
received += chunk.toString("utf8");
|
|
2960
|
+
const newline = received.indexOf("\n");
|
|
2961
|
+
if (newline < 0) return;
|
|
2962
|
+
try {
|
|
2963
|
+
finish(JSON.parse(received.slice(0, newline)));
|
|
2964
|
+
} catch {
|
|
2965
|
+
finish(failureResult(request.action, "invalid_response", "The browser session returned an invalid result.", request.sessionId));
|
|
2966
|
+
}
|
|
2967
|
+
});
|
|
2968
|
+
socket.once("error", () => {
|
|
2969
|
+
finish(failureResult(request.action, "session_not_found", "The browser session is not live. Start a new session.", request.sessionId));
|
|
2970
|
+
});
|
|
2971
|
+
socket.once("close", () => {
|
|
2972
|
+
if (!settled) {
|
|
2973
|
+
finish(failureResult(request.action, "session_not_found", "The browser session closed before returning a result.", request.sessionId));
|
|
2974
|
+
}
|
|
2975
|
+
});
|
|
2976
|
+
});
|
|
2977
|
+
}
|
|
2978
|
+
|
|
2979
|
+
// src/start.ts
|
|
2980
|
+
import { spawn as spawn2 } from "node:child_process";
|
|
2981
|
+
import path6 from "node:path";
|
|
2982
|
+
import { fileURLToPath as fileURLToPath3 } from "node:url";
|
|
2983
|
+
async function startSession(command) {
|
|
2984
|
+
let target;
|
|
2985
|
+
try {
|
|
2986
|
+
target = resolveWebTarget(command.environment);
|
|
2987
|
+
} catch (error) {
|
|
2988
|
+
throw new InvocationError(error instanceof Error ? error.message : "The selected target is invalid.", "invalid_target");
|
|
2989
|
+
}
|
|
2990
|
+
await ensureSessionDirectory();
|
|
2991
|
+
const deadlineAt = Date.now() + command.timeoutMs;
|
|
2992
|
+
const store = command.fresh ? void 0 : await SavedSession.open({ ...target, mode: command.mode });
|
|
2993
|
+
let spawned;
|
|
2994
|
+
let spawnedSessionId;
|
|
2995
|
+
try {
|
|
2996
|
+
store?.load();
|
|
2997
|
+
const selectOwner = () => {
|
|
2998
|
+
const existing = store?.owner();
|
|
2999
|
+
if (existing) {
|
|
3000
|
+
if (existing.headless !== command.headless) throw new InvocationError("The saved session is already live with different visibility. Close it before changing --headed, or use --fresh.");
|
|
3001
|
+
return existing;
|
|
3002
|
+
}
|
|
3003
|
+
const sessionId = createSessionId();
|
|
3004
|
+
const scriptPath = path6.resolve(process.argv[1] || fileURLToPath3(import.meta.url));
|
|
3005
|
+
const child = spawn2(process.execPath, [
|
|
3006
|
+
scriptPath,
|
|
3007
|
+
"__owner",
|
|
3008
|
+
sessionId,
|
|
3009
|
+
target.origin,
|
|
3010
|
+
command.mode,
|
|
3011
|
+
command.headless ? "headless" : "headed",
|
|
3012
|
+
String(command.timeoutMs),
|
|
3013
|
+
target.environment,
|
|
3014
|
+
command.fresh ? "fresh" : "saved"
|
|
3015
|
+
], { detached: true, stdio: "ignore" });
|
|
3016
|
+
spawned = child;
|
|
3017
|
+
spawnedSessionId = sessionId;
|
|
3018
|
+
child.on("error", () => void 0);
|
|
3019
|
+
child.unref();
|
|
3020
|
+
if (!child.pid) throw new Error("Could not start the browser owner.");
|
|
3021
|
+
const owner = { sessionId, pid: child.pid, headless: command.headless };
|
|
3022
|
+
store?.setOwner(owner);
|
|
3023
|
+
return owner;
|
|
3024
|
+
};
|
|
3025
|
+
while (Date.now() < deadlineAt) {
|
|
3026
|
+
const owner = store ? await store.reserve(deadlineAt, selectOwner) : selectOwner();
|
|
3027
|
+
const result = await requestSession({ sessionId: owner.sessionId, action: "start", timeoutMs: Math.max(1, deadlineAt - Date.now()) }, {
|
|
3028
|
+
waitForSocket: true,
|
|
3029
|
+
ownerAlive: () => {
|
|
3030
|
+
try {
|
|
3031
|
+
process.kill(owner.pid, 0);
|
|
3032
|
+
return true;
|
|
3033
|
+
} catch {
|
|
3034
|
+
return false;
|
|
3035
|
+
}
|
|
3036
|
+
}
|
|
3037
|
+
});
|
|
3038
|
+
if (!store || !["session_not_found", "session_unavailable", "browser_start_failed"].includes(result.error?.code ?? "")) {
|
|
3039
|
+
return { result, startedOwner: spawnedSessionId !== void 0 && result.sessionId === spawnedSessionId };
|
|
3040
|
+
}
|
|
3041
|
+
await new Promise((resolve) => setTimeout(resolve, 40));
|
|
3042
|
+
}
|
|
3043
|
+
return { result: failureResult("start", "deadline_exceeded", "The saved browser owner did not become available before the deadline."), startedOwner: false };
|
|
3044
|
+
} catch (error) {
|
|
3045
|
+
if (spawned?.pid) {
|
|
3046
|
+
try {
|
|
3047
|
+
process.kill(spawned.pid, "SIGTERM");
|
|
3048
|
+
} catch {
|
|
3049
|
+
}
|
|
3050
|
+
}
|
|
3051
|
+
throw error;
|
|
3052
|
+
} finally {
|
|
3053
|
+
store?.close();
|
|
3054
|
+
}
|
|
3055
|
+
}
|
|
3056
|
+
|
|
3057
|
+
// src/interactive.ts
|
|
3058
|
+
function isInteractiveInvocation(argv) {
|
|
3059
|
+
return argv.length === 0 || argv[0] === "interactive";
|
|
3060
|
+
}
|
|
3061
|
+
function parseInteractiveArguments(argv) {
|
|
3062
|
+
const values = /* @__PURE__ */ new Map();
|
|
3063
|
+
const options = argv[0] === "interactive" ? argv.slice(1) : argv;
|
|
3064
|
+
for (let index = 0; index < options.length; index += 2) {
|
|
3065
|
+
const option = options[index];
|
|
3066
|
+
const value = options[index + 1];
|
|
3067
|
+
if (option !== "--env" && option !== "--device") throw new InvocationError(`Interactive mode accepts only --env local|dev and --device mobile|desktop, not ${option}.`);
|
|
3068
|
+
if (values.has(option)) throw new InvocationError(`${option} may only be supplied once.`);
|
|
3069
|
+
if (value === void 0 || value.startsWith("--")) throw new InvocationError(`${option} requires a value.`);
|
|
3070
|
+
values.set(option, value);
|
|
3071
|
+
}
|
|
3072
|
+
const environment = values.get("--env") ?? "dev";
|
|
3073
|
+
const device = values.get("--device") ?? "desktop";
|
|
3074
|
+
if (environment !== "local" && environment !== "dev") throw new InvocationError("--env must be local or dev.");
|
|
3075
|
+
if (device !== "mobile" && device !== "desktop") throw new InvocationError("--device must be mobile or desktop.");
|
|
3076
|
+
return { environment, device };
|
|
3077
|
+
}
|
|
3078
|
+
|
|
3079
|
+
export {
|
|
3080
|
+
CliProfileStore,
|
|
3081
|
+
resolveCliTarget,
|
|
3082
|
+
SavedSessionError,
|
|
3083
|
+
InvocationError,
|
|
3084
|
+
ACTIONS,
|
|
3085
|
+
isAction,
|
|
3086
|
+
DEFAULT_CONFIG_PATH2 as DEFAULT_CONFIG_PATH,
|
|
3087
|
+
failureResult,
|
|
3088
|
+
isSessionId,
|
|
3089
|
+
runSessionOwner,
|
|
3090
|
+
requestSession,
|
|
3091
|
+
startSession,
|
|
3092
|
+
isInteractiveInvocation,
|
|
3093
|
+
parseInteractiveArguments
|
|
3094
|
+
};
|