1. 什么是 ChatGPT App 的冒烟测试
在常见的 Web 开发语境中,冒烟测试就是最基本的检查——“系统还活着吗?” 页面能打开,按钮不会崩,没出现致命错误。
在 ChatGPT Apps 的世界里,冒烟测试会更有趣一些,因为链路上同时涉及多个环节:
- 你的小部件代码(React/Next.js)。
- Next.js 开发服务器。
- 隧道(ngrok/Cloudflare)。
- ChatGPT,它会创建 iframe 并把你的小部件加载到聊天里。
对我们来说,一个理想的冒烟测试意味着:
- 小部件能在 ChatGPT 内部无错误地渲染;
- 基础的交互正常(例如点击按钮后能打开外部链接);
- 浏览器控制台和开发服务器日志里没有一片“红色雪崩”。
重要提示:在这个阶段,我们还不检查 MCP 工具,不做压力测试,也不计算 Token 成本。我们的目标朴素而务实:证明“代码 → Next.js → 隧道 → ChatGPT → 用户”这条链路能够闭环。
可以把它想象成这样一张表:
| 检查什么 | 如何判断一切正常 |
|---|---|
| 小部件渲染 | 在 ChatGPT 中能看到我们的 UI,而不是“损坏的 iframe” |
| ChatGPT ↔ 我们的服务器连通性 | 没有“无法加载应用”的错误 |
| 沙箱中的 JS 运行 | 处理器 onClick 确实会执行 |
| 能否打开外部链接 | 按钮会用指定的 URL 打开新标签/窗口 |
2. 我们的教学 App:简单的“Hello GiftGenius”
在本课程中,我们会逐步构建 GiftGenius——一个礼物选品助手。在当前这一步,它还不会选礼物,但至少可以礼貌地打个招呼,并展示一个“了解更多”的链接。
我们需要一个最小但真实的小部件:没有复杂逻辑,但包含可运行的 React 代码。
一个最简单的小部件组件可以长这样(名称和样式你可以自行调整,这里先使用课程计划中的基础版):
// app/widget/page.tsx
'use client';
export default function GiftGeniusWidget() {
return (
<main style={{ padding: 16, fontFamily: 'system-ui, sans-serif' }}>
<h1 style={{ fontSize: 24, marginBottom: 8 }}>
Hello from GiftGenius
</h1>
<p style={{ marginBottom: 16 }}>
这是你的第一个 ChatGPT App。接下来我们会教它挑选礼物。
</p>
</main>
);
}
几个重要的注意点。
首先,文件开头的指令 'use client'; 会把组件标记为客户端组件。没有它,Next.js 会把文件当作服务端组件处理,你将无法使用 window、事件处理器 onClick,以及任何浏览器 API。
其次,这就是一个普通的 React 组件。你看不到任何“Apps SDK 的魔法”——这很坦诚。它之所以最终出现在 ChatGPT 里,所有“魔法”都藏在 MCP 服务器的配置以及那个返回小部件 URL 的工具里。我们稍后再处理这些内容,现在只关心 UI。
3. 把小部件嵌入模板并运行
在用于 Apps SDK 的官方 Next.js 模板里,小部件页面通常已经存在;你可以直接修改,或者按需在相应路由下新建页面(例如 /widget)。
假设你已经有了 app/widget/page.tsx,并把其内容替换为上面的代码。接下来链路是这样的:
- 你保存文件。
- Next.js 开发服务器(通过 npm run dev 已经启动)会重启相关模块,HMR 刷新页面。
- 通过隧道,你的公网 HTTPS URL 在相同路径 /widget 上开始提供更新后的 UI。
有两种方法进行验证。
先用“老派方式”——在本地浏览器打开:
http://localhost:3000/widget
你会看到相同的 Hello from GiftGenius。没错,这还不是 ChatGPT,只是用来确认你的 Next.js 应用的 UI 是活的。
然后——通过隧道。拿到发放的 URL(类似 https://witty-cat.ngrok-free.app),在后面加上 /widget,并在普通浏览器中打开:
https://witty-cat.ngrok-free.app/widget
如果一切顺利,页面看起来应完全一致。说明“Next.js → 隧道 → 你的浏览器”这段链路正常,现在只差把 ChatGPT 插到它们之间。
4. 在 ChatGPT 内部检查小部件
ChatGPT 的 Dev Mode 本质上做三件事:创建一个 iframe,给它设置指向你公网 URL 的 src,然后让这个 iframe 在聊天消息里存活。
简化后的事件顺序如下:
sequenceDiagram
participant Dev as 你(Dev)
participant Next as Next.js 开发服务器
participant Tun as 隧道(HTTPS)
participant GPT as ChatGPT
participant User as 用户
Dev->>Next: npm run dev (http://localhost:3000)
Dev->>Tun: 启动指向 3000 端口的隧道
GPT->>Tun: GET https://.../widget
Tun->>Next: 代理到 http://localhost:3000/widget
Next-->>Tun: 小部件的 HTML + JS
Tun-->>GPT: 返回 HTML/JS 响应
GPT->>User: 渲染带小部件的 iframe
要看到结果,你需要:
- 在浏览器中打开 ChatGPT,选择所需的模型(通常是 GPT‑5.1,或 Dev Mode 默认指定的模型)。
- 显式选择你的应用(通过 Apps/Developer 菜单),或用类似“启动 GiftGenius 应用”的话语来“召唤”它。
- ChatGPT 会调用你的 App,MCP 服务器返回的响应中包含 UI 链接(就是 /widget),随后你的小部件会出现在聊天消息里。
如果一切正常,你会在 ChatGPT 内部看到熟悉的标题“Hello from GiftGenius”。到这一步,冒烟测试几乎完成:iframe 能渲染,“Next.js → 隧道 → ChatGPT”这段链路是通的。还剩表格里的最后一项——小部件能以可预期的方式打开外部链接。为此我们需要 openExternal。
稍后,当你开始修改代码时,正常的开发循环通常如下:
- 修改 JSX。
- 保存。
- 要么刷新 ChatGPT 的标签页,要么(有时)只需“轻触”一下小部件——比如发送一条新消息或再次启动 App(具体取决于你的模板和缓存设置)。
如果看不到变更,首先考虑三个嫌疑:开发服务器未启动、隧道掉线,或 ChatGPT 连到了旧的 URL。在“如果出错,去哪儿排查”一节我们会更详细地剖析这个场景。
5. 为什么不能直接放一个 <a href> 就完事
为了完成冒烟测试中的最后一项——点击按钮打开外部页面——我们需要搞清楚 openExternal。一个合乎直觉的问题是:“为什么一定要用 openExternal?直接放链接不行吗?”
问题在于,你的小部件并不是“直接跑在浏览器里”,而是运行在 ChatGPT 管控的 iframe 里。这个 iframe 处在相当严格的沙箱中:可能会有 Content Security Policy 限制、sandbox 属性、以及 target="_blank" 与弹窗拦截的种种怪异行为。结果就是,在这样的 iframe 中,<ahref="…"> 或 window.open() 的行为可能变得不可预测:从完全无效到弹出你无法控制的警告不等。
此外,从 UX 的角度,OpenAI 希望对你何时以及如何打开外部页面进行统一管控。因此,Apps SDK 提供了统一的桥接对象 window.openai:你的代码不直接去操作父窗口,而是通过清晰定义的 API 把动作委托给宿主应用。
6. API window.openai.openExternal:是什么、如何工作
在小部件的沙箱中,可以访问全局对象 window.openai。这是你的 UI 与 ChatGPT 之间的主要“桥梁”:你可以通过它调用工具、发送后续消息、切换显示模式、管理小部件状态,当然也包括打开外部链接。
在本讲里,我们只关心其中的一个方法:
window.openai.openExternal({ href: string }): void;
当你调用 window.openai.openExternal({ href: 'https://example.com' }) 时,ChatGPT 会:
- 检查该 URL 是否符合策略。
- 可能向用户显示一个警告(例如提示这是外部站点)。
- 在用户的浏览器中以新标签/窗口方式打开该链接。
需要把两件事想清楚。
首先,这是纯客户端操作。它不会调用 MCP 工具,不会访问你的后端,也不会消耗 OpenAI 的 Token。它只是向宿主应用发出一个信号:“请打开这个 URL”。
其次,这种方式与沙箱兼容。ChatGPT 会自行决定如何打开链接,避免让你的 iframe 过度使用 window.open()。
7. 给我们的小部件加一个使用 openExternal 的按钮
现在我们来从“Hello GiftGenius”里打开一个外部链接。最简单的场景:一个“打开演示链接”的按钮,指向例如你的文档或服务落地页。
先写一个小小的 helper,这样 TypeScript 不会报错,而且当你直接在浏览器里打开 /widget(此时还没有 window.openai)时,小部件也不会崩:
// app/widget/openExternalSafe.ts
export function openExternalSafe(href: string) {
if (typeof window !== 'undefined' && (window as any).openai?.openExternal) {
(window as any).openai.openExternal({ href });
} else {
// 在没有 ChatGPT 的本地预览场景下的回退
window.open(href, '_blank', 'noopener,noreferrer');
}
}
这里我刻意使用了 (window as any),以免现在就去为 window.openai 做完整的类型声明。课程稍后会更规范地描述该对象的接口。目前只需确保代码能编译并运行即可。
现在把 helper 引入我们的小部件,并加上按钮:
// app/widget/page.tsx
'use client';
import { openExternalSafe } from './openExternalSafe';
export default function GiftGeniusWidget() {
return (
<main style={{ padding: 16, fontFamily: 'system-ui, sans-serif' }}>
<h1 style={{ fontSize: 24, marginBottom: 8 }}>
Hello from GiftGenius
</h1>
<p style={{ marginBottom: 16 }}>
这是你的第一个 ChatGPT App。接下来我们会教它挑选礼物。
</p>
<button
type="button"
onClick={() => openExternalSafe('https://example.com')}
style={{
padding: '8px 16px',
borderRadius: 8,
border: '1px solid #ccc',
cursor: 'pointer',
}}
>
打开演示链接
</button>
</main>
);
}
点击时会发生什么。
如果小部件运行在 ChatGPT 内部,window.openai.openExternal 存在,ChatGPT 会按照其规则打开 https://example.com。
如果你在普通浏览器中打开了 http://localhost:3000/widget,此时没有 window.openai,就会触发回退:用浏览器的常规方式打开新标签。在这里,window.open 只在直接在普通浏览器打开 /widget 时使用,也就是不在 ChatGPT 的沙箱内。在该场景下它会像往常一样工作,不会造成问题。
关于 openExternal 的细节,我们会在模块 3(关于小部件与沙箱的专题)中深入讲解,所以现在可以放心进入应用的运行环节。
8. 端到端的迷你冒烟测试
现在可以做一次完整的“实战演练”。按顺序走完这些步骤:
- 确认开发服务器已启动(npm run dev),并能在 http://localhost:3000/widget 看到 Hello from GiftGenius。
- 确认到 3000 端口的隧道已建立,并且公网 URL 能在外部浏览器打开。
- 打开 ChatGPT,启用 Dev Mode,并确认你的 App 连接的是正确的 URL(公网地址,而不是 localhost)。
- 打开聊天,选择该 App(或让模型启动它)。
- 确认在内嵌小部件里能看到“Hello from GiftGenius”。
- 点击“打开演示链接”按钮,确认浏览器打开了 https://example.com(或你的地址)。
如果这些都顺利完成,说明:
- 小部件的 HTML/JS 已由 Next 服务器正确构建并返回。
- HTTPS 隧道能正确代理请求。
- ChatGPT 信任你的 URL,并能加载小部件。
- window.openai 工作正常,能够把“打开外部链接”的指令传达出去。
这正是我们对第一个冒烟测试的期望。
9. 如果出错,去哪儿排查
与“常规”前端不同,这里主要有三个诊断入口。要快速判断究竟是哪一处出问题了:
- 先看 ChatGPT 中的 UI。如果你看到的不是小部件,而是类似“Error loading app”或“We had trouble talking to your app”的报错,问题大概率在隧道或你的开发服务器可达性上。试着在浏览器中直接打开公网 URL:如果打不开,或打开的是 Next.js 错误页面,优先修这个。
- 然后在 ChatGPT 所在的浏览器页面打开 DevTools。那里有一个专门用于你的小部件的 iframe,在里面可以看到熟悉的 Console 标签。如果点击使用 openExternal 的按钮没有反应,看看是否有“window.openai is undefined”之类的错误。如果有——很可能是你并不是在 ChatGPT 中测试(而是直接用隧道 URL 打开的),或者忘了在文件里写 'use client';。
- 同时关注运行 npm run dev 的终端。如果那里有构建错误(TypeScript、ESLint、编译),ChatGPT 最多只能看到旧版本代码,最坏情况下什么也看不到。如果没有错误却看不到更新,确认隧道仍然活着:不少隧道服务会在空闲超时后自动断开会话。
还有一个常见情况:在 localhost 下都正常,但通过隧道访问时得到 404 或奇怪的页面。这时请仔细检查基础路径(/widget 与 / 是否搞混)、basePath/assetPrefix 设置(如果你改过的话),以及 Dev Mode 中配置的地址。
10. 一点“收尾”建议:如何停掉相关进程
这虽是小事,但在实践中非常有用。新手经常忘记,开发服务器和隧道都是独立进程,会在后台持续运行。
如果你突然遇到“端口 3000 已被占用”,可能是某个终端里还藏着旧的 npm run dev。在 Windows 上有时需要去任务管理器里“跳舞”,在 macOS 与 Linux 上通常是在启动该进程的终端里用 Ctrl + C 结束它。
隧道也一样:如果你连续尝试了多个隧道,或忘了关掉旧的,就很容易搞不清 Dev Mode 里你的 App 现在指向哪个 URL。建议养成习惯:准备结束会话时——先断开隧道、再停掉开发服务器,下次启动从“干净”的状态开始。
11. 第一次冒烟测试的常见错误
错误 №1:使用 localhost 而不是公网 HTTPS URL。
很常见:你在 Dev Mode 里不小心填了 http://localhost:3000,或者干脆忘了开隧道。在你的机器上当然没问题,但运行在云端的 ChatGPT 根本无法访问 localhost。解决办法很简单:检查 App 设置里填的是公网隧道的 HTTPS 地址,并且路径正确(/mcp 或根路径,取决于模板)。
错误 №2:忘记在小部件文件里加 'use client';。
你写了漂亮的 React 代码、加上了 onClick,又访问了 window.openai,但 Next.js 静悄悄地把页面当作服务端组件。最好的结果是得到“window is not defined”的报错,最坏的则是组件根本没法构建。想要使用浏览器 API,小部件必须是客户端组件,这由第一行的 'use client'; 说明。
错误 №3:直接调用 window.open() 而不是 openExternal。
看起来直接写 window.open('https://example.com') 更省事。在普通浏览器里也许还能用,但在 ChatGPT 的沙箱里行为就不可预测了:从完全无效到被拦截都有可能。对 ChatGPT Apps 来说,正确的做法是使用 window.openai.openExternal({ href }),把打开链接的动作交给宿主并遵循所有安全策略。
错误 №4:TypeScript 抱怨 window.openai,开发者通过“关掉类型检查”来“修复”。
有时大家会在文件开头写 // @ts-nocheck。这能消除编译错误,但也会把整个文件的 TypeScript 都关掉。更安全的做法要么在 window 周围按点使用 as any,要么在单独文件中为 window.openai 描述一个最小接口。本模块里我们选择了用带 (window as any) 的小 helper openExternalSafe,更严谨的类型稍后再加。
错误 №5:只在 localhost 下看结果,却不在 ChatGPT 内部验证。
只看到 http://localhost:3000/widget 能打开就以为大功告成,这很诱人。但本模块的意义正是要在 ChatGPT 内看到 App。普通浏览器里一切正常,并不保证 ChatGPT 能正确创建 iframe、通过隧道获取资源、也不会撞上 CORS/CSP。一个完整的冒烟测试必须包含在 ChatGPT 界面里启动 App 的步骤。
错误 №6:忘记了隧道,或隧道掉线了。
你更新了代码,但 ChatGPT 里不是旧版本就是完全加载失败。很多时候是隧道因超时被关闭,但开发者模式仍然指向旧 URL。如果在普通浏览器里打开隧道 URL 就报错——先把隧道恢复,再去怀疑 Apps SDK。
错误 №7:忽略 iframe 里的控制台。
做 SPA 的开发者习惯在自己应用的 DevTools 看 console.log,但在 ChatGPT 里这是一个 iframe,需要在 DevTools 里选择正确的 Frame。如果只看顶层,你可能一条错误都看不到,但小部件里其实早就“红了”。养成“在小部件的 iframe 上打开 DevTools”的习惯,会大幅节省精力。
GO TO FULL VERSION