Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
144 changes: 144 additions & 0 deletions scripts/test-extension-runtime-logging.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
#!/usr/bin/env node

import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import test from 'node:test';
import vm from 'node:vm';
import { createRequire } from 'node:module';

const require = createRequire(import.meta.url);
const ts = require('typescript');

const ROOT = process.cwd();
const EXTENSION_VIEW_PATH = path.join(ROOT, 'src/renderer/src/ExtensionView.tsx');
const source = fs.readFileSync(EXTENSION_VIEW_PATH, 'utf8');

function sourceBetween(startNeedle, endNeedle) {
const start = source.indexOf(startNeedle);
assert.notEqual(start, -1, `Could not find start marker: ${startNeedle}`);
const end = source.indexOf(endNeedle, start);
assert.notEqual(end, -1, `Could not find end marker: ${endNeedle}`);
return source.slice(start, end);
}

function runTranspiledSnippet(snippet) {
const transpiled = ts.transpileModule(snippet, {
compilerOptions: {
module: ts.ModuleKind.CommonJS,
target: ts.ScriptTarget.ES2022,
},
fileName: EXTENSION_VIEW_PATH,
});
const debugCalls = [];
const sandbox = {
console: {
debug: (...args) => debugCalls.push(args),
},
__debugCalls: debugCalls,
};
vm.runInNewContext(transpiled.outputText, sandbox, { filename: EXTENSION_VIEW_PATH });
return sandbox.__result;
}

test('baseline: legacy success diagnostics log and stringify exported functions', () => {
const logCalls = [];
let toStringCalls = 0;
const exported = function Command() {};
exported.toString = () => {
toStringCalls += 1;
return 'function Command() { return "ok"; }';
};

const legacyConsole = {
log: (...args) => logCalls.push(args),
};

legacyConsole.log('[loadExtensionExport] Extension loaded successfully');
legacyConsole.log('[loadExtensionExport] Exported type:', typeof exported);
legacyConsole.log('[loadExtensionExport] Exported name:', exported?.name);
legacyConsole.log('[loadExtensionExport] Exported function:', exported?.toString?.().slice(0, 200));

console.log(
`[baseline] extension runtime success diagnostics: logs=${logCalls.length} exportedToStringCalls=${toStringCalls}`
);

assert.equal(logCalls.length, 4);
assert.equal(toStringCalls, 1);
});

test('extension runtime debug helper is quiet unless explicitly enabled', () => {
const helperSource = sourceBetween(
'const EXTENSION_RUNTIME_DEBUG_GLOBAL',
'// ─── React Module for Extensions'
);
const result = runTranspiledSnippet(`
${helperSource}
const beforeEnabled = isExtensionRuntimeDebugLoggingEnabled();
logExtensionRuntimeDebug('quiet');
globalThis[EXTENSION_RUNTIME_DEBUG_GLOBAL] = true;
const afterEnabled = isExtensionRuntimeDebugLoggingEnabled();
logExtensionRuntimeDebug('loud', 42);
globalThis.__result = {
beforeEnabled,
afterEnabled,
debugCalls: globalThis.__debugCalls,
};
`);

console.log(
`[after] extension runtime debug helper: defaultDebugCalls=0 enabledDebugCalls=${result.debugCalls.length}`
);

assert.equal(result.beforeEnabled, false);
assert.equal(result.afterEnabled, true);
assert.deepEqual(result.debugCalls, [['loud', 42]]);
});

test('success-path extension runtime diagnostics are gated behind debug logging', () => {
const moduleReactSetup = sourceBetween(
'// Create React module for extensions',
'// ─── JSX Runtime for Extensions'
);
const reactRequireBridge = sourceBetween(
'let reactRequireCount = 0;',
'// ── Raycast API shim'
);
const loadSuccessPath = sourceBetween(
'const exported =\n fakeModule.exports.default || fakeModule.exports;',
'if (typeof exported === \'function\')'
);
const viewRenderer = sourceBetween(
'const ViewRenderer: React.FC<{',
'const ScopedExtensionContext: React.FC<{'
);

for (const [label, snippet] of [
['module React setup', moduleReactSetup],
['fakeRequire React bridge', reactRequireBridge],
['loadExtensionExport success path', loadSuccessPath],
['ViewRenderer render path', viewRenderer],
]) {
assert.doesNotMatch(snippet, /console\.log/, `${label} must not emit unconditional console.log diagnostics`);
}

assert.match(moduleReactSetup, /logExtensionRuntimeDebug/);
assert.match(reactRequireBridge, /logExtensionRuntimeDebug/);
assert.match(loadSuccessPath, /if \(isExtensionRuntimeDebugLoggingEnabled\(\)\)/);
assert.match(viewRenderer, /logExtensionRuntimeDebug/);

const beforeDebugGate = loadSuccessPath.slice(
0,
loadSuccessPath.indexOf('if (isExtensionRuntimeDebugLoggingEnabled())')
);
assert.doesNotMatch(beforeDebugGate, /toString/, 'exported function toString must not run before debug is enabled');
assert.match(loadSuccessPath, /console\.debug\('\[loadExtensionExport\] Exported function:', exported\?\.toString/);
});

test('actionable warnings and errors remain available', () => {
assert.match(source, /console\.warn\('Extension exported an object, not a function\. Trying to wrap it\.'\)/);
assert.match(source, /console\.warn\(`Extension tried to require unknown module:/);
assert.match(source, /console\.error\('Extension did not export a function\. Got:'/);
assert.match(source, /console\.error\('Failed to load extension:'/);
assert.match(source, /console\.error\('Stack:'/);
});
48 changes: 29 additions & 19 deletions src/renderer/src/ExtensionView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,18 @@ import { withExtensionContext } from './raycast-api/context-scope-runtime';
// Also import @raycast/utils stubs from our shim
import * as RaycastUtils from './raycast-api';

const EXTENSION_RUNTIME_DEBUG_GLOBAL = '__SUPERCMD_EXTENSION_RUNTIME_DEBUG';

function isExtensionRuntimeDebugLoggingEnabled(): boolean {
return (globalThis as any)[EXTENSION_RUNTIME_DEBUG_GLOBAL] === true;
}

function logExtensionRuntimeDebug(...args: unknown[]): void {
if (isExtensionRuntimeDebugLoggingEnabled()) {
console.debug(...args);
}
}

// ─── React Module for Extensions ────────────────────────────────────
// Extensions MUST use the exact same React instance as the host app.
//
Expand All @@ -30,9 +42,9 @@ import * as RaycastUtils from './raycast-api';
// Create React module for extensions
// We simply return the actual React import - no copying, no wrapping
// This ensures extensions get the exact same React that the host uses
console.log('[React] Setting up React for extensions');
console.log('[React] React.version:', React.version);
console.log('[React] React.useState:', typeof React.useState);
logExtensionRuntimeDebug('[React] Setting up React for extensions');
logExtensionRuntimeDebug('[React] React.version:', React.version);
logExtensionRuntimeDebug('[React] React.useState:', typeof React.useState);

// ─── JSX Runtime for Extensions ─────────────────────────────────────
// We use the actual jsx-runtime import to ensure full compatibility.
Expand Down Expand Up @@ -3607,14 +3619,11 @@ function loadExtensionExport(
// This is the critical bridge between extension code and the
// SuperCmd renderer environment. Every module an extension
// might `require()` must be handled here.
//
// IMPORTANT: We track React requires to verify the same instance is always returned.
let reactRequireCount = 0;
const fakeRequire: any = (name: string): any => {
// Track all requires for debugging
if (name === 'react' || name.startsWith('react/') || name === 'react-dom') {
reactRequireCount++;
console.log(`[fakeRequire] #${reactRequireCount} require("${name}")`);
logExtensionRuntimeDebug(`[fakeRequire] #${reactRequireCount} require("${name}")`);
}
// ── React & friends ─────────────────────────────────────
// CRITICAL: Extensions MUST use the same React instance as the host.
Expand All @@ -3626,24 +3635,24 @@ function loadExtensionExport(
switch (name) {
case 'react': {
// Return React directly - the exact same module the host uses
console.log('[fakeRequire] Providing React directly');
logExtensionRuntimeDebug('[fakeRequire] Providing React directly');
(globalThis as any).__SUPERCMD_REACT = React;
return React;
}
case 'react-dom':
case 'react-dom/client':
console.log('[fakeRequire] Providing ReactDOM');
console.log('[fakeRequire] ReactDOM.createRoot:', (ReactDOM as any).createRoot);
logExtensionRuntimeDebug('[fakeRequire] Providing ReactDOM');
logExtensionRuntimeDebug('[fakeRequire] ReactDOM.createRoot:', (ReactDOM as any).createRoot);
return ReactDOM;
case 'react-dom/server':
return reactDomServerStub;
case 'react/jsx-runtime':
case 'react/jsx-dev-runtime': {
// Return the actual jsx-runtime to ensure JSX creates elements
// using the same React.createElement
console.log('[fakeRequire] Providing jsx-runtime');
console.log('[fakeRequire] JsxRuntime.Fragment === React.Fragment:', JsxRuntime.Fragment === React.Fragment);
console.log('[fakeRequire] JsxRuntime.Fragment === React.Fragment:', JsxRuntime.Fragment === React.Fragment);
logExtensionRuntimeDebug('[fakeRequire] Providing jsx-runtime');
logExtensionRuntimeDebug('[fakeRequire] JsxRuntime.Fragment === React.Fragment:', JsxRuntime.Fragment === React.Fragment);
logExtensionRuntimeDebug('[fakeRequire] JsxRuntime.Fragment === React.Fragment:', JsxRuntime.Fragment === React.Fragment);
return JsxRuntime;
}

Expand Down Expand Up @@ -4011,10 +4020,12 @@ function loadExtensionExport(
const exported =
fakeModule.exports.default || fakeModule.exports;

console.log('[loadExtensionExport] Extension loaded successfully');
console.log('[loadExtensionExport] Exported type:', typeof exported);
console.log('[loadExtensionExport] Exported name:', exported?.name);
console.log('[loadExtensionExport] Exported function:', exported?.toString?.().slice(0, 200));
if (isExtensionRuntimeDebugLoggingEnabled()) {
console.debug('[loadExtensionExport] Extension loaded successfully');
console.debug('[loadExtensionExport] Exported type:', typeof exported);
console.debug('[loadExtensionExport] Exported name:', exported?.name);
console.debug('[loadExtensionExport] Exported function:', exported?.toString?.().slice(0, 200));
}

if (typeof exported === 'function') {
return exported;
Expand Down Expand Up @@ -4165,9 +4176,8 @@ const ViewRenderer: React.FC<{
fallbackText,
launchType = 'userInitiated',
}) => {
// Simple test that hooks work here
const [test] = useState('ok');
console.log('[ViewRenderer] Hooks work here, rendering extension...');
logExtensionRuntimeDebug('[ViewRenderer] Hooks work here, rendering extension...');
// Pass standard Raycast props: arguments (command arguments) and launchType
return React.createElement(Component, {
arguments: launchArguments,
Expand Down
Loading