const test = require('node:test');
const assert = require('node:assert/strict');
const path = require('node:path');
const Module = require('node:module');
const { EventEmitter } = require('node:events');

require('ts-node/register/transpile-only');

const originalModuleLoad = Module._load;
const pluginNodeExecutorModulePath = path.resolve(__dirname, 'plugin-node-executor.ts');

// The executor verifies grants against the on-disk built-in plugin manifest.
// That manifest is a build artifact (only present after the frontend/plugin build),
// so the unit test stubs the manifest read instead of depending on built assets.
const BUILT_IN_PLUGIN_MANIFEST = {
  id: 'sync-md',
  name: 'Sync.md',
  version: '1.0.0',
  permissions: ['nodeExecution'],
};

const isBuiltInManifestPath = (filePath) =>
  typeof filePath === 'string' &&
  filePath.replace(/\\/g, '/').endsWith(`${BUILT_IN_PLUGIN_MANIFEST.id}/manifest.json`);

let ipcHandlers;
let dialogCalls;
let nextDialogResult;
let nextDialogPromise;
// In-memory stand-in for the main-owned persisted consent store (plugin-node-consent-store.ts),
// so the executor's Phase 2 ask-once logic can be tested without touching the filesystem.
let consentStore;
// Recorded (command, args, options) tuples from the stubbed child_process.spawn.
let spawnCalls;

class FakeWebContents extends EventEmitter {
  constructor(id, url = 'app://index.html') {
    super();
    this.id = id;
    this._url = url;
    this._isDestroyed = false;
    this.window = { id: `window-${id}` };
  }

  getURL() {
    return this._url;
  }

  isDestroyed() {
    return this._isDestroyed;
  }

  navigate(url) {
    this._url = url;
    this.emit('will-navigate', {}, url);
    this.emit('did-navigate', {}, url);
  }

  startNavigation(url, isInPlace = false, isMainFrame = true) {
    this._url = url;
    this.emit('did-start-navigation', {}, url, isInPlace, isMainFrame);
  }

  destroy() {
    this._isDestroyed = true;
    this.emit('destroyed');
  }
}

const resetModule = () => {
  for (const key of Object.keys(require.cache)) {
    if (
      key === pluginNodeExecutorModulePath ||
      key.endsWith('/electron/plugin-node-executor.ts') ||
      key.endsWith('/electron/plugin-node-executor.js')
    ) {
      delete require.cache[key];
    }
  }
};

const installMocks = () => {
  Module._load = function patchedLoad(request, parent, isMain) {
    // Stub the built-in plugin manifest read, scoped to the executor module so
    // every other `require('fs')` (ts-node, node:test, ...) keeps the real fs.
    if (
      request === 'fs' &&
      parent &&
      typeof parent.filename === 'string' &&
      parent.filename.endsWith('plugin-node-executor.ts')
    ) {
      const realFs = originalModuleLoad.call(this, request, parent, isMain);
      return {
        ...realFs,
        existsSync: (filePath) =>
          isBuiltInManifestPath(filePath) ? true : realFs.existsSync(filePath),
        readFileSync: (filePath, ...rest) =>
          isBuiltInManifestPath(filePath)
            ? JSON.stringify(BUILT_IN_PLUGIN_MANIFEST)
            : realFs.readFileSync(filePath, ...rest),
      };
    }

    // Stub the persisted-consent store so Phase 2 ask-once logic is exercised in-memory.
    if (
      request === './plugin-node-consent-store' &&
      parent &&
      typeof parent.filename === 'string' &&
      parent.filename.endsWith('plugin-node-executor.ts')
    ) {
      return consentStore;
    }

    // Stub child_process so the spawn-path tests can assert the resolved binary and
    // env without launching a real process.
    if (
      request === 'child_process' &&
      parent &&
      typeof parent.filename === 'string' &&
      parent.filename.endsWith('plugin-node-executor.ts')
    ) {
      return {
        spawn: (command, spawnArgs, options) => {
          spawnCalls.push([command, spawnArgs, options]);
          const child = new EventEmitter();
          child.stdout = new EventEmitter();
          child.stderr = new EventEmitter();
          child.killed = false;
          child.kill = () => {
            child.killed = true;
          };
          setImmediate(() => {
            child.stdout.emit('data', JSON.stringify({ __result: 42 }));
            child.emit('close', 0);
          });
          return child;
        },
      };
    }

    if (request === 'electron-log/main') {
      return { log: () => {}, error: () => {} };
    }

    if (request === 'electron') {
      return {
        app: {
          isPackaged: false,
        },
        BrowserWindow: {
          fromWebContents: (webContents) => webContents.window,
        },
        dialog: {
          showMessageBox: async (...args) => {
            dialogCalls.push(args);
            if (nextDialogPromise) {
              return nextDialogPromise;
            }
            return nextDialogResult;
          },
        },
        ipcMain: {
          handle: (eventName, handler) => {
            ipcHandlers.set(eventName, handler);
          },
        },
      };
    }

    return originalModuleLoad.call(this, request, parent, isMain);
  };
};

const loadModule = () => {
  resetModule();
  return require(pluginNodeExecutorModulePath);
};

const callIpc = (channel, sender, ...args) => {
  const handler = ipcHandlers.get(channel);
  assert.equal(typeof handler, 'function');
  return handler({ sender }, ...args);
};

test.beforeEach(() => {
  ipcHandlers = new Map();
  dialogCalls = [];
  spawnCalls = [];
  nextDialogResult = { response: 0 };
  nextDialogPromise = undefined;
  const records = new Map();
  consentStore = {
    __records: records,
    getNodeExecutionConsent: async (pluginId) => records.get(pluginId) ?? null,
    setNodeExecutionConsent: async (pluginId, consent) => {
      records.set(pluginId, consent);
    },
    clearNodeExecutionConsent: async (pluginId) => {
      records.delete(pluginId);
    },
  };
  installMocks();
});

test.afterEach(() => {
  Module._load = originalModuleLoad;
  resetModule();
});

test('issues a main-owned token and reuses it for the same webContents', async () => {
  loadModule();
  const webContents = new FakeWebContents(1);

  const firstGrant = await callIpc(
    'PLUGIN_REQUEST_NODE_EXECUTION_GRANT',
    webContents,
    'sync-md',
  );
  const secondGrant = await callIpc(
    'PLUGIN_REQUEST_NODE_EXECUTION_GRANT',
    webContents,
    'sync-md',
  );

  assert.equal(typeof firstGrant.token, 'string');
  assert.equal(secondGrant.token, firstGrant.token);
  assert.equal(dialogCalls.length, 1);
});

test('requires the issuing webContents and token for script execution', async () => {
  loadModule();
  const webContents = new FakeWebContents(2);
  const otherWebContents = new FakeWebContents(3);

  const grant = await callIpc(
    'PLUGIN_REQUEST_NODE_EXECUTION_GRANT',
    webContents,
    'sync-md',
  );
  const result = await callIpc(
    'PLUGIN_EXEC_NODE_SCRIPT',
    webContents,
    'sync-md',
    grant.token,
    { script: 'return args[0] + 1;', args: [1] },
  );

  assert.equal(result.success, true);
  assert.equal(result.result, 2);
  assert.equal(typeof result.executionTime, 'number');
  await assert.rejects(
    () =>
      callIpc('PLUGIN_EXEC_NODE_SCRIPT', otherWebContents, 'sync-md', grant.token, {
        script: 'return true;',
      }),
    /not authorized/,
  );
});

test('does not mint a token when the sender navigates while consent is pending', async () => {
  loadModule();
  const webContents = new FakeWebContents(5);
  let resolveDialog;
  nextDialogPromise = new Promise((resolve) => {
    resolveDialog = resolve;
  });

  const grantPromise = callIpc(
    'PLUGIN_REQUEST_NODE_EXECUTION_GRANT',
    webContents,
    'sync-md',
  );
  assert.equal(dialogCalls.length, 1);

  webContents.navigate('http://localhost:4200/after-navigation');
  resolveDialog({ response: 0 });

  await assert.doesNotReject(grantPromise);
  assert.equal(await grantPromise, null);
});

test('revoke is scoped to the issuing webContents', async () => {
  loadModule();
  const webContents = new FakeWebContents(6);
  const otherWebContents = new FakeWebContents(7);

  const grant = await callIpc(
    'PLUGIN_REQUEST_NODE_EXECUTION_GRANT',
    webContents,
    'sync-md',
  );
  await callIpc(
    'PLUGIN_REVOKE_NODE_EXECUTION_GRANT',
    otherWebContents,
    'sync-md',
    grant.token,
  );
  await callIpc('PLUGIN_EXEC_NODE_SCRIPT', webContents, 'sync-md', grant.token, {
    script: 'return true;',
  });

  await callIpc(
    'PLUGIN_REVOKE_NODE_EXECUTION_GRANT',
    webContents,
    'sync-md',
    grant.token,
  );
  await assert.rejects(
    () =>
      callIpc('PLUGIN_EXEC_NODE_SCRIPT', webContents, 'sync-md', grant.token, {
        script: 'return true;',
      }),
    /not authorized/,
  );
});

test('issues a grant to an uploaded (non-bundled) plugin and labels it unverified', async () => {
  loadModule();
  const webContents = new FakeWebContents(10);

  const grant = await callIpc(
    'PLUGIN_REQUEST_NODE_EXECUTION_GRANT',
    webContents,
    'uploaded-node-plugin',
    { name: 'Uploaded Node Plugin', version: '1.2.3' },
  );

  assert.equal(typeof grant.token, 'string');
  assert.equal(dialogCalls.length, 1);
  const opts = dialogCalls[0][1];
  // Dialog anchors on the validated id and flags the self-declared name as unverified,
  // and defaults to Deny.
  assert.match(opts.detail, /Plugin ID: uploaded-node-plugin/);
  assert.match(opts.detail, /self-declared, unverified/);
  // The Allow is persisted (ask-once), so the prompt must disclose that the choice is
  // remembered rather than claiming it is only valid "for this app session".
  assert.match(opts.detail, /remembered on this device/);
  assert.equal(opts.detail.includes('for this app session'), false);
  assert.equal(opts.defaultId, 1);
});

test('uploaded plugin executes after grant; a denied request leaves exec unauthorized', async () => {
  loadModule();
  const allowWc = new FakeWebContents(11);
  const grant = await callIpc(
    'PLUGIN_REQUEST_NODE_EXECUTION_GRANT',
    allowWc,
    'uploaded-node-plugin',
    { name: 'Uploaded', version: '1.0.0' },
  );
  const okResult = await callIpc(
    'PLUGIN_EXEC_NODE_SCRIPT',
    allowWc,
    'uploaded-node-plugin',
    grant.token,
    { script: 'return args[0] + 1;', args: [4] },
  );
  assert.equal(okResult.success, true);
  assert.equal(okResult.result, 5);

  // A denied request mints no token, so exec stays unauthorized.
  nextDialogResult = { response: 1 };
  const denyWc = new FakeWebContents(12);
  const denied = await callIpc(
    'PLUGIN_REQUEST_NODE_EXECUTION_GRANT',
    denyWc,
    'denied-plugin',
    { name: 'Denied', version: '1.0.0' },
  );
  assert.equal(denied, null);
  await assert.rejects(
    () =>
      callIpc('PLUGIN_EXEC_NODE_SCRIPT', denyWc, 'denied-plugin', 'made-up-token', {
        script: 'return true;',
      }),
    /not authorized/,
  );
});

test('accepts a non-kebab uploaded id (dots/uppercase) the built-in rule would reject', async () => {
  loadModule();
  const webContents = new FakeWebContents(13);

  const grant = await callIpc(
    'PLUGIN_REQUEST_NODE_EXECUTION_GRANT',
    webContents,
    'Community.Plugin-2',
    { name: 'Community Plugin', version: '2.0.0' },
  );

  assert.equal(typeof grant.token, 'string');
});

test('rejects unsafe ids and sanitizes self-declared display strings', async () => {
  loadModule();
  const webContents = new FakeWebContents(14);

  // A newline in the id is rejected outright (it is a grant Map key + dialog text).
  await assert.rejects(
    () =>
      callIpc('PLUGIN_REQUEST_NODE_EXECUTION_GRANT', webContents, 'evil\nid', {
        name: 'x',
        version: '1',
      }),
    /Invalid pluginId/,
  );

  // Path separators / dot-segments are rejected (the id is used as a path component in
  // the bundled-manifest existsSync probe).
  for (const badId of ['../../etc', '..', '.', 'a/b', 'a\\b']) {
    await assert.rejects(
      () =>
        callIpc('PLUGIN_REQUEST_NODE_EXECUTION_GRANT', webContents, badId, {
          name: 'x',
          version: '1',
        }),
      /Invalid pluginId/,
    );
  }

  // A crafted name cannot inject an extra dialog line and is length-capped.
  const craftedName = `${'A'.repeat(500)}\nVerified by Super Productivity`;
  const grant = await callIpc(
    'PLUGIN_REQUEST_NODE_EXECUTION_GRANT',
    webContents,
    'crafted-name-plugin',
    { name: craftedName, version: '1.0.0' },
  );
  assert.equal(typeof grant.token, 'string');
  const detail = dialogCalls[dialogCalls.length - 1][1].detail;
  assert.equal(detail.includes('Verified by Super Productivity'), false);
  assert.ok(detail.includes('…'));
});

test('revoke from the issuing webContents drops the grant even without the token', async () => {
  loadModule();
  const webContents = new FakeWebContents(15);
  const grant = await callIpc(
    'PLUGIN_REQUEST_NODE_EXECUTION_GRANT',
    webContents,
    'uploaded-node-plugin',
    { name: 'Uploaded', version: '1.0.0' },
  );

  // Teardown/re-upload revokes by id without resupplying the token, so a re-uploaded
  // plugin reusing the id cannot inherit this live grant.
  await callIpc(
    'PLUGIN_REVOKE_NODE_EXECUTION_GRANT',
    webContents,
    'uploaded-node-plugin',
    '',
  );
  await assert.rejects(
    () =>
      callIpc(
        'PLUGIN_EXEC_NODE_SCRIPT',
        webContents,
        'uploaded-node-plugin',
        grant.token,
        {
          script: 'return true;',
        },
      ),
    /not authorized/,
  );
});

test('rejects bidi/zero-width/homoglyph and leading-dot ids the allowlist must exclude', async () => {
  loadModule();
  const webContents = new FakeWebContents(16);

  // These are exactly the dialog-anchor spoofing vectors a denylist of explicit Unicode
  // ranges tends to miss; the allowlist rejects every non-[A-Za-z0-9._-] id by construction.
  const badIds = [
    `sync${String.fromCodePoint(0x061c)}md`, // U+061C ARABIC LETTER MARK (bidi)
    `sync${String.fromCodePoint(0x2060)}md`, // U+2060 WORD JOINER (zero-width)
    `sync${String.fromCodePoint(0x3164)}md`, // U+3164 HANGUL FILLER (invisible)
    `${String.fromCodePoint(0xff53)}ync-md`, // U+FF53 fullwidth 's' (homoglyph)
    '.hidden', // leading dot-segment
    '-leading-dash',
    'a b', // whitespace
  ];

  for (const badId of badIds) {
    await assert.rejects(
      () =>
        callIpc('PLUGIN_REQUEST_NODE_EXECUTION_GRANT', webContents, badId, {
          name: 'x',
          version: '1',
        }),
      /Invalid pluginId/,
      `expected ${JSON.stringify(badId)} to be rejected`,
    );
  }
});

test('strips bidi/zero-width chars from self-declared display strings', async () => {
  loadModule();
  const webContents = new FakeWebContents(17);

  const name = `Tru${String.fromCodePoint(0x061c)}sted${String.fromCodePoint(0x2060)} Plugin`;
  const grant = await callIpc(
    'PLUGIN_REQUEST_NODE_EXECUTION_GRANT',
    webContents,
    'display-sanitize-plugin',
    { name, version: `1.0${String.fromCodePoint(0xfeff)}.0` },
  );

  assert.equal(typeof grant.token, 'string');
  const detail = dialogCalls[dialogCalls.length - 1][1].detail;
  // The control/format chars are gone; the visible text survives.
  assert.equal(detail.includes(String.fromCodePoint(0x061c)), false);
  assert.equal(detail.includes(String.fromCodePoint(0x2060)), false);
  assert.equal(detail.includes(String.fromCodePoint(0xfeff)), false);
  assert.match(detail, /Trusted Plugin/);
});

test('never upgrades trust: an on-disk match that does not cleanly verify uses the unverified dialog', async () => {
  // Simulate a bundled dir whose manifest exists but is not a grantable nodeExecution
  // built-in (here: missing the permission). The verified-built-in branch must return
  // null and fall back to the unverified-uploaded dialog rather than throw or upgrade.
  const originalPermissions = BUILT_IN_PLUGIN_MANIFEST.permissions;
  BUILT_IN_PLUGIN_MANIFEST.permissions = [];
  try {
    loadModule();
    const webContents = new FakeWebContents(18);
    const grant = await callIpc(
      'PLUGIN_REQUEST_NODE_EXECUTION_GRANT',
      webContents,
      BUILT_IN_PLUGIN_MANIFEST.id,
      { name: 'Impersonator', version: '9.9.9' },
    );

    assert.equal(typeof grant.token, 'string');
    const opts = dialogCalls[dialogCalls.length - 1][1];
    assert.match(opts.title, /run code on your machine/);
    assert.match(opts.detail, /self-declared, unverified/);
    assert.equal(opts.defaultId, 1);
  } finally {
    BUILT_IN_PLUGIN_MANIFEST.permissions = originalPermissions;
  }
});

// --- Phase 2: persisted, per-plugin consent (#8512) ---

test('uploaded plugin with persisted consent is granted without a dialog (ask-once)', async () => {
  loadModule();
  // Simulate a grant remembered from a previous app session.
  consentStore.__records.set('uploaded-node-plugin', {
    name: 'Uploaded',
    version: '1.0.0',
    grantedAt: 1,
  });
  const webContents = new FakeWebContents(20);

  const grant = await callIpc(
    'PLUGIN_REQUEST_NODE_EXECUTION_GRANT',
    webContents,
    'uploaded-node-plugin',
    { name: 'Uploaded', version: '1.0.0' },
  );

  assert.equal(typeof grant.token, 'string');
  assert.equal(dialogCalls.length, 0);
});

test('granting an uploaded plugin persists consent for the next session', async () => {
  loadModule();
  const webContents = new FakeWebContents(21);

  await callIpc('PLUGIN_REQUEST_NODE_EXECUTION_GRANT', webContents, 'persist-me', {
    name: 'Persist Me',
    version: '2.0.0',
  });

  assert.equal(dialogCalls.length, 1);
  const persisted = consentStore.__records.get('persist-me');
  assert.equal(persisted.name, 'Persist Me');
  assert.equal(persisted.version, '2.0.0');
  assert.equal(typeof persisted.grantedAt, 'number');
});

test('denying an uploaded plugin does not persist consent', async () => {
  loadModule();
  nextDialogResult = { response: 1 };
  const webContents = new FakeWebContents(22);

  const denied = await callIpc(
    'PLUGIN_REQUEST_NODE_EXECUTION_GRANT',
    webContents,
    'deny-me',
    { name: 'Deny', version: '1.0.0' },
  );

  assert.equal(denied, null);
  assert.equal(consentStore.__records.has('deny-me'), false);
});

test('clearConsent drops the grant + persisted consent so the next request re-prompts', async () => {
  loadModule();
  const wc1 = new FakeWebContents(23);
  const grant = await callIpc('PLUGIN_REQUEST_NODE_EXECUTION_GRANT', wc1, 'clear-me', {
    name: 'Clear',
    version: '1.0.0',
  });
  assert.equal(dialogCalls.length, 1);
  assert.ok(consentStore.__records.has('clear-me'));

  await callIpc('PLUGIN_CLEAR_NODE_EXECUTION_CONSENT', wc1, 'clear-me');
  assert.equal(consentStore.__records.has('clear-me'), false);

  // The live session grant is gone too: exec is now unauthorized.
  await assert.rejects(
    () =>
      callIpc('PLUGIN_EXEC_NODE_SCRIPT', wc1, 'clear-me', grant.token, {
        script: 'return true;',
      }),
    /not authorized/,
  );

  // A fresh request re-prompts because no persisted consent remains.
  const wc2 = new FakeWebContents(24);
  await callIpc('PLUGIN_REQUEST_NODE_EXECUTION_GRANT', wc2, 'clear-me', {
    name: 'Clear',
    version: '1.0.0',
  });
  assert.equal(dialogCalls.length, 2);
});

test('rejects prototype-pollution ids before any consent lookup or mint', async () => {
  // SECURITY regression: 'constructor'/'prototype' pass the id allowlist regex and would,
  // against a plain-object consent map, resolve to an Object.prototype member and mint a
  // grant with no dialog. They must be rejected outright. ('__proto__' fails the regex.)
  loadModule();
  const webContents = new FakeWebContents(27);

  for (const badId of ['constructor', 'prototype', '__proto__']) {
    await assert.rejects(
      () =>
        callIpc('PLUGIN_REQUEST_NODE_EXECUTION_GRANT', webContents, badId, {
          name: 'x',
          version: '1',
        }),
      /Invalid pluginId/,
      `expected ${badId} to be rejected`,
    );
  }
  assert.equal(dialogCalls.length, 0);
  assert.equal(consentStore.__records.has('constructor'), false);
});

test('built-in plugin consent is never persisted (stays per-session, regression)', async () => {
  loadModule();
  const wc1 = new FakeWebContents(25);
  await callIpc('PLUGIN_REQUEST_NODE_EXECUTION_GRANT', wc1, 'sync-md');
  assert.equal(dialogCalls.length, 1);
  // Built-in plugins keep the verified per-session prompt — nothing is written to the
  // persisted store, so a new session prompts again.
  assert.equal(consentStore.__records.has('sync-md'), false);

  const wc2 = new FakeWebContents(26);
  await callIpc('PLUGIN_REQUEST_NODE_EXECUTION_GRANT', wc2, 'sync-md');
  assert.equal(dialogCalls.length, 2);
});

test('mints the grant before persisting consent (persist is best-effort, never gates the grant)', async () => {
  // SECURITY ordering: the consent persist is a new `await` after the post-dialog
  // sender-validity check. Minting BEFORE that write keeps the grant in the live table
  // during the write, so a navigation/destroy cleanup that fires mid-write drops it (in
  // real Electron), and a persist failure can never lose an approved grant. Asserting the
  // grant already exists at the moment of the persist write pins that ordering.
  let grantsSizeAtPersist = -1;
  let mod;
  consentStore.setNodeExecutionConsent = async (pluginId, consent) => {
    grantsSizeAtPersist = mod.pluginNodeExecutor.grants.size;
    consentStore.__records.set(pluginId, consent);
  };
  mod = loadModule();
  const webContents = new FakeWebContents(30);

  const grant = await callIpc(
    'PLUGIN_REQUEST_NODE_EXECUTION_GRANT',
    webContents,
    'uploaded-order',
    { name: 'Uploaded', version: '1.0.0' },
  );

  assert.equal(typeof grant.token, 'string');
  // The grant for this request was already live when the persist ran (mint-before-persist).
  assert.equal(grantsSizeAtPersist, 1);
});

test('spawn path runs the script with process.execPath, never a bare node lookup', async () => {
  // REGRESSION: the spawn path used `execPath.includes('electron') ? execPath : 'node'`.
  // In a packaged app the executable name contains no "electron" (e.g. "Super
  // Productivity"), and the child env below has no PATH, so spawn('node') could only
  // resolve through the execvp fallback (/usr/bin:/bin) — ENOENT on most machines.
  // The executor must always use its own binary in node mode.
  loadModule();
  const webContents = new FakeWebContents(32);
  const grant = await callIpc(
    'PLUGIN_REQUEST_NODE_EXECUTION_GRANT',
    webContents,
    'sync-md',
  );

  // The script must mention child_process so canExecuteDirectly() routes it to spawn.
  const result = await callIpc(
    'PLUGIN_EXEC_NODE_SCRIPT',
    webContents,
    'sync-md',
    grant.token,
    {
      script: "const cp = require('child_process'); void cp; return 1;",
    },
  );

  assert.equal(result.success, true);
  assert.equal(result.result, 42); // from the stubbed child's stdout
  assert.equal(spawnCalls.length, 1);
  const [command, spawnArgs, options] = spawnCalls[0];
  assert.equal(command, process.execPath);
  assert.ok(spawnArgs.includes('-e'));
  assert.equal(options.env.ELECTRON_RUN_AS_NODE, '1');
  // The env stays minimal by design — which is exactly why a bare 'node' cannot resolve.
  assert.equal('PATH' in options.env, false);
});

test('a consent persist failure still grants the approved session (best-effort)', async () => {
  // Persisting is best-effort: a write failure only costs a re-prompt next session, never
  // the grant the user just approved. Minting before the persist guarantees this.
  let mod;
  consentStore.setNodeExecutionConsent = async () => {
    throw Object.assign(new Error('disk full'), { code: 'ENOSPC' });
  };
  mod = loadModule();
  assert.ok(mod);
  const webContents = new FakeWebContents(31);

  const grant = await callIpc(
    'PLUGIN_REQUEST_NODE_EXECUTION_GRANT',
    webContents,
    'persist-fails',
    { name: 'Uploaded', version: '1.0.0' },
  );

  assert.equal(typeof grant.token, 'string');
  const result = await callIpc(
    'PLUGIN_EXEC_NODE_SCRIPT',
    webContents,
    'persist-fails',
    grant.token,
    { script: 'return 1 + 1;' },
  );
  assert.equal(result.success, true);
  assert.equal(result.result, 2);
});
