{
  "nodes": [
    {
      "parameters": {
        "httpMethod": "POST",
        "path": "lock-acquire",
        "responseMode": "responseNode",
        "options": {
          "allowedOrigins": "*"
        }
      },
      "type": "n8n-nodes-base.webhook",
      "typeVersion": 2.1,
      "position": [
        -320,
        224
      ],
      "id": "f7314da8-7cf7-479b-b090-9d3ede7b5b18",
      "name": "Lock Acquire Webhook",
      "webhookId": "lock-acquire"
    },
    {
      "parameters": {
        "jsCode": "// ═══ Lock Acquire Handler — no TTL dependency ═══\n//\n// Key changes:\n// 1. Lock check uses is_running (server truth) instead of expires_at\n// 2. Removed expires_at from lock object — lock lives until:\n//    - All tests complete (auto-release in Store Result)\n//    - Explicit release (Lock Release webhook from HTML)\n//    - Abandoned-run safety net (30 min in Heartbeat)\n// 3. kept held_since and last_heartbeat for diagnostics only\n\nconst staticData = $getWorkflowStaticData('global');\nif (!staticData.jobs)      staticData.jobs      = {};\nif (!staticData.testState) staticData.testState = null;\nconst _statusFileName = staticData.statusFileName || 'test-status.json';\n\nconst body      = $input.first().json.body    || {};\nconst clientId  = (body.client_id  || '').trim();\nconst sessionId = (body.session_id || '').trim();\nconst tests     = Array.isArray(body.tests) ? body.tests : [];\nconst now       = Math.floor(Date.now() / 1000);\n\nif (!clientId) {\n  return [{ json: { success: false, error: 'client_id required', _statusCode: 400 } }];\n}\n\nif (tests.length === 0) {\n  return [{ json: { success: false, error: 'tests array required', _statusCode: 400 } }];\n}\n\n// ── Check existing active run ──────────────────────────────\n// Use is_running as the source of truth, not expires_at\nconst existing = staticData.testState;\nif (existing && existing.is_running && existing.lock) {\n  const lk = existing.lock;\n  return [{ json: {\n    success:         false,\n    error:           'Test run already in progress',\n    lock_held_by:    lk.held_by,\n    lock_held_since: lk.held_since,\n    _statusCode:     409\n  }}];\n}\n\n// ── Build testState with all tests as PENDING ──────────────\nconst testList = tests.map(t => ({\n  id:          parseInt(t.id)  || 0,\n  name:        t.name  || '',\n  slug:        t.slug  || '',\n  status:      'PENDING',\n  result:      null,\n  elapsed:     null,\n  started_at:  null,\n  finished_at: null\n}));\n\nconst newState = {\n  session_id:   sessionId || null,\n  is_running:   true,\n  started_at:   now,\n  completed_at: null,\n  lock: {\n    held_by:        clientId,\n    held_since:     now,\n    last_heartbeat: now\n    // NO expires_at — lock lives until run completes or explicit release\n  },\n  tests: testList,\n  summary: {\n    pass: 0, fail: 0, skipped: 0,\n    running: 0, pending: testList.length, total: testList.length\n  },\n  last_updated: now\n};\n\nstaticData.testState = newState;\n\n// ── Write status file ──────────────────────────────────────\nconst fs = require('fs');\ntry {\n  fs.writeFileSync('/tmp/' + _statusFileName, JSON.stringify(newState, null, 2));\n} catch(e) { /* non-fatal */ }\n\nreturn [{ json: {\n  success:    true,\n  session_id: sessionId || null,\n  lock:       newState.lock,\n  test_count: testList.length,\n  _statusFileName,\n  _statusCode: 200\n}}];"
      },
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        -112,
        224
      ],
      "id": "1895edc9-dc69-4f10-9bc5-012fc918023e",
      "name": "Lock Acquire Handler"
    },
    {
      "parameters": {
        "command": "sh /home/node/scripts/upload_status.sh {{ $json._statusFileName || 'test-status.json' }}"
      },
      "type": "n8n-nodes-base.executeCommand",
      "typeVersion": 1,
      "position": [
        112,
        224
      ],
      "id": "5d50baab-7b03-4131-9381-0ae3e25af5cd",
      "name": "Lock Acquire Upload",
      "continueOnFail": true
    },
    {
      "parameters": {
        "respondWith": "json",
        "responseBody": "={{ JSON.stringify({ success: $('Lock Acquire Handler').first().json.success, session_id: $('Lock Acquire Handler').first().json.session_id, lock: $('Lock Acquire Handler').first().json.lock, test_count: $('Lock Acquire Handler').first().json.test_count, error: $('Lock Acquire Handler').first().json.error, lock_held_by: $('Lock Acquire Handler').first().json.lock_held_by, lock_held_since: $('Lock Acquire Handler').first().json.lock_held_since }) }}",
        "options": {
          "responseCode": "={{ $('Lock Acquire Handler').first().json._statusCode }}",
          "responseHeaders": {
            "entries": [
              {
                "name": "Access-Control-Allow-Origin",
                "value": "*"
              },
              {
                "name": "Access-Control-Allow-Headers",
                "value": "Content-Type, Authorization"
              }
            ]
          }
        }
      },
      "type": "n8n-nodes-base.respondToWebhook",
      "typeVersion": 1.1,
      "position": [
        336,
        224
      ],
      "id": "b91c7f81-ef94-41a4-a28c-49dd1c9deb9e",
      "name": "Lock Acquire Response"
    },
    {
      "parameters": {
        "httpMethod": "POST",
        "path": "test-run-async",
        "responseMode": "responseNode",
        "options": {
          "allowedOrigins": "*"
        }
      },
      "type": "n8n-nodes-base.webhook",
      "typeVersion": 2.1,
      "position": [
        -320,
        464
      ],
      "id": "5014151f-3f23-44a6-8f28-1ee4a449edd5",
      "name": "Start Test Webhook",
      "webhookId": "test-run-async"
    },
    {
      "parameters": {
        "jsCode": "// ═══ Generate job_id and mark test as RUNNING ═══\n// New params vs original: test_id (int), test_name (string)\n// These must be sent by HTML in the test-run-async payload.\n\nconst body       = $input.first().json.body || {};\nconst slug       = (body.slug        || '').trim();\nconst sessionId  = (body.session_id  || '').trim();\nconst deviceName = (body.device_name || '').trim();\nconst workflowId = (body.workflow_id || '').trim();\nconst testId     = parseInt(body.test_id)    || null;\nconst testName   = (body.test_name   || '').trim();\n\nif (!slug) {\n  return [{ json: { _done: true, error: 'slug required', _statusCode: 400 } }];\n}\n\nconst staticData = $getWorkflowStaticData('global');\nif (!staticData.jobs) staticData.jobs = {};\nconst _statusFileName = staticData.statusFileName || 'test-status.json';\n\nconst jobId = 'job_' + Date.now() + '_' + Math.random().toString(36).slice(2, 8);\nconst now   = Math.floor(Date.now() / 1000);\n\n// Build target URL from environment variable\nconst rawBase   = ($env['N8N_WEBHOOK_BASE_URL'] || 'http://localhost:5678/webhook').replace(/\\/+$/, '');\nconst targetUrl = rawBase + '/' + slug;\n\nstaticData.jobs[jobId] = {\n  job_id:     jobId,\n  slug,\n  status:     'running',\n  created_at: now,\n  session_id: sessionId || null,\n  test_id:    testId,\n  test_name:  testName,\n  result:     null\n};\n\n// ── Update testState: mark this test RUNNING ───────────────\nconst ts = staticData.testState;\nif (ts && testId) {\n  const entry = ts.tests.find(t => t.id === testId);\n  if (entry) {\n    entry.status     = 'RUNNING';\n    entry.started_at = now;\n  }\n  if (sessionId && !ts.session_id) ts.session_id = sessionId;\n\n  const all    = ts.tests;\n  ts.summary   = {\n    pass:    all.filter(t => t.status === 'PASS').length,\n    fail:    all.filter(t => t.status === 'FAIL').length,\n    skipped: all.filter(t => t.status === 'SKIPPED').length,\n    running: all.filter(t => t.status === 'RUNNING').length,\n    pending: all.filter(t => t.status === 'PENDING').length,\n    total:   all.length\n  };\n  ts.last_updated = now;\n\n  const fs = require('fs');\n  try { fs.writeFileSync('/tmp/' + _statusFileName, JSON.stringify(ts, null, 2)); } catch(e) {}\n}\n\nreturn [{ json: {\n  job_id:          jobId,\n  slug,\n  test_id:         testId,\n  test_name:       testName,\n  status:          'running',\n  target_url:      targetUrl,\n  forward_payload: { session_id: sessionId, device_name: deviceName },\n  _done:           false,\n  _statusFileName,\n  _statusCode:     202\n}}];"
      },
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        -112,
        464
      ],
      "id": "9a5864bb-052f-429a-baa3-97a736a766b8",
      "name": "Generate Job ID"
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "strict"
          },
          "conditions": [
            {
              "id": "done-check",
              "leftValue": "={{ $json._done }}",
              "rightValue": true,
              "operator": {
                "type": "boolean",
                "operation": "notEquals"
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        112,
        464
      ],
      "id": "4d0560bf-ec1c-48aa-9c36-c18847a8dbbc",
      "name": "Valid Request?"
    },
    {
      "parameters": {
        "respondWith": "json",
        "responseBody": "={{ JSON.stringify({ job_id: $json.job_id, status: $json.status, slug: $json.slug }) }}",
        "options": {
          "responseCode": "={{ $json._statusCode }}",
          "responseHeaders": {
            "entries": [
              {
                "name": "Access-Control-Allow-Origin",
                "value": "*"
              },
              {
                "name": "Access-Control-Allow-Headers",
                "value": "Content-Type"
              }
            ]
          }
        }
      },
      "type": "n8n-nodes-base.respondToWebhook",
      "typeVersion": 1.1,
      "position": [
        336,
        368
      ],
      "id": "e8b34ae8-f0ef-45fa-b240-f5358699d58a",
      "name": "Respond 202 Accepted"
    },
    {
      "parameters": {
        "respondWith": "json",
        "responseBody": "={{ JSON.stringify({ error: $json.error }) }}",
        "options": {
          "responseCode": "={{ $json._statusCode }}",
          "responseHeaders": {
            "entries": [
              {
                "name": "Access-Control-Allow-Origin",
                "value": "*"
              },
              {
                "name": "Access-Control-Allow-Headers",
                "value": "Content-Type"
              }
            ]
          }
        }
      },
      "type": "n8n-nodes-base.respondToWebhook",
      "typeVersion": 1.1,
      "position": [
        336,
        592
      ],
      "id": "91b94dd5-d054-4d59-8318-89a869e9c677",
      "name": "Respond Error"
    },
    {
      "parameters": {
        "command": "sh /home/node/scripts/upload_status.sh {{ $json._statusFileName || 'test-status.json' }}"
      },
      "type": "n8n-nodes-base.executeCommand",
      "typeVersion": 1,
      "position": [
        560,
        368
      ],
      "id": "030b1b8a-d3d4-4ad4-b333-d936f5d0ff09",
      "name": "Upload Status Start",
      "continueOnFail": true
    },
    {
      "parameters": {
        "method": "POST",
        "url": "={{ $('Generate Job ID').first().json.target_url }}",
        "sendBody": true,
        "bodyParameters": {
          "parameters": [
            {
              "name": "session_id",
              "value": "={{ $('Generate Job ID').first().json.forward_payload.session_id  || '' }}"
            },
            {
              "name": "device_name",
              "value": "={{ $('Generate Job ID').first().json.forward_payload.device_name || '' }}"
            }
          ]
        },
        "options": {
          "timeout": 48000000
        }
      },
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        784,
        368
      ],
      "id": "2224059b-45e2-4940-b8ad-093a1407aa71",
      "name": "Call Actual Test",
      "executeOnce": true,
      "continueOnFail": true
    },
    {
      "parameters": {
        "jsCode": "// ═══ Store Result + update testState + AUTO-COMPLETE ═══\n//\n// Key change: After storing a test result, check if ALL tests\n// have reached a terminal state. If so, auto-release the lock\n// and mark the run as completed. This removes dependency on\n// browser heartbeat TTL for run lifecycle management.\n\nconst staticData = $getWorkflowStaticData('global');\nif (!staticData.jobs)      staticData.jobs      = {};\nif (!staticData.testState) staticData.testState = null;\nconst _statusFileName = staticData.statusFileName || 'test-status.json';\n\nconst genData   = $('Generate Job ID').first().json;\nconst jobId     = genData.job_id;\nconst testId    = genData.test_id    || null;\nconst rawResult = $input.first().json;\nconst now       = Math.floor(Date.now() / 1000);\n\n// Terminal statuses — test is \"done\" in any of these\nconst TERMINAL = ['PASS', 'FAIL', 'ERROR', 'SKIPPED', 'TIMEOUT'];\n\n// ── Determine result values ────────────────────────────────\nlet testResult = 'ERROR';\nlet teastName  = genData.slug || '';\nlet testActive = '';\nlet sessionId  = genData.forward_payload ? genData.forward_payload.session_id : null;\nlet elapsed    = null;\n\nif (!rawResult.error) {\n  testResult = rawResult.test_result  || rawResult.result || 'UNKNOWN';\n  teastName  = rawResult.teast_name   || rawResult.test_name || genData.slug || '';\n  testActive = rawResult.test_active  || '';\n  sessionId  = rawResult.session_id   || sessionId;\n  elapsed    = rawResult.elapsed      || null;\n}\n\n// ── Update jobs ────────────────────────────────────────────\nif (staticData.jobs[jobId]) {\n  Object.assign(staticData.jobs[jobId], {\n    status:      'done',\n    result:      testResult,\n    teast_name:  teastName,\n    test_active: testActive,\n    session_id:  sessionId,\n    elapsed,\n    finished_at: now\n  });\n}\n\n// ── Update testState ───────────────────────────────────────\nconst ts = staticData.testState;\nlet runAutoCompleted = false;\n\nif (ts && testId) {\n  const entry = ts.tests.find(t => t.id === testId);\n  if (entry) {\n    entry.status      = testResult;\n    entry.result      = testResult;\n    entry.elapsed     = elapsed;\n    entry.finished_at = now;\n  }\n  if (sessionId && !ts.session_id) ts.session_id = sessionId;\n\n  // ── Recompute summary ──────────────────────────────────\n  const all  = ts.tests;\n  ts.summary = {\n    pass:    all.filter(t => t.status === 'PASS').length,\n    fail:    all.filter(t => t.status === 'FAIL').length,\n    skipped: all.filter(t => TERMINAL.includes(t.status) && !['PASS','FAIL'].includes(t.status)).length,\n    running: all.filter(t => t.status === 'RUNNING').length,\n    pending: all.filter(t => t.status === 'PENDING').length,\n    total:   all.length\n  };\n  ts.last_updated = now;\n\n  // ══ AUTO-COMPLETE: if all tests are in terminal state ══\n  const allDone = all.every(t => TERMINAL.includes(t.status));\n  if (allDone && ts.is_running) {\n    ts.is_running   = false;\n    ts.completed_at = now;\n    ts.lock         = null;   // release lock server-side\n    ts.summary.running = 0;\n    ts.summary.pending = 0;\n    runAutoCompleted = true;\n  }\n\n  const fs = require('fs');\n  try { fs.writeFileSync('/tmp/' + _statusFileName, JSON.stringify(ts, null, 2)); } catch(e) {}\n}\n\n// ── Prune old jobs (keep last 100) ─────────────────────────\nconst keys = Object.keys(staticData.jobs);\nif (keys.length > 100) {\n  keys.sort((a, b) => (staticData.jobs[a].created_at || 0) - (staticData.jobs[b].created_at || 0));\n  keys.slice(0, keys.length - 100).forEach(k => delete staticData.jobs[k]);\n}\n\nreturn [{ json: {\n  job_id:             jobId,\n  test_result:        testResult,\n  teast_name:         teastName,\n  test_active:        testActive,\n  session_id:         sessionId,\n  elapsed,\n  stored_at:          now,\n  _statusFileName,\n  run_auto_completed: runAutoCompleted\n}}];"
      },
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1008,
        368
      ],
      "id": "c44829f9-3b34-408e-887e-a7023e6f7713",
      "name": "Store Result"
    },
    {
      "parameters": {
        "command": "sh /home/node/scripts/upload_status.sh {{ $json._statusFileName || 'test-status.json' }}"
      },
      "type": "n8n-nodes-base.executeCommand",
      "typeVersion": 1,
      "position": [
        1216,
        368
      ],
      "id": "7559284d-4260-4ab2-9075-7f3a8b6370c6",
      "name": "Upload Status Result",
      "continueOnFail": true
    },
    {
      "parameters": {
        "httpMethod": "POST",
        "path": "test-check",
        "responseMode": "responseNode",
        "options": {
          "allowedOrigins": "*"
        }
      },
      "type": "n8n-nodes-base.webhook",
      "typeVersion": 2.1,
      "position": [
        -320,
        704
      ],
      "id": "0771536c-662d-4f38-a11f-d5d467172526",
      "name": "Check Status Webhook",
      "webhookId": "test-check"
    },
    {
      "parameters": {
        "jsCode": "// ═══ Check job status ═══\n\nconst staticData = $getWorkflowStaticData('global');\nif (!staticData.jobs) staticData.jobs = {};\n\nconst body  = $input.first().json.body || {};\nconst jobId = (body.job_id || '').trim();\nconst now   = Math.floor(Date.now() / 1000);\n\nif (!jobId) {\n  return [{ json: { error: 'job_id required', _statusCode: 400 } }];\n}\n\nconst job = staticData.jobs[jobId];\nif (!job) {\n  return [{ json: { error: 'job not found', job_id: jobId, _statusCode: 404 } }];\n}\n\nif (job.status === 'done') {\n  return [{ json: {\n    job_id:      jobId,\n    status:      'done',\n    session_id:  job.session_id,\n    teast_name:  job.teast_name,\n    test_active: job.test_active,\n    test_result: job.result,\n    elapsed:     job.elapsed,\n    _statusCode: 200\n  }}];\n}\n\nconst elapsed_seconds = now - job.created_at;\nreturn [{ json: {\n  job_id:           jobId,\n  status:           'running',\n  elapsed_seconds,\n  workflow_status:  'active',\n  _statusCode:      202\n}}];"
      },
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        -112,
        704
      ],
      "id": "ed4134f6-b1f4-415f-9300-ed59624b2bff",
      "name": "Check Job Status"
    },
    {
      "parameters": {
        "respondWith": "json",
        "responseBody": "={{ JSON.stringify( $json._statusCode === 200 ? { job_id: $json.job_id, status: $json.status, session_id: $json.session_id, teast_name: $json.teast_name, test_active: $json.test_active, test_result: $json.test_result, elapsed: $json.elapsed } : { job_id: $json.job_id, status: $json.status, elapsed_seconds: $json.elapsed_seconds, workflow_status: $json.workflow_status, error: $json.error } ) }}",
        "options": {
          "responseCode": "={{ $json._statusCode }}",
          "responseHeaders": {
            "entries": [
              {
                "name": "Access-Control-Allow-Origin",
                "value": "*"
              },
              {
                "name": "Access-Control-Allow-Headers",
                "value": "Content-Type"
              }
            ]
          }
        }
      },
      "type": "n8n-nodes-base.respondToWebhook",
      "typeVersion": 1.1,
      "position": [
        112,
        704
      ],
      "id": "c8414aca-60eb-49b4-9cf1-e082187f3e49",
      "name": "Check Response"
    },
    {
      "parameters": {
        "httpMethod": "POST",
        "path": "lock-release",
        "responseMode": "responseNode",
        "options": {
          "allowedOrigins": "*"
        }
      },
      "type": "n8n-nodes-base.webhook",
      "typeVersion": 2.1,
      "position": [
        -320,
        944
      ],
      "id": "2d893970-e8e4-46f4-9b29-97ba367be20c",
      "name": "Lock Release Webhook",
      "webhookId": "lock-release"
    },
    {
      "parameters": {
        "jsCode": "// ═══ Lock Release Handler ═══\n// Payload: { client_id }\n// Releases lock, marks only PENDING tests as SKIPPED.\n// RUNNING tests are left alone — Store Result will handle them\n// and auto-complete the run when the last one finishes.\n\nconst staticData = $getWorkflowStaticData('global');\nif (!staticData.testState) staticData.testState = null;\nconst _statusFileName = staticData.statusFileName || 'test-status.json';\n\nconst body     = $input.first().json.body || {};\nconst clientId = (body.client_id || '').trim();\nconst now      = Math.floor(Date.now() / 1000);\n\nif (!clientId) {\n  return [{ json: { success: false, error: 'client_id required', _statusCode: 400 } }];\n}\n\nconst ts = staticData.testState;\n\n// No active lock for this client — idempotent success\nif (!ts || !ts.lock || ts.lock.held_by !== clientId) {\n  return [{ json: { success: true, message: 'No active lock held by this client', _statusFileName, _statusCode: 200 } }];\n}\n\n// ── Release lock ───────────────────────────────────────────\nts.lock         = null;\nts.last_updated = now;\n\n// Mark only PENDING tests as SKIPPED (they'll never start).\n// RUNNING tests are left alone — Store Result will handle them\n// and auto-complete the run when the last one finishes.\nlet hasRunning = false;\nts.tests.forEach(t => {\n  if (t.status === 'PENDING') {\n    t.status      = 'SKIPPED';\n    t.finished_at = now;\n  }\n  if (t.status === 'RUNNING') {\n    hasRunning = true;\n  }\n});\n\n// Only mark run as complete if nothing is still running.\n// If a test is still RUNNING, Store Result will set these\n// when that test finishes (auto-complete logic).\nif (!hasRunning) {\n  ts.is_running   = false;\n  ts.completed_at = now;\n}\n\n// Recompute summary\nconst all  = ts.tests;\nts.summary = {\n  pass:    all.filter(t => t.status === 'PASS').length,\n  fail:    all.filter(t => t.status === 'FAIL').length,\n  skipped: all.filter(t => ['SKIPPED','TIMEOUT','ERROR'].includes(t.status)).length,\n  running: all.filter(t => t.status === 'RUNNING').length,\n  pending: 0,\n  total:   all.length\n};\n\nconst fs = require('fs');\ntry { fs.writeFileSync('/tmp/' + _statusFileName, JSON.stringify(ts, null, 2)); } catch(e) {}\n\nreturn [{ json: {\n  success:      true,\n  message:      hasRunning ? 'Lock released, waiting for running test(s) to finish' : 'Lock released, run completed',\n  has_running:  hasRunning,\n  completed_at: hasRunning ? null : now,\n  summary:      ts.summary,\n  _statusFileName,\n  _statusCode:  200\n}}];"
      },
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        -112,
        944
      ],
      "id": "1dcf494f-5b06-4368-8537-820ec7c4e190",
      "name": "Lock Release Handler"
    },
    {
      "parameters": {
        "command": "sh /home/node/scripts/upload_status.sh {{ $json._statusFileName || 'test-status.json' }}"
      },
      "type": "n8n-nodes-base.executeCommand",
      "typeVersion": 1,
      "position": [
        112,
        944
      ],
      "id": "45bda054-ab1d-4234-a298-88a9f5606ab3",
      "name": "Lock Release Upload",
      "continueOnFail": true
    },
    {
      "parameters": {
        "respondWith": "json",
        "responseBody": "={{ JSON.stringify({ success: $('Lock Release Handler').first().json.success, message: $('Lock Release Handler').first().json.message, completed_at: $('Lock Release Handler').first().json.completed_at, summary: $('Lock Release Handler').first().json.summary, error: $('Lock Release Handler').first().json.error }) }}",
        "options": {
          "responseCode": "={{ $('Lock Release Handler').first().json._statusCode }}",
          "responseHeaders": {
            "entries": [
              {
                "name": "Access-Control-Allow-Origin",
                "value": "*"
              },
              {
                "name": "Access-Control-Allow-Headers",
                "value": "Content-Type, Authorization"
              }
            ]
          }
        }
      },
      "type": "n8n-nodes-base.respondToWebhook",
      "typeVersion": 1.1,
      "position": [
        336,
        944
      ],
      "id": "875a871a-5585-4f24-b896-34b55463f5b0",
      "name": "Lock Release Response"
    },
    {
      "parameters": {
        "httpMethod": "POST",
        "path": "session-heartbeat",
        "responseMode": "responseNode",
        "options": {
          "allowedOrigins": "*"
        }
      },
      "type": "n8n-nodes-base.webhook",
      "typeVersion": 2.1,
      "position": [
        -320,
        1184
      ],
      "id": "bc14511e-ddc2-4d85-9396-179e925badb1",
      "name": "Heartbeat Webhook",
      "webhookId": "session-heartbeat"
    },
    {
      "parameters": {
        "jsCode": "// ═══ Session Heartbeat — READ-ONLY (no destructive mutations) ═══\n//\n// Key changes vs previous version:\n// 1. REMOVED: Lock auto-expiry based on expires_at < now\n//    This was the root cause of observer heartbeats killing active runs\n//    when the runner's browser tab was throttled.\n// 2. Lock lifecycle is now managed by:\n//    - Store Result (auto-completes when all tests finish)\n//    - Lock Release (explicit release from HTML beforeunload / stop)\n// 3. ADDED: Conservative 30-minute abandoned-run safety net\n//    Only cleans up if is_running=true but last_updated is stale 30+ min\n//    AND no test is currently RUNNING. This handles crashed browsers.\n\nconst staticData = $getWorkflowStaticData('global');\nif (!staticData.viewers)   staticData.viewers   = {};\nif (!staticData.jobs)      staticData.jobs      = {};\nif (!staticData.testState) staticData.testState = null;\nconst _statusFileName = staticData.statusFileName || 'test-status.json';\n\nconst body     = $input.first().json.body || {};\nconst clientId = (body.client_id || '').trim();\nconst now      = Math.floor(Date.now() / 1000);\n\nif (!clientId) {\n  return [{ json: { error: 'client_id required', _statusCode: 400 } }];\n}\n\nconst VIEWER_TTL       = 60;    // 1 minute — prune stale viewers\nconst ABANDON_TIMEOUT  = 1800;  // 30 minutes — safety net for crashed runners\n\nconst ts       = staticData.testState;\nconst isRunner = !!(ts && ts.lock && ts.lock.held_by === clientId);\n\n// ── 1. Register / refresh this viewer ─────────────────────\nstaticData.viewers[clientId] = { last_seen: now, is_runner: isRunner };\n\n// ── 2. Prune stale viewers ─────────────────────────────────\nObject.keys(staticData.viewers).forEach(id => {\n  if (now - staticData.viewers[id].last_seen > VIEWER_TTL) delete staticData.viewers[id];\n});\n\n// ── 3. Refresh last_heartbeat if this client is the runner ─\n//    (informational only — not used for lock expiry anymore)\nif (ts && ts.lock && ts.lock.held_by === clientId) {\n  ts.lock.last_heartbeat = now;\n  ts.last_updated        = now;\n}\n\n// ── 4. Abandoned-run safety net (30 min, very conservative) ─\n//    Only triggers if:\n//    - is_running is true\n//    - last_updated is stale by 30+ minutes\n//    - No test is currently in RUNNING state\n//    This handles the edge case where browser crashed with no\n//    beforeunload, AND the test workflow also crashed/hung.\nif (ts && ts.is_running && ts.last_updated) {\n  const staleDuration  = now - ts.last_updated;\n  const hasRunningTest = ts.tests.some(t => t.status === 'RUNNING');\n\n  if (staleDuration > ABANDON_TIMEOUT && !hasRunningTest) {\n    ts.lock         = null;\n    ts.is_running   = false;\n    ts.completed_at = now;\n    ts.last_updated = now;\n\n    ts.tests.forEach(t => {\n      if (t.status === 'PENDING') {\n        t.status      = 'SKIPPED';\n        t.finished_at = now;\n      }\n    });\n\n    const all  = ts.tests;\n    ts.summary = {\n      pass:    all.filter(t => t.status === 'PASS').length,\n      fail:    all.filter(t => t.status === 'FAIL').length,\n      skipped: all.filter(t => ['SKIPPED','TIMEOUT','ERROR'].includes(t.status)).length,\n      running: 0,\n      pending: 0,\n      total:   all.length\n    };\n\n    const fs = require('fs');\n    try { fs.writeFileSync('/tmp/' + _statusFileName, JSON.stringify(ts, null, 2)); } catch(e) {}\n  }\n}\n\n// ── 5. Build response ──────────────────────────────────────\nconst viewerCount = Object.keys(staticData.viewers).length;\nconst lockState   = (ts && ts.lock) ? {\n  active:     true,\n  held_by:    ts.lock.held_by,\n  session_id: ts.session_id,\n  held_since: ts.lock.held_since\n} : { active: false };\n\nconst responseBody = JSON.stringify({\n  ok:               true,\n  viewer_count:     viewerCount,\n  is_runner:        isRunner,\n  lock:             lockState,\n  test_state:       ts || null,\n  server_time:      now,\n  status_file_name: _statusFileName\n});\n\nreturn [{ json: {\n  _response_body: responseBody,\n  _statusCode:    200\n}}];"
      },
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        -112,
        1184
      ],
      "id": "6f874986-5e7a-4ded-be49-1b3638eb6059",
      "name": "Heartbeat Handler"
    },
    {
      "parameters": {
        "respondWith": "json",
        "responseBody": "={{ $json._response_body }}",
        "options": {
          "responseCode": "={{ $json._statusCode }}",
          "responseHeaders": {
            "entries": [
              {
                "name": "Access-Control-Allow-Origin",
                "value": "*"
              },
              {
                "name": "Access-Control-Allow-Headers",
                "value": "Content-Type, Authorization"
              }
            ]
          }
        }
      },
      "type": "n8n-nodes-base.respondToWebhook",
      "typeVersion": 1.1,
      "position": [
        112,
        1184
      ],
      "id": "70b7a888-4937-49ff-bf3b-2e6e661d90b2",
      "name": "Heartbeat Response"
    },
    {
      "parameters": {
        "jsCode": "// ═══ TEMPORARY — Force a test back to RUNNING ═══\n// Works via file directly (no staticData dependency)\n// Fire once, then delete.\n\nconst fs = require('fs');\nconst TARGET_ID = 13;\nconst staticData = $getWorkflowStaticData('global');\nconst _statusFileName = staticData.statusFileName || 'test-status.json';\n\nlet ts = JSON.parse(fs.readFileSync('/tmp/' + _statusFileName, 'utf8'));\ntry {\n  fs.writeFileSync('/tmp/' + _statusFileName, JSON.stringify(ts, null, 2));\n} catch(e) {\n  return [{ json: { error: 'Cannot read status file: ' + e.message } }];\n}\n\nconst entry = ts.tests.find(t => t.id === TARGET_ID);\nif (!entry) return [{ json: { error: 'Test ID ' + TARGET_ID + ' not found in file' } }];\n\nconst before = entry.status;\n\nentry.status      = 'RUNNING';\nentry.result      = null;\nentry.finished_at = null;\n\nts.is_running   = true;\nts.completed_at = null;\nts.last_updated = Math.floor(Date.now() / 1000);\n\n// Recompute summary\nconst all  = ts.tests;\nts.summary = {\n  pass:    all.filter(t => t.status === 'PASS').length,\n  fail:    all.filter(t => t.status === 'FAIL').length,\n  skipped: all.filter(t => ['SKIPPED','TIMEOUT','ERROR'].includes(t.status)).length,\n  running: all.filter(t => t.status === 'RUNNING').length,\n  pending: all.filter(t => t.status === 'PENDING').length,\n  total:   all.length\n};\n\nfs.writeFileSync('/tmp/test-status.json', JSON.stringify(ts, null, 2));\n\nreturn [{ json: {\n  fixed:   true,\n  test_id: TARGET_ID,\n  before,\n  after:   'RUNNING',\n  statusFileName: _statusFileName,\n  summary: ts.summary\n} }];"
      },
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        2064,
        1104
      ],
      "id": "609e1497-16a8-4147-b09d-3f54d1c3364b",
      "name": "Code in JavaScript"
    },
    {
      "parameters": {
        "command": "sh /home/node/scripts/upload_status.sh {{ $json._statusFileName || 'test-status.json' }}"
      },
      "type": "n8n-nodes-base.executeCommand",
      "typeVersion": 1,
      "position": [
        2288,
        1104
      ],
      "id": "5d36d92e-e71f-4f35-9b32-ed8dc8659d32",
      "name": "Upload Status Result1",
      "continueOnFail": true
    },
    {
      "parameters": {
        "httpMethod": "POST",
        "path": "fix-status",
        "options": {}
      },
      "type": "n8n-nodes-base.webhook",
      "typeVersion": 2.1,
      "position": [
        1840,
        1104
      ],
      "id": "8a6db94b-fb93-4a27-b929-b0d336121814",
      "name": "Webhook",
      "webhookId": "a039bf9b-bed0-46d9-9aef-8ca10ae79772"
    },
    {
      "parameters": {
        "jsCode": "// ═══ Admin Set Status File Name ═══\n// Payload: { filename }\n// Saves the status file name to staticData.\n// Only validates the filename format — auth is handled by the HTML\n// (admin token required in the UI before this endpoint is callable).\n\nconst staticData = $getWorkflowStaticData('global');\n\nconst body     = $input.first().json.body || {};\nconst filename = (body.filename || '').trim();\n\n// ── Validate ───────────────────────────────────────────────\nif (!filename) {\n  // Empty means \"reset to default\"\n  delete staticData.statusFileName;\n  return [{ json: {\n    success:  true,\n    filename: 'test-status.json',\n    message:  'Reset to default',\n    _statusCode: 200\n  }}];\n}\n\n// Basic safety: only allow alphanumeric, dashes, underscores, dots\n// Must end with .json\nif (!/^[a-zA-Z0-9_-]+\\.json$/.test(filename)) {\n  return [{ json: {\n    success: false,\n    error:   'Invalid filename. Use alphanumeric/dash/underscore, must end with .json',\n    _statusCode: 400\n  }}];\n}\n\nif (filename.length > 60) {\n  return [{ json: {\n    success: false,\n    error:   'Filename too long (max 60 chars)',\n    _statusCode: 400\n  }}];\n}\n\nstaticData.statusFileName = filename;\n\nreturn [{ json: {\n  success:  true,\n  filename: filename,\n  message:  'Status file name updated',\n  _statusCode: 200\n}}];"
      },
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        -112,
        1392
      ],
      "id": "77b6e5dd-a250-47eb-aa52-76551a73c94d",
      "name": "Admin Set Status File Handler"
    },
    {
      "parameters": {
        "respondWith": "json",
        "responseBody": "={{ $json._response_body }}",
        "options": {
          "responseCode": "={{ $json._statusCode }}",
          "responseHeaders": {
            "entries": [
              {
                "name": "Access-Control-Allow-Origin",
                "value": "*"
              },
              {
                "name": "Access-Control-Allow-Headers",
                "value": "Content-Type, Authorization"
              }
            ]
          }
        }
      },
      "type": "n8n-nodes-base.respondToWebhook",
      "typeVersion": 1.1,
      "position": [
        112,
        1392
      ],
      "id": "ad06db5b-16cc-4beb-a49d-e9e20dc58bf2",
      "name": "Admin Set Status File Response"
    },
    {
      "parameters": {
        "httpMethod": "POST",
        "path": "admin-get-status-file",
        "responseMode": "responseNode",
        "options": {}
      },
      "type": "n8n-nodes-base.webhook",
      "typeVersion": 2.1,
      "position": [
        -320,
        1600
      ],
      "id": "5470169e-3abc-4859-9323-5380b2aa7919",
      "name": "Admin Get Status File Webhook",
      "webhookId": "a039bf9b-bed0-46d9-9aef-8ca10ae79772"
    },
    {
      "parameters": {
        "httpMethod": "POST",
        "path": "admin-set-status-file",
        "responseMode": "responseNode",
        "options": {}
      },
      "type": "n8n-nodes-base.webhook",
      "typeVersion": 2.1,
      "position": [
        -320,
        1392
      ],
      "id": "0fb129fd-8a55-4af2-a41a-996ebd96839e",
      "name": "Admin set status file Webhook",
      "webhookId": "a039bf9b-bed0-46d9-9aef-8ca10ae79772"
    },
    {
      "parameters": {
        "jsCode": "// ═══ Get current status file name ═══\nconst staticData = $getWorkflowStaticData('global');\n\nreturn [{ json: {\n  success:  true,\n  filename: staticData.statusFileName || 'test-status.json',\n  is_default: !staticData.statusFileName,\n  _statusCode: 200\n}}];"
      },
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        -112,
        1600
      ],
      "id": "3be4c738-a9cb-4632-b66d-05f6fdc0c926",
      "name": "Admin Get Status File Handler"
    },
    {
      "parameters": {
        "respondWith": "json",
        "responseBody": "={{ $json._response_body }}",
        "options": {
          "responseCode": "={{ $json._statusCode }}",
          "responseHeaders": {
            "entries": [
              {
                "name": "Access-Control-Allow-Origin",
                "value": "*"
              },
              {
                "name": "Access-Control-Allow-Headers",
                "value": "Content-Type, Authorization"
              }
            ]
          }
        }
      },
      "type": "n8n-nodes-base.respondToWebhook",
      "typeVersion": 1.1,
      "position": [
        112,
        1600
      ],
      "id": "e1721fdb-aab3-4c5f-9f0a-7ca3291048b6",
      "name": "Admin Get Status File Response"
    }
  ],
  "connections": {
    "Lock Acquire Webhook": {
      "main": [
        [
          {
            "node": "Lock Acquire Handler",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Lock Acquire Handler": {
      "main": [
        [
          {
            "node": "Lock Acquire Upload",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Lock Acquire Upload": {
      "main": [
        [
          {
            "node": "Lock Acquire Response",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Start Test Webhook": {
      "main": [
        [
          {
            "node": "Generate Job ID",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Generate Job ID": {
      "main": [
        [
          {
            "node": "Valid Request?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Valid Request?": {
      "main": [
        [
          {
            "node": "Respond 202 Accepted",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Respond Error",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Respond 202 Accepted": {
      "main": [
        [
          {
            "node": "Upload Status Start",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Upload Status Start": {
      "main": [
        [
          {
            "node": "Call Actual Test",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Call Actual Test": {
      "main": [
        [
          {
            "node": "Store Result",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Store Result": {
      "main": [
        [
          {
            "node": "Upload Status Result",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Check Status Webhook": {
      "main": [
        [
          {
            "node": "Check Job Status",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Check Job Status": {
      "main": [
        [
          {
            "node": "Check Response",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Lock Release Webhook": {
      "main": [
        [
          {
            "node": "Lock Release Handler",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Lock Release Handler": {
      "main": [
        [
          {
            "node": "Lock Release Upload",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Lock Release Upload": {
      "main": [
        [
          {
            "node": "Lock Release Response",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Heartbeat Webhook": {
      "main": [
        [
          {
            "node": "Heartbeat Handler",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Heartbeat Handler": {
      "main": [
        [
          {
            "node": "Heartbeat Response",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Code in JavaScript": {
      "main": [
        [
          {
            "node": "Upload Status Result1",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Webhook": {
      "main": [
        [
          {
            "node": "Code in JavaScript",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Admin Set Status File Handler": {
      "main": [
        [
          {
            "node": "Admin Set Status File Response",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Admin Get Status File Webhook": {
      "main": [
        [
          {
            "node": "Admin Get Status File Handler",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Admin set status file Webhook": {
      "main": [
        [
          {
            "node": "Admin Set Status File Handler",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Admin Get Status File Handler": {
      "main": [
        [
          {
            "node": "Admin Get Status File Response",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "pinData": {
    "Heartbeat Webhook": [
      {
        "headers": {
          "connection": "upgrade",
          "host": "dev-test.local",
          "x-real-ip": "192.168.1.186",
          "x-forwarded-for": "192.168.1.186",
          "x-forwarded-proto": "https",
          "x-forwarded-host": "dev-test.local",
          "x-forwarded-port": "443",
          "content-length": "45",
          "user-agent": "Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:148.0) Gecko/20100101 Firefox/148.0",
          "accept": "*/*",
          "accept-language": "en-US,en;q=0.9",
          "accept-encoding": "gzip, deflate, br, zstd",
          "content-type": "application/json",
          "origin": "null",
          "sec-fetch-dest": "empty",
          "sec-fetch-mode": "cors",
          "sec-fetch-site": "cross-site",
          "priority": "u=4"
        },
        "params": {},
        "query": {},
        "body": {
          "client_id": "cl_vm430bxibuo2doz",
          "token": ""
        },
        "webhookUrl": "http://localhost:5678/webhook/session-heartbeat",
        "executionMode": "production"
      }
    ]
  },
  "meta": {
    "instanceId": "27b239c52b459d8212719c67a4c194acfc86902b0c17a1abc2e1d6409dd5d73c"
  }
}
