|
| 1 | +import crypto from 'node:crypto'; |
| 2 | +import fs from 'node:fs'; |
| 3 | +import path from 'node:path'; |
| 4 | + |
| 5 | +import { expect } from '@playwright/test'; |
| 6 | + |
| 7 | +import { loadFixture } from '../../playwright/paths'; |
| 8 | +import { test } from '../../playwright/test'; |
| 9 | + |
| 10 | +const PLUGIN_NAME = 'insomnia-plugin-sandbox-probe'; |
| 11 | + |
| 12 | +// Write a probe plugin into the data-path plugins directory (same pattern as plugin-bridge.test.ts). |
| 13 | +// - sandboxprobe: reports which execution path ran it (Node's `process` global exists in the legacy |
| 14 | +// main-process path but is absent inside QuickJS) and exercises an async host-bridge round-trip. |
| 15 | +// - cryptoparity: a deterministic require('crypto') workload whose output must be byte-identical |
| 16 | +// between the legacy path and the sandbox path. |
| 17 | +const installProbePlugin = (dataPath: string) => { |
| 18 | + const pluginDir = path.join(dataPath, 'plugins', PLUGIN_NAME); |
| 19 | + fs.mkdirSync(pluginDir, { recursive: true }); |
| 20 | + fs.writeFileSync( |
| 21 | + path.join(pluginDir, 'package.json'), |
| 22 | + // The 'insomnia' key is required — the loader skips packages that lack it. |
| 23 | + JSON.stringify({ name: PLUGIN_NAME, version: '1.0.0', main: 'index.js', insomnia: {} }), |
| 24 | + ); |
| 25 | + fs.writeFileSync( |
| 26 | + path.join(pluginDir, 'index.js'), |
| 27 | + ` |
| 28 | + module.exports.templateTags = [ |
| 29 | + { |
| 30 | + name: 'sandboxprobe', |
| 31 | + displayName: 'Sandbox Probe', |
| 32 | + description: 'Reports whether it executed inside the QuickJS sandbox', |
| 33 | + args: [{ displayName: 'Label', type: 'string', defaultValue: 'hello' }], |
| 34 | + async run(context, label = 'hello') { |
| 35 | + const ranIn = typeof process === 'undefined' ? 'sandbox' : 'main-process'; |
| 36 | + let arch = 'n/a'; |
| 37 | + try { |
| 38 | + const hostOS = await context.util.nodeOS(); |
| 39 | + arch = hostOS.arch; |
| 40 | + } catch (err) { |
| 41 | + arch = 'bridge-error:' + err.message; |
| 42 | + } |
| 43 | + return label + ' | ran in: ' + ranIn + ' | arch via bridge: ' + arch; |
| 44 | + }, |
| 45 | + }, |
| 46 | + { |
| 47 | + name: 'cryptoparity', |
| 48 | + displayName: 'Crypto Parity', |
| 49 | + description: 'Deterministic crypto workload for flag-on/off parity', |
| 50 | + args: [{ displayName: 'Input', type: 'string', defaultValue: 'insomnia-test' }], |
| 51 | + async run(context, input = 'insomnia-test') { |
| 52 | + return require('crypto').createHash('sha256').update(String(input)).digest('hex'); |
| 53 | + }, |
| 54 | + }, |
| 55 | + ]; |
| 56 | + `, |
| 57 | + ); |
| 58 | +}; |
| 59 | + |
| 60 | +test('Template tag sandbox: flag routes plugin tag execution into the QuickJS sandbox', async ({ |
| 61 | + page, |
| 62 | + app, |
| 63 | + dataPath, |
| 64 | + insomnia, |
| 65 | +}) => { |
| 66 | + installProbePlugin(dataPath); |
| 67 | + |
| 68 | + // The persistent "Plugin system updated" notification fires from a Root mount effect whenever |
| 69 | + // user plugins exist on disk, and its toast region intercepts pointer events over the page. |
| 70 | + // Seed its once-only guard so route remounts can't re-raise it, then clear any toast already |
| 71 | + // in flight before driving the UI. |
| 72 | + await page.getByLabel('Import').waitFor(); |
| 73 | + await page.evaluate(() => localStorage.setItem('plugin-system-changes-toast-shown', 'true')); |
| 74 | + const dismissButtons = page.getByRole('button', { name: 'Dismiss' }); |
| 75 | + await dismissButtons |
| 76 | + .first() |
| 77 | + .waitFor({ timeout: 3000 }) |
| 78 | + .catch(() => {}); |
| 79 | + while ((await dismissButtons.count()) > 0) { |
| 80 | + await dismissButtons.first().click(); |
| 81 | + } |
| 82 | + |
| 83 | + // Import a collection whose request body contains the probe tags (the tags render as pills |
| 84 | + // lexically, so importing before the plugin is registered is fine). |
| 85 | + const fixture = await loadFixture('sandbox-probe-collection.yaml'); |
| 86 | + await app.evaluate(async ({ clipboard }, text) => clipboard.writeText(text), fixture); |
| 87 | + await page.getByLabel('Import').click(); |
| 88 | + await page.locator('[data-test-id="import-from-clipboard"]').click(); |
| 89 | + await page.getByRole('button', { name: 'Scan' }).click(); |
| 90 | + await page.getByRole('dialog').getByRole('button', { name: 'Import' }).click(); |
| 91 | + |
| 92 | + // Register the probe plugin through the bridge. |
| 93 | + await page.evaluate(() => (window as any).main.plugins.reloadPlugins()); |
| 94 | + |
| 95 | + await insomnia.navigationSidebar.clickRequestOrFolder('Sandbox Probe'); |
| 96 | + await page.getByText('Body', { exact: true }).click(); |
| 97 | + |
| 98 | + // Open a tag pill in the body editor and assert its Live Preview output (auto-retries until the |
| 99 | + // render completes), then close the modal. |
| 100 | + const assertTagPreview = async (tagPrefix: string, expected: string) => { |
| 101 | + await page.locator(`[data-template^="${tagPrefix}"]`).click(); |
| 102 | + const modal = page.getByRole('dialog'); |
| 103 | + await expect.soft(modal.getByLabel('Live Preview')).toContainText(expected); |
| 104 | + await modal.getByRole('button', { name: 'Done' }).click(); |
| 105 | + }; |
| 106 | + |
| 107 | + const expectedHash = crypto.createHash('sha256').update('insomnia-test').digest('hex'); |
| 108 | + |
| 109 | + // Flag off (default): tags run on the legacy main-process path. |
| 110 | + await assertTagPreview('{% sandboxprobe', 'e2e | ran in: main-process'); |
| 111 | + await assertTagPreview('{% cryptoparity', expectedHash); |
| 112 | + |
| 113 | + // Toggle the sandbox on: Preferences → Scripting → "Run template tags in sandbox". |
| 114 | + await page.getByTestId('settings-button').click(); |
| 115 | + await page.getByRole('tab', { name: 'Scripting' }).click(); |
| 116 | + const sandboxToggle = page.getByTestId('toggle-template-tag-sandbox'); |
| 117 | + await sandboxToggle.click(); |
| 118 | + await expect.soft(sandboxToggle.getByRole('switch')).toBeChecked(); |
| 119 | + await page.locator('.app').press('Escape'); |
| 120 | + |
| 121 | + // Canary: the same tag now reports sandbox execution, and the async host bridge still round-trips. |
| 122 | + // Derive the expected arch from the Electron main process (where pluginToMainAPI runs) rather |
| 123 | + // than the Playwright runner, which can differ in cross-arch setups. |
| 124 | + const electronArch = await app.evaluate(() => process.arch); |
| 125 | + await assertTagPreview('{% sandboxprobe', `e2e | ran in: sandbox | arch via bridge: ${electronArch}`); |
| 126 | + |
| 127 | + // Parity: the sandboxed require('crypto') workload is byte-identical to the legacy render above. |
| 128 | + await assertTagPreview('{% cryptoparity', expectedHash); |
| 129 | +}); |
0 commit comments