Merge pull request #12 from LightZirconite/main

Latest update, fixing many issues before V3
This commit is contained in:
Obsidian
2025-11-15 12:53:56 +01:00
committed by GitHub
13 changed files with 259 additions and 183 deletions

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "microsoft-rewards-bot", "name": "microsoft-rewards-bot",
"version": "2.59.1", "version": "2.60.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "microsoft-rewards-bot", "name": "microsoft-rewards-bot",
"version": "2.59.1", "version": "2.60.0",
"license": "CC-BY-NC-SA-4.0", "license": "CC-BY-NC-SA-4.0",
"dependencies": { "dependencies": {
"axios": "^1.8.4", "axios": "^1.8.4",

View File

@@ -1,6 +1,6 @@
{ {
"name": "microsoft-rewards-bot", "name": "microsoft-rewards-bot",
"version": "2.59.1", "version": "2.60.0",
"description": "Automate Microsoft Rewards points collection", "description": "Automate Microsoft Rewards points collection",
"private": true, "private": true,
"main": "index.js", "main": "index.js",
@@ -26,7 +26,7 @@
"prestart": "node -e \"const fs = require('fs'); if (!fs.existsSync('dist/index.js')) { console.log('⚠️ Compiled files not found, building...'); require('child_process').execSync('npm run build', {stdio: 'inherit'}); }\"", "prestart": "node -e \"const fs = require('fs'); if (!fs.existsSync('dist/index.js')) { console.log('⚠️ Compiled files not found, building...'); require('child_process').execSync('npm run build', {stdio: 'inherit'}); }\"",
"start": "node --enable-source-maps ./dist/index.js", "start": "node --enable-source-maps ./dist/index.js",
"dev": "ts-node ./src/index.ts -dev", "dev": "ts-node ./src/index.ts -dev",
"test": "node --test --loader ts-node/esm tests/**/*.test.ts", "test": "node --test --require ts-node/register tests/**/*.test.ts",
"creator": "ts-node ./src/account-creation/cli.ts", "creator": "ts-node ./src/account-creation/cli.ts",
"dashboard": "node --enable-source-maps ./dist/index.js -dashboard", "dashboard": "node --enable-source-maps ./dist/index.js -dashboard",
"dashboard:dev": "ts-node ./src/index.ts -dashboard", "dashboard:dev": "ts-node ./src/index.ts -dashboard",

View File

@@ -105,6 +105,8 @@ export class HumanBehavior {
*/ */
async microGestures(context: string): Promise<void> { async microGestures(context: string): Promise<void> {
try { try {
const gestureNotes: string[] = []
// 60% chance of mouse movement (humans move mouse A LOT) // 60% chance of mouse movement (humans move mouse A LOT)
if (Math.random() < 0.6) { if (Math.random() < 0.6) {
const x = Math.floor(Math.random() * 200) + 50 // Random x: 50-250px const x = Math.floor(Math.random() * 200) + 50 // Random x: 50-250px
@@ -115,8 +117,7 @@ export class HumanBehavior {
// Mouse move failed - page may be closed or unavailable // Mouse move failed - page may be closed or unavailable
}) })
// VERBOSE logging disabled - too noisy gestureNotes.push(`mouse→(${x},${y})`)
// log(false, 'CREATOR', `[${context}] 🖱️ Mouse moved to (${x}, ${y})`, 'log', 'gray')
} }
// 30% chance of scroll (humans scroll to read content) // 30% chance of scroll (humans scroll to read content)
@@ -129,8 +130,11 @@ export class HumanBehavior {
// Scroll failed - page may be closed or unavailable // Scroll failed - page may be closed or unavailable
}) })
// VERBOSE logging disabled - too noisy gestureNotes.push(`scroll ${direction > 0 ? '↓' : '↑'} ${distance}px`)
// log(false, 'CREATOR', `[${context}] 📜 Scrolled ${direction > 0 ? 'down' : 'up'} ${distance}px`, 'log', 'gray') }
if (gestureNotes.length > 0) {
log(false, 'CREATOR', `[${context}] micro gestures: ${gestureNotes.join(', ')}`, 'log', 'gray')
} }
} catch { } catch {
// Gesture execution failed - not critical for operation // Gesture execution failed - not critical for operation

View File

@@ -170,7 +170,7 @@ export default class BrowserFunc {
}) })
this.bot.log(this.bot.isMobile, 'GO-HOME-DEBUG', this.bot.log(this.bot.isMobile, 'GO-HOME-DEBUG',
`DOM Diagnostic - ` + 'DOM Diagnostic - ' +
`URL: ${diagnosticInfo.url}, ` + `URL: ${diagnosticInfo.url}, ` +
`Title: "${diagnosticInfo.pageTitle}", ` + `Title: "${diagnosticInfo.pageTitle}", ` +
`Elements with 'activit': ${diagnosticInfo.activitiesIdCount} [${diagnosticInfo.activitiesIds.join(', ')}], ` + `Elements with 'activit': ${diagnosticInfo.activitiesIdCount} [${diagnosticInfo.activitiesIds.join(', ')}], ` +

View File

@@ -19,10 +19,10 @@ export interface AccountResult {
email: string email: string
pointsEarned: number pointsEarned: number
runDuration: number runDuration: number
initialPoints: number // Points avant l'exécution initialPoints: number // Points before execution
finalPoints: number // Points après l'exécution finalPoints: number // Points after execution
desktopPoints: number // Points gagnés sur Desktop desktopPoints: number // Points earned on desktop
mobilePoints: number // Points gagnés sur Mobile mobilePoints: number // Points earned on mobile
errors?: string[] errors?: string[]
banned?: boolean banned?: boolean
} }
@@ -38,11 +38,13 @@ export interface SummaryData {
export class SummaryReporter { export class SummaryReporter {
private config: Config private config: Config
private jobState: JobState private jobState?: JobState
constructor(config: Config) { constructor(config: Config) {
this.config = config this.config = config
this.jobState = new JobState(config) if (config.jobState?.enabled !== false) {
this.jobState = new JobState(config)
}
} }
/** /**
@@ -85,7 +87,7 @@ export class SummaryReporter {
description += ` | **🚫 Banned:** ${bannedCount}` description += ` | **🚫 Banned:** ${bannedCount}`
} }
description += `\n\n**📊 Account Details**\n` description += '\n\n**📊 Account Details**\n'
const accountsWithErrors: AccountResult[] = [] const accountsWithErrors: AccountResult[] = []
@@ -100,13 +102,13 @@ export class SummaryReporter {
description += `• Duration: ${durationSec}s\n` description += `• Duration: ${durationSec}s\n`
// Collect accounts with errors for separate webhook // Collect accounts with errors for separate webhook
if ((account.errors?.length || account.banned) && account.email) { if (this.hasAccountFailure(account)) {
accountsWithErrors.push(account) accountsWithErrors.push(account)
} }
} }
// Footer summary // Footer summary
description += `\n**🌐 Total Balance**\n` description += '\n**🌐 Total Balance**\n'
description += `${totalInitial} → **${totalFinal}** pts (+${summary.totalPoints})` description += `${totalInitial} → **${totalFinal}** pts (+${summary.totalPoints})`
const color = bannedCount > 0 ? 0xFF0000 : summary.failureCount > 0 ? 0xFFAA00 : 0x00FF00 const color = bannedCount > 0 ? 0xFF0000 : summary.failureCount > 0 ? 0xFFAA00 : 0x00FF00
@@ -145,7 +147,7 @@ export class SummaryReporter {
// Error details // Error details
if (account.banned) { if (account.banned) {
errorDescription += `• Status: Account Banned/Suspended\n` errorDescription += '• Status: Account Banned/Suspended\n'
if (account.errors?.length && account.errors[0]) { if (account.errors?.length && account.errors[0]) {
errorDescription += `• Reason: ${account.errors[0]}\n` errorDescription += `• Reason: ${account.errors[0]}\n`
} }
@@ -153,14 +155,14 @@ export class SummaryReporter {
errorDescription += `• Error: ${account.errors[0]}\n` errorDescription += `• Error: ${account.errors[0]}\n`
} }
errorDescription += `\n` errorDescription += '\n'
} }
errorDescription += `**📋 Recommended Actions:**\n` errorDescription += '**📋 Recommended Actions:**\n'
errorDescription += `• Check account status manually\n` errorDescription += '• Check account status manually\n'
errorDescription += `• Review error messages above\n` errorDescription += '• Review error messages above\n'
errorDescription += `• Verify credentials if login failed\n` errorDescription += '• Verify credentials if login failed\n'
errorDescription += `• Consider proxy rotation if rate-limited` errorDescription += '• Consider proxy rotation if rate-limited'
await ConclusionWebhook( await ConclusionWebhook(
this.config, this.config,
@@ -195,6 +197,10 @@ export class SummaryReporter {
* Update job state with completion status * Update job state with completion status
*/ */
async updateJobState(summary: SummaryData): Promise<void> { async updateJobState(summary: SummaryData): Promise<void> {
if (!this.jobState) {
return
}
try { try {
const day = summary.endTime.toISOString().split('T')?.[0] const day = summary.endTime.toISOString().split('T')?.[0]
if (!day) return if (!day) return
@@ -205,7 +211,7 @@ export class SummaryReporter {
day, day,
{ {
totalCollected: account.pointsEarned, totalCollected: account.pointsEarned,
banned: false, banned: account.banned ?? false,
errors: account.errors?.length ?? 0 errors: account.errors?.length ?? 0
} }
) )
@@ -237,13 +243,15 @@ export class SummaryReporter {
log('main', 'SUMMARY', '─'.repeat(80)) log('main', 'SUMMARY', '─'.repeat(80))
for (const account of summary.accounts) { for (const account of summary.accounts) {
const status = account.errors?.length ? '❌ FAILED' : '✅ SUCCESS' const status = this.hasAccountFailure(account) ? (account.banned ? '🚫 BANNED' : '❌ FAILED') : '✅ SUCCESS'
const duration = Math.round(account.runDuration / 1000) const duration = Math.round(account.runDuration / 1000)
log('main', 'SUMMARY', `${status} | ${account.email}`) log('main', 'SUMMARY', `${status} | ${account.email}`)
log('main', 'SUMMARY', ` Points: ${account.pointsEarned} | Duration: ${duration}s`) log('main', 'SUMMARY', ` Points: ${account.pointsEarned} | Duration: ${duration}s`)
if (account.errors?.length) { if (account.banned) {
log('main', 'SUMMARY', ' Status: Account flagged as banned/suspended', 'error')
} else if (account.errors?.length) {
log('main', 'SUMMARY', ` Error: ${account.errors[0]}`, 'error') log('main', 'SUMMARY', ` Error: ${account.errors[0]}`, 'error')
} }
} }
@@ -267,8 +275,8 @@ export class SummaryReporter {
endTime: Date endTime: Date
): SummaryData { ): SummaryData {
const totalPoints = accounts.reduce((sum, acc) => sum + acc.pointsEarned, 0) const totalPoints = accounts.reduce((sum, acc) => sum + acc.pointsEarned, 0)
const successCount = accounts.filter(acc => !acc.errors?.length).length const failureCount = accounts.filter(acc => this.hasAccountFailure(acc)).length
const failureCount = accounts.length - successCount const successCount = accounts.length - failureCount
return { return {
accounts, accounts,
@@ -279,4 +287,8 @@ export class SummaryReporter {
failureCount failureCount
} }
} }
private hasAccountFailure(account: AccountResult): boolean {
return Boolean(account.errors?.length) || account.banned === true
}
} }

View File

@@ -205,12 +205,12 @@ export class MicrosoftRewardsBot {
// Only cluster when there's more than 1 cluster demanded // Only cluster when there's more than 1 cluster demanded
if (this.config.clusters > 1) { if (this.config.clusters > 1) {
if (cluster.isPrimary) { if (cluster.isPrimary) {
this.runMaster() await this.runMaster()
} else if (cluster.worker) { } else if (cluster.worker) {
await this.runWorker() await this.runWorker()
} else { } else {
// Neither primary nor worker - something's wrong with clustering // Neither primary nor worker - something's wrong with clustering
log('main', 'MAIN', `ERROR: Cluster mode failed - neither primary nor worker! Falling back to single-process mode.`, 'error') log('main', 'MAIN', 'ERROR: Cluster mode failed - neither primary nor worker! Falling back to single-process mode.', 'error')
const passes = this.config.passesPerRun ?? 1 const passes = this.config.passesPerRun ?? 1
for (let pass = 1; pass <= passes; pass++) { for (let pass = 1; pass <= passes; pass++) {
if (passes > 1) { if (passes > 1) {
@@ -279,101 +279,108 @@ export class MicrosoftRewardsBot {
return this.accountSummaries return this.accountSummaries
} }
private runMaster() { private runMaster(): Promise<void> {
log('main', 'MAIN-PRIMARY', 'Primary process started') return new Promise((resolve) => {
log('main', 'MAIN-PRIMARY', 'Primary process started')
const totalAccounts = this.accounts.length const totalAccounts = this.accounts.length
// Validate accounts exist // Validate accounts exist
if (totalAccounts === 0) { if (totalAccounts === 0) {
log('main', 'MAIN-PRIMARY', 'No accounts found to process. Exiting.', 'warn') log('main', 'MAIN-PRIMARY', 'No accounts found to process. Nothing to do.', 'warn')
process.exit(0) resolve()
} return
// If user over-specified clusters (e.g. 10 clusters but only 2 accounts), don't spawn useless idle workers.
const workerCount = Math.min(this.config.clusters, totalAccounts)
const accountChunks = this.utils.chunkArray(this.accounts, workerCount)
// Reset activeWorkers to actual spawn count (constructor used raw clusters)
this.activeWorkers = workerCount
// Store worker-to-chunk mapping for crash recovery
const workerChunkMap = new Map<number, Account[]>()
for (let i = 0; i < workerCount; i++) {
const worker = cluster.fork()
const chunk = accountChunks[i] || []
// Validate chunk has accounts
if (chunk.length === 0) {
log('main', 'MAIN-PRIMARY', `Warning: Worker ${i} received empty account chunk`, 'warn')
} }
// Store chunk mapping for crash recovery // If user over-specified clusters (e.g. 10 clusters but only 2 accounts), don't spawn useless idle workers.
if (worker.id) { const workerCount = Math.min(this.config.clusters, totalAccounts)
workerChunkMap.set(worker.id, chunk) const accountChunks = this.utils.chunkArray(this.accounts, workerCount)
// Reset activeWorkers to actual spawn count (constructor used raw clusters)
this.activeWorkers = workerCount
// Store worker-to-chunk mapping for crash recovery
const workerChunkMap = new Map<number, Account[]>()
let resolved = false
const finishRun = async () => {
if (resolved) return
resolved = true
try {
await this.sendConclusion(this.accountSummaries)
} catch (e) {
log('main', 'CONCLUSION', `Failed to send conclusion: ${e instanceof Error ? e.message : String(e)}`, 'warn')
}
log('main', 'MAIN-WORKER', 'All workers destroyed. Run complete.', 'warn')
resolve()
} }
// FIXED: Proper type checking before calling send for (let i = 0; i < workerCount; i++) {
if (worker.send && typeof worker.send === 'function') { const worker = cluster.fork()
worker.send({ chunk }) const chunk = accountChunks[i] || []
} else {
log('main', 'MAIN-PRIMARY', `ERROR: Worker ${i} does not have a send function!`, 'error') // Validate chunk has accounts
if (chunk.length === 0) {
log('main', 'MAIN-PRIMARY', `Warning: Worker ${i} received empty account chunk`, 'warn')
}
// Store chunk mapping for crash recovery
if (worker.id) {
workerChunkMap.set(worker.id, chunk)
}
// FIXED: Proper type checking before calling send
if (worker.send && typeof worker.send === 'function') {
worker.send({ chunk })
} else {
log('main', 'MAIN-PRIMARY', `ERROR: Worker ${i} does not have a send function!`, 'error')
}
worker.on('message', (msg: unknown) => {
// IMPROVED: Using type-safe interface and type guard
if (isWorkerMessage(msg)) {
this.accountSummaries.push(...msg.data)
}
})
} }
worker.on('message', (msg: unknown) => {
// IMPROVED: Using type-safe interface and type guard cluster.on('exit', (worker: Worker, code: number) => {
if (isWorkerMessage(msg)) { this.activeWorkers -= 1
this.accountSummaries.push(...msg.data)
log('main', 'MAIN-WORKER', `Worker ${worker.process.pid} destroyed | Code: ${code} | Active workers: ${this.activeWorkers}`, 'warn')
// Optional: restart crashed worker (basic heuristic) if crashRecovery allows
const cr = this.config.crashRecovery
if (cr?.restartFailedWorker && code !== 0 && worker.id) {
const attempts = (worker as { _restartAttempts?: number })._restartAttempts || 0
if (attempts < (cr.restartFailedWorkerAttempts ?? 1)) {
(worker as { _restartAttempts?: number })._restartAttempts = attempts + 1
log('main', 'CRASH-RECOVERY', `Respawning worker (attempt ${attempts + 1})`, 'warn')
const originalChunk = workerChunkMap.get(worker.id)
const newW = cluster.fork()
if (originalChunk && originalChunk.length > 0 && newW.id) {
(newW as { send?: (m: { chunk: Account[] }) => void }).send?.({ chunk: originalChunk })
workerChunkMap.set(newW.id, originalChunk)
workerChunkMap.delete(worker.id)
log('main', 'CRASH-RECOVERY', `Assigned ${originalChunk.length} account(s) to respawned worker`)
} else {
log('main', 'CRASH-RECOVERY', 'Warning: Could not reassign accounts to respawned worker', 'warn')
}
newW.on('message', (msg: unknown) => {
// IMPROVED: Using type-safe interface and type guard
if (isWorkerMessage(msg)) {
this.accountSummaries.push(...msg.data)
}
})
}
}
// Check if all workers have exited
if (this.activeWorkers === 0) {
void finishRun()
} }
}) })
}
cluster.on('exit', (worker: Worker, code: number) => {
this.activeWorkers -= 1
log('main', 'MAIN-WORKER', `Worker ${worker.process.pid} destroyed | Code: ${code} | Active workers: ${this.activeWorkers}`, 'warn')
// Optional: restart crashed worker (basic heuristic) if crashRecovery allows
const cr = this.config.crashRecovery
if (cr?.restartFailedWorker && code !== 0 && worker.id) {
const attempts = (worker as { _restartAttempts?: number })._restartAttempts || 0
if (attempts < (cr.restartFailedWorkerAttempts ?? 1)) {
(worker as { _restartAttempts?: number })._restartAttempts = attempts + 1
log('main', 'CRASH-RECOVERY', `Respawning worker (attempt ${attempts + 1})`, 'warn')
const originalChunk = workerChunkMap.get(worker.id)
const newW = cluster.fork()
if (originalChunk && originalChunk.length > 0 && newW.id) {
(newW as { send?: (m: { chunk: Account[] }) => void }).send?.({ chunk: originalChunk })
workerChunkMap.set(newW.id, originalChunk)
workerChunkMap.delete(worker.id)
log('main', 'CRASH-RECOVERY', `Assigned ${originalChunk.length} account(s) to respawned worker`)
} else {
log('main', 'CRASH-RECOVERY', 'Warning: Could not reassign accounts to respawned worker', 'warn')
}
newW.on('message', (msg: unknown) => {
// IMPROVED: Using type-safe interface and type guard
if (isWorkerMessage(msg)) {
this.accountSummaries.push(...msg.data)
}
})
}
}
// Check if all workers have exited
if (this.activeWorkers === 0) {
// All workers done -> send conclusion and exit (update check moved to startup)
(async () => {
try {
await this.sendConclusion(this.accountSummaries)
} catch (e) {
log('main', 'CONCLUSION', `Failed to send conclusion: ${e instanceof Error ? e.message : String(e)}`, 'warn')
}
log('main', 'MAIN-WORKER', 'All workers destroyed. Exiting main process!', 'warn')
process.exit(0)
})()
}
}) })
} }
@@ -382,11 +389,21 @@ export class MicrosoftRewardsBot {
// Wait for chunk (either already received during init, or will arrive soon) // Wait for chunk (either already received during init, or will arrive soon)
const chunk = await new Promise<Account[]>((resolve) => { const chunk = await new Promise<Account[]>((resolve) => {
if ((global as any).__workerChunk) { if (global.__workerChunk) {
resolve((global as any).__workerChunk) const bufferedChunk = global.__workerChunk
} else { global.__workerChunk = undefined
(process as unknown as { on: (ev: 'message', cb: (m: { chunk: Account[] }) => void) => void }).on('message', ({ chunk: c }: { chunk: Account[] }) => resolve(c)) resolve(bufferedChunk)
return
} }
const handleMessage = (message: unknown): void => {
if (isWorkerChunkMessage(message)) {
process.off('message', handleMessage)
resolve(message.chunk)
}
}
process.on('message', handleMessage)
}) })
if (!chunk || chunk.length === 0) { if (!chunk || chunk.length === 0) {
@@ -896,6 +913,20 @@ function isWorkerMessage(msg: unknown): msg is WorkerMessage {
return m.type === 'summary' && Array.isArray(m.data) return m.type === 'summary' && Array.isArray(m.data)
} }
interface WorkerChunkMessage {
chunk: Account[]
}
function isWorkerChunkMessage(message: unknown): message is WorkerChunkMessage {
if (!message || typeof message !== 'object') return false
return Array.isArray((message as WorkerChunkMessage).chunk)
}
declare global {
// eslint-disable-next-line no-var
var __workerChunk: Account[] | undefined
}
// Use utility functions from Utils.ts // Use utility functions from Utils.ts
const shortErr = shortErrorMessage const shortErr = shortErrorMessage
const formatFullError = formatDetailedError const formatFullError = formatDetailedError
@@ -905,9 +936,14 @@ async function main(): Promise<void> {
// Workers initialize for ~2 seconds before reaching runWorker(), so messages // Workers initialize for ~2 seconds before reaching runWorker(), so messages
// sent by primary during initialization would be lost without this early listener // sent by primary during initialization would be lost without this early listener
if (!cluster.isPrimary && cluster.worker) { if (!cluster.isPrimary && cluster.worker) {
(process as unknown as { on: (ev: 'message', cb: (m: { chunk: Account[] }) => void) => void }).on('message', ({ chunk }: { chunk: Account[] }) => { const bufferChunk = (message: unknown): void => {
(global as any).__workerChunk = chunk if (isWorkerChunkMessage(message)) {
}) global.__workerChunk = message.chunk
process.off('message', bufferChunk)
}
}
process.on('message', bufferChunk)
} }
// Check for dashboard mode flag (standalone dashboard) // Check for dashboard mode flag (standalone dashboard)

View File

@@ -25,9 +25,16 @@ export function obfuscateWebhookUrl(url: string): string {
return Buffer.from(url).toString('base64') return Buffer.from(url).toString('base64')
} }
const BASE64_REGEX = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/
export function deobfuscateWebhookUrl(encoded: string): string { export function deobfuscateWebhookUrl(encoded: string): string {
const trimmed = encoded.trim()
if (!trimmed || !BASE64_REGEX.test(trimmed)) {
return ''
}
try { try {
return Buffer.from(encoded, 'base64').toString('utf-8') return Buffer.from(trimmed, 'base64').toString('utf-8')
} catch { } catch {
return '' return ''
} }

View File

@@ -14,12 +14,12 @@ test('MobileFlow module exports correctly', async () => {
test('MobileFlow has run method', async () => { test('MobileFlow has run method', async () => {
const { MobileFlow } = await import('../../src/flows/MobileFlow') const { MobileFlow } = await import('../../src/flows/MobileFlow')
// Mock bot instance // Mock bot instance
const mockBot = { const mockBot = {
log: () => {}, log: () => { },
isMobile: true, isMobile: true,
config: { config: {
workers: {}, workers: {},
runOnZeroPoints: false, runOnZeroPoints: false,
searchSettings: { retryMobileSearchAmount: 0 } searchSettings: { retryMobileSearchAmount: 0 }
@@ -29,7 +29,7 @@ test('MobileFlow has run method', async () => {
activities: {}, activities: {},
compromisedModeActive: false compromisedModeActive: false
} }
const flow = new MobileFlow(mockBot as never) const flow = new MobileFlow(mockBot as never)
assert.ok(flow, 'MobileFlow instance should be created') assert.ok(flow, 'MobileFlow instance should be created')
assert.equal(typeof flow.run, 'function', 'MobileFlow should have run() method') assert.equal(typeof flow.run, 'function', 'MobileFlow should have run() method')
@@ -37,27 +37,27 @@ test('MobileFlow has run method', async () => {
test('MobileFlowResult interface has correct structure', async () => { test('MobileFlowResult interface has correct structure', async () => {
const { MobileFlow } = await import('../../src/flows/MobileFlow') const { MobileFlow } = await import('../../src/flows/MobileFlow')
// Validate that MobileFlowResult type exports (compile-time check) // Validate that MobileFlowResult type exports (compile-time check)
type MobileFlowResult = Awaited<ReturnType<InstanceType<typeof MobileFlow>['run']>> type MobileFlowResult = Awaited<ReturnType<InstanceType<typeof MobileFlow>['run']>>
const mockResult: MobileFlowResult = { const mockResult: MobileFlowResult = {
initialPoints: 1000, initialPoints: 1000,
collectedPoints: 30 collectedPoints: 30
} }
assert.equal(typeof mockResult.initialPoints, 'number', 'initialPoints should be a number') assert.equal(typeof mockResult.initialPoints, 'number', 'initialPoints should be a number')
assert.equal(typeof mockResult.collectedPoints, 'number', 'collectedPoints should be a number') assert.equal(typeof mockResult.collectedPoints, 'number', 'collectedPoints should be a number')
}) })
test('MobileFlow accepts retry tracker', async () => { test('MobileFlow accepts retry tracker', async () => {
const { MobileFlow } = await import('../../src/flows/MobileFlow') const { MobileFlow } = await import('../../src/flows/MobileFlow')
const { MobileRetryTracker } = await import('../../src/util/MobileRetryTracker') const { MobileRetryTracker } = await import('../../src/util/state/MobileRetryTracker')
const mockBot = { const mockBot = {
log: () => {}, log: () => { },
isMobile: true, isMobile: true,
config: { config: {
workers: {}, workers: {},
runOnZeroPoints: false, runOnZeroPoints: false,
searchSettings: { retryMobileSearchAmount: 3 } searchSettings: { retryMobileSearchAmount: 3 }
@@ -67,10 +67,10 @@ test('MobileFlow accepts retry tracker', async () => {
activities: {}, activities: {},
compromisedModeActive: false compromisedModeActive: false
} }
const flow = new MobileFlow(mockBot as never) const flow = new MobileFlow(mockBot as never)
const tracker = new MobileRetryTracker(3) const tracker = new MobileRetryTracker(3)
assert.ok(flow, 'MobileFlow should accept retry tracker') assert.ok(flow, 'MobileFlow should accept retry tracker')
assert.equal(typeof tracker.registerFailure, 'function', 'MobileRetryTracker should have registerFailure method') assert.equal(typeof tracker.registerFailure, 'function', 'MobileRetryTracker should have registerFailure method')
}) })

View File

@@ -14,40 +14,57 @@ test('SummaryReporter module exports correctly', async () => {
test('SummaryReporter creates instance with config', async () => { test('SummaryReporter creates instance with config', async () => {
const { SummaryReporter } = await import('../../src/flows/SummaryReporter') const { SummaryReporter } = await import('../../src/flows/SummaryReporter')
const mockConfig = { const mockConfig = {
webhook: { enabled: false }, webhook: { enabled: false },
ntfy: { enabled: false }, ntfy: { enabled: false },
sessionPath: './sessions', sessionPath: './sessions',
jobState: { enabled: false } jobState: { enabled: false }
} }
const reporter = new SummaryReporter(mockConfig as never) const reporter = new SummaryReporter(mockConfig as never)
assert.ok(reporter, 'SummaryReporter instance should be created') assert.ok(reporter, 'SummaryReporter instance should be created')
}) })
test('SummaryReporter creates summary correctly', async () => { test('SummaryReporter creates summary correctly', async () => {
const { SummaryReporter } = await import('../../src/flows/SummaryReporter') const { SummaryReporter } = await import('../../src/flows/SummaryReporter')
const mockConfig = { const mockConfig = {
webhook: { enabled: false }, webhook: { enabled: false },
ntfy: { enabled: false }, ntfy: { enabled: false },
sessionPath: './sessions', sessionPath: './sessions',
jobState: { enabled: false } jobState: { enabled: false }
} }
const reporter = new SummaryReporter(mockConfig as never) const reporter = new SummaryReporter(mockConfig as never)
const accounts = [ const accounts = [
{ email: 'test@example.com', pointsEarned: 100, runDuration: 60000 }, {
{ email: 'test2@example.com', pointsEarned: 150, runDuration: 70000, errors: ['test error'] } email: 'test@example.com',
pointsEarned: 100,
runDuration: 60000,
initialPoints: 1000,
finalPoints: 1100,
desktopPoints: 60,
mobilePoints: 40
},
{
email: 'test2@example.com',
pointsEarned: 150,
runDuration: 70000,
initialPoints: 2000,
finalPoints: 2150,
desktopPoints: 90,
mobilePoints: 60,
errors: ['test error']
}
] ]
const startTime = new Date('2025-01-01T10:00:00Z') const startTime = new Date('2025-01-01T10:00:00Z')
const endTime = new Date('2025-01-01T10:05:00Z') const endTime = new Date('2025-01-01T10:05:00Z')
const summary = reporter.createSummary(accounts, startTime, endTime) const summary = reporter.createSummary(accounts, startTime, endTime)
assert.equal(summary.totalPoints, 250, 'Total points should be 250') assert.equal(summary.totalPoints, 250, 'Total points should be 250')
assert.equal(summary.successCount, 1, 'Success count should be 1') assert.equal(summary.successCount, 1, 'Success count should be 1')
assert.equal(summary.failureCount, 1, 'Failure count should be 1') assert.equal(summary.failureCount, 1, 'Failure count should be 1')
@@ -56,22 +73,30 @@ test('SummaryReporter creates summary correctly', async () => {
test('SummaryData structure is correct', async () => { test('SummaryData structure is correct', async () => {
const { SummaryReporter } = await import('../../src/flows/SummaryReporter') const { SummaryReporter } = await import('../../src/flows/SummaryReporter')
const mockConfig = { const mockConfig = {
webhook: { enabled: false }, webhook: { enabled: false },
ntfy: { enabled: false }, ntfy: { enabled: false },
sessionPath: './sessions', sessionPath: './sessions',
jobState: { enabled: false } jobState: { enabled: false }
} }
const reporter = new SummaryReporter(mockConfig as never) const reporter = new SummaryReporter(mockConfig as never)
const summary = reporter.createSummary( const summary = reporter.createSummary(
[{ email: 'test@example.com', pointsEarned: 50, runDuration: 30000 }], [{
email: 'test@example.com',
pointsEarned: 50,
runDuration: 30000,
initialPoints: 500,
finalPoints: 550,
desktopPoints: 30,
mobilePoints: 20
}],
new Date(), new Date(),
new Date() new Date()
) )
assert.ok(summary.startTime instanceof Date, 'startTime should be a Date') assert.ok(summary.startTime instanceof Date, 'startTime should be a Date')
assert.ok(summary.endTime instanceof Date, 'endTime should be a Date') assert.ok(summary.endTime instanceof Date, 'endTime should be a Date')
assert.equal(typeof summary.totalPoints, 'number', 'totalPoints should be a number') assert.equal(typeof summary.totalPoints, 'number', 'totalPoints should be a number')

View File

@@ -1,7 +1,7 @@
import assert from 'node:assert/strict' import assert from 'node:assert/strict'
import test from 'node:test' import test from 'node:test'
import { LoginState, LoginStateDetector } from '../src/util/LoginStateDetector' import { LoginState, LoginStateDetector } from '../src/util/validation/LoginStateDetector'
/** /**
* Tests for LoginStateDetector - login flow state machine * Tests for LoginStateDetector - login flow state machine
@@ -10,6 +10,8 @@ import { LoginState, LoginStateDetector } from '../src/util/LoginStateDetector'
// Type helper for mock Page objects in tests // Type helper for mock Page objects in tests
type MockPage = Parameters<typeof LoginStateDetector.detectState>[0] type MockPage = Parameters<typeof LoginStateDetector.detectState>[0]
const asMockPage = <T>(page: T): MockPage => page as unknown as MockPage
test('LoginState enum contains expected states', () => { test('LoginState enum contains expected states', () => {
assert.ok(LoginState.EmailPage, 'Should have EmailPage state') assert.ok(LoginState.EmailPage, 'Should have EmailPage state')
assert.ok(LoginState.PasswordPage, 'Should have PasswordPage state') assert.ok(LoginState.PasswordPage, 'Should have PasswordPage state')
@@ -60,7 +62,7 @@ test('detectState identifies LoggedIn state on rewards domain', async () => {
evaluate: () => Promise.resolve(200) evaluate: () => Promise.resolve(200)
} }
const detection = await LoginStateDetector.detectState(mockPage as MockPage) const detection = await LoginStateDetector.detectState(asMockPage(mockPage))
assert.equal(detection.state, LoginState.LoggedIn, 'Should detect LoggedIn state') assert.equal(detection.state, LoginState.LoggedIn, 'Should detect LoggedIn state')
assert.equal(detection.confidence, 'high', 'Should have high confidence') assert.equal(detection.confidence, 'high', 'Should have high confidence')
@@ -88,7 +90,7 @@ test('detectState identifies EmailPage state on login.live.com', async () => {
evaluate: () => Promise.resolve(100) evaluate: () => Promise.resolve(100)
} }
const detection = await LoginStateDetector.detectState(mockPage as MockPage) const detection = await LoginStateDetector.detectState(asMockPage(mockPage))
assert.equal(detection.state, LoginState.EmailPage, 'Should detect EmailPage state') assert.equal(detection.state, LoginState.EmailPage, 'Should detect EmailPage state')
assert.equal(detection.confidence, 'high', 'Should have high confidence') assert.equal(detection.confidence, 'high', 'Should have high confidence')
@@ -115,7 +117,7 @@ test('detectState identifies PasswordPage state', async () => {
evaluate: () => Promise.resolve(100) evaluate: () => Promise.resolve(100)
} }
const detection = await LoginStateDetector.detectState(mockPage as MockPage) const detection = await LoginStateDetector.detectState(asMockPage(mockPage))
assert.equal(detection.state, LoginState.PasswordPage, 'Should detect PasswordPage state') assert.equal(detection.state, LoginState.PasswordPage, 'Should detect PasswordPage state')
assert.equal(detection.confidence, 'high', 'Should have high confidence') assert.equal(detection.confidence, 'high', 'Should have high confidence')
@@ -142,7 +144,7 @@ test('detectState identifies TwoFactorRequired state', async () => {
evaluate: () => Promise.resolve(100) evaluate: () => Promise.resolve(100)
} }
const detection = await LoginStateDetector.detectState(mockPage as MockPage) const detection = await LoginStateDetector.detectState(asMockPage(mockPage))
assert.equal(detection.state, LoginState.TwoFactorRequired, 'Should detect TwoFactorRequired state') assert.equal(detection.state, LoginState.TwoFactorRequired, 'Should detect TwoFactorRequired state')
assert.equal(detection.confidence, 'high', 'Should have high confidence') assert.equal(detection.confidence, 'high', 'Should have high confidence')
@@ -170,7 +172,7 @@ test('detectState identifies PasskeyPrompt state', async () => {
evaluate: () => Promise.resolve(100) evaluate: () => Promise.resolve(100)
} }
const detection = await LoginStateDetector.detectState(mockPage as MockPage) const detection = await LoginStateDetector.detectState(asMockPage(mockPage))
assert.equal(detection.state, LoginState.PasskeyPrompt, 'Should detect PasskeyPrompt state') assert.equal(detection.state, LoginState.PasskeyPrompt, 'Should detect PasskeyPrompt state')
assert.equal(detection.confidence, 'high', 'Should have high confidence') assert.equal(detection.confidence, 'high', 'Should have high confidence')
@@ -198,7 +200,7 @@ test('detectState identifies Blocked state', async () => {
evaluate: () => Promise.resolve(100) evaluate: () => Promise.resolve(100)
} }
const detection = await LoginStateDetector.detectState(mockPage as MockPage) const detection = await LoginStateDetector.detectState(asMockPage(mockPage))
assert.equal(detection.state, LoginState.Blocked, 'Should detect Blocked state') assert.equal(detection.state, LoginState.Blocked, 'Should detect Blocked state')
assert.equal(detection.confidence, 'high', 'Should have high confidence') assert.equal(detection.confidence, 'high', 'Should have high confidence')
@@ -216,7 +218,7 @@ test('detectState returns Unknown for ambiguous pages', async () => {
evaluate: () => Promise.resolve(50) evaluate: () => Promise.resolve(50)
} }
const detection = await LoginStateDetector.detectState(mockPage as MockPage) const detection = await LoginStateDetector.detectState(asMockPage(mockPage))
assert.equal(detection.state, LoginState.Unknown, 'Should return Unknown for ambiguous pages') assert.equal(detection.state, LoginState.Unknown, 'Should return Unknown for ambiguous pages')
assert.equal(detection.confidence, 'low', 'Should have low confidence') assert.equal(detection.confidence, 'low', 'Should have low confidence')
@@ -234,7 +236,7 @@ test('detectState handles errors gracefully', async () => {
} }
try { try {
await LoginStateDetector.detectState(mockPage as MockPage) await LoginStateDetector.detectState(asMockPage(mockPage))
assert.fail('Should throw error') assert.fail('Should throw error')
} catch (e) { } catch (e) {
assert.ok(e instanceof Error, 'Should throw Error instance') assert.ok(e instanceof Error, 'Should throw Error instance')

View File

@@ -1,7 +1,7 @@
import test from 'node:test'
import assert from 'node:assert/strict' import assert from 'node:assert/strict'
import test from 'node:test'
import { MobileRetryTracker } from '../src/util/MobileRetryTracker' import { MobileRetryTracker } from '../src/util/state/MobileRetryTracker'
test('MobileRetryTracker stops retries after configured limit', () => { test('MobileRetryTracker stops retries after configured limit', () => {
const tracker = new MobileRetryTracker(2) const tracker = new MobileRetryTracker(2)

View File

@@ -1,7 +1,7 @@
import test from 'node:test'
import assert from 'node:assert/strict' import assert from 'node:assert/strict'
import test from 'node:test'
import { QueryDiversityEngine } from '../src/util/QueryDiversityEngine' import { QueryDiversityEngine } from '../src/util/network/QueryDiversityEngine'
test('QueryDiversityEngine fetches and limits queries', async () => { test('QueryDiversityEngine fetches and limits queries', async () => {
const engine = new QueryDiversityEngine({ const engine = new QueryDiversityEngine({
@@ -13,7 +13,7 @@ test('QueryDiversityEngine fetches and limits queries', async () => {
assert.ok(queries.length > 0, 'Should return at least one query') assert.ok(queries.length > 0, 'Should return at least one query')
assert.ok(queries.length <= 10, 'Should respect count limit') assert.ok(queries.length <= 10, 'Should respect count limit')
assert.ok(queries.every(q => typeof q === 'string' && q.length > 0), 'All queries should be non-empty strings') assert.ok(queries.every((q: string) => typeof q === 'string' && q.length > 0), 'All queries should be non-empty strings')
}) })
test('QueryDiversityEngine deduplicates queries', async () => { test('QueryDiversityEngine deduplicates queries', async () => {

View File

@@ -3,18 +3,8 @@
* Tests intelligent page readiness and element detection * Tests intelligent page readiness and element detection
*/ */
import assert from 'node:assert' import assert from 'node:assert';
import { describe, it } from 'node:test' import { describe, it } from 'node:test';
// Mock Playwright types for testing
type MockPage = {
url: () => string
content: () => Promise<string>
waitForLoadState: (state: string, options?: { timeout: number }) => Promise<void>
waitForTimeout: (ms: number) => Promise<void>
locator: (selector: string) => MockLocator
evaluate: <T>(fn: () => T) => Promise<T>
}
type MockLocator = { type MockLocator = {
waitFor: (options: { state: string; timeout: number }) => Promise<void> waitFor: (options: { state: string; timeout: number }) => Promise<void>
@@ -94,7 +84,7 @@ describe('SmartWait', () => {
mockLogFn('✓ Element found quickly (567ms)') mockLogFn('✓ Element found quickly (567ms)')
assert.strictEqual(logs.length, 2, 'Should capture log messages') assert.strictEqual(logs.length, 2, 'Should capture log messages')
assert.ok(logs[0].includes('1234ms'), 'Should include timing data') assert.ok(logs[0]?.includes('1234ms'), 'Should include timing data')
}) })
it('should extract performance metrics from logs', () => { it('should extract performance metrics from logs', () => {
@@ -102,8 +92,8 @@ describe('SmartWait', () => {
const timeMatch = logMessage.match(/(\d+)ms/) const timeMatch = logMessage.match(/(\d+)ms/)
assert.ok(timeMatch, 'Should include parseable timing') assert.ok(timeMatch, 'Should include parseable timing')
if (timeMatch) { if (timeMatch && timeMatch[1]) {
const time = parseInt(timeMatch[1]) const time = parseInt(timeMatch[1], 10)
assert.ok(time > 0, 'Should extract valid timing') assert.ok(time > 0, 'Should extract valid timing')
} }
}) })