1. Smoke test là gì đối với ChatGPT App
Trong thế giới phát triển web thông thường, smoke test là phép kiểm tra tối thiểu “hệ thống còn sống không?”. Trang mở được, các nút không bị lỗi, không có vấn đề nghiêm trọng.
Trong thế giới ChatGPT Apps, smoke test thú vị hơn một chút vì có nhiều mắt xích tham gia cùng lúc:
- Mã widget của bạn (React/Next.js).
- Dev server của Next.js.
- Đường hầm (ngrok/Cloudflare).
- ChatGPT tạo iframe và nạp widget của bạn vào trong cuộc trò chuyện.
Đối với chúng ta, một smoke test tốt là khi:
- widget được render bên trong ChatGPT mà không có lỗi;
- tính tương tác cơ bản hoạt động (ví dụ, bấm nút — liên kết ngoài được mở);
- không có cơn mưa lỗi màu đỏ nào trong console của trình duyệt hay trong log của dev server.
Quan trọng: ở giai đoạn này, chúng ta chưa kiểm tra công cụ MCP, chưa làm kiểm thử tải và chưa tính chi phí token. Mục tiêu khiêm tốn và rất thực tế: chứng minh chuỗi “code → Next.js → tunnel → ChatGPT → người dùng” đã khép kín.
Bạn có thể hình dung điều này bằng bảng sau:
| Kiểm tra gì | Dấu hiệu mọi thứ ổn |
|---|---|
| Render của widget | Trong ChatGPT thấy UI của chúng ta, không phải “iframe hỏng” |
| Kết nối ChatGPT ↔ server của chúng ta | Không có lỗi “không thể tải ứng dụng” |
| JS hoạt động trong sandbox | Các handler onClick thực sự được thực thi |
| Khả năng mở liên kết ngoài | Nút mở tab/cửa sổ mới với URL chỉ định |
2. Ứng dụng mẫu của chúng ta: “Hello GiftGenius” đơn giản
Trong khóa học này, chúng ta dần xây dựng ứng dụng GiftGenius — trợ lý gợi ý quà tặng. Ở bước này nó chưa gợi ý gì, nhưng đã có thể chào hỏi lịch sự và hiển thị một liên kết “tìm hiểu thêm”.
Chúng ta cần một widget tối giản nhưng đúng nghĩa: không có logic phức tạp, nhưng có mã React chạy thực sự.
Biến thể đơn giản nhất của component widget có thể như sau (tên và style bạn có thể chỉnh cho phù hợp, nhưng ta lấy cơ sở theo kế hoạch khóa học):
// 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 }}>
Đây là ChatGPT App đầu tiên của bạn. Sau này chúng ta sẽ dạy nó gợi ý quà tặng.
</p>
</main>
);
}
Một vài điểm quan trọng.
Thứ nhất, chỉ thị 'use client'; ở đầu file biến component thành client. Không có nó, Next.js coi file là server component, và bạn sẽ không thể sử dụng window, các handler onClick và bất kỳ API trình duyệt nào.
Thứ hai, đây là một React‑component bình thường. Không có “phép màu của Apps SDK” nào lộ ra — và điều đó là trung thực. Toàn bộ “phép màu” khi nó xuất hiện bên trong ChatGPT nằm ở cấu hình máy chủ MCP và công cụ trả về liên kết đến URL của widget. Chúng ta sẽ làm việc đó sau; hiện tại, ta chỉ quan tâm đến UI.
3. Gắn widget vào template và chạy
Trong template Next.js chính thức cho Apps SDK, trang widget thường đã có sẵn; bạn hoặc chỉnh sửa nó, hoặc tạo trang riêng theo route cần thiết (ví dụ, /widget).
Giả sử bạn có app/widget/page.tsx, và bạn thay nội dung của nó bằng đoạn mã phía trên. Chuỗi thao tác tiếp theo như sau:
- Bạn lưu file.
- Dev server Next.js (đã chạy qua npm run dev) reload các module cần thiết, HMR cập nhật trang.
- Qua đường hầm, URL HTTPS công khai của bạn với cùng path /widget bắt đầu phục vụ UI đã cập nhật.
Có hai cách để kiểm tra.
Đầu tiên theo cách cổ điển — trong trình duyệt local. Mở:
http://localhost:3000/widget
và bạn sẽ thấy cùng dòng Hello from GiftGenius. Đúng vậy, đây chưa phải ChatGPT; bạn chỉ đang xác nhận rằng UI của ứng dụng Next.js của bạn đang hoạt động.
Sau đó — qua đường hầm. Lấy URL được cấp (ví dụ https://witty-cat.ngrok-free.app), thêm /widget và mở trong trình duyệt thông thường:
https://witty-cat.ngrok-free.app/widget
Nếu mọi thứ ổn, trang sẽ giống hệt. Nghĩa là chuỗi “Next.js → tunnel → trình duyệt của bạn” hoạt động; giờ chỉ còn chèn ChatGPT vào giữa.
4. Kiểm tra widget bên trong ChatGPT
Trong Dev Mode, ChatGPT về bản chất làm ba bước: tạo một iframe, đặt src của nó tới URL công khai của bạn và để iframe đó “sống” trong thông điệp chat.
Đơn giản hóa, trình tự trông như sau:
sequenceDiagram
participant Dev as Bạn (Dev)
participant Next as Next.js dev server
participant Tun as Đường hầm (HTTPS)
participant GPT as ChatGPT
participant User as Người dùng
Dev->>Next: npm run dev (http://localhost:3000)
Dev->>Tun: Khởi chạy đường hầm tới cổng 3000
GPT->>Tun: GET https://.../widget
Tun->>Next: Proxy tới http://localhost:3000/widget
Next-->>Tun: HTML + JS của widget
Tun-->>GPT: Phản hồi chứa HTML/JS
GPT->>User: Render iframe chứa widget
Để thấy kết quả, bạn:
- Mở ChatGPT trong trình duyệt, chọn model phù hợp (thường là GPT‑5.1 hoặc model mặc định của Dev Mode).
- Chọn rõ ứng dụng của bạn (qua menu Apps/Developer) hoặc “gọi” nó bằng câu như: “Chạy ứng dụng GiftGenius”.
- ChatGPT gọi App của bạn, máy chủ MCP trả về phản hồi có chứa liên kết đến UI (chính là /widget), và widget của bạn xuất hiện trong tin nhắn chat.
Nếu mọi thứ ổn, bạn sẽ thấy tiêu đề quen thuộc “Hello from GiftGenius” ngay bên trong ChatGPT. Ở bước này, smoke test gần như hoàn tất: iframe render được, chuỗi “Next.js → tunnel → ChatGPT” sống. Còn lại kiểm tra mục cuối trong bảng — widget có thể mở liên kết ngoài một cách dự đoán được. Để làm vậy, ta cần openExternal.
Chút nữa, khi bạn bắt đầu thay đổi mã, vòng đời dev bình thường sẽ như sau:
- Sửa JSX.
- Lưu lại.
- Hoặc refresh tab ChatGPT, hoặc (đôi khi) chỉ cần “động chạm” widget — ví dụ, gửi thông điệp mới hoặc chạy lại App (tùy template và cơ chế cache).
Nếu không thấy thay đổi, hãy nghĩ ngay đến ba nghi phạm: Dev server chưa chạy, đường hầm đã rớt, hoặc ChatGPT đang trỏ tới URL cũ. Trong phần “Tìm lỗi khi mọi thứ không như ý” chúng ta sẽ phân tích kỹ hơn.
5. Vì sao không thể chỉ đặt <a href> rồi quên đi
Để hoàn thành mục cuối của smoke test — một nút mở trang bên ngoài — chúng ta sẽ phải dùng openExternal. Câu hỏi hợp lý: “Tại sao cần openExternal? Sao không dùng liên kết thông thường?”
Vấn đề là widget của bạn không “chạy trong trình duyệt thuần”, mà trong một iframe do ChatGPT quản lý. Iframe này nằm trong sandbox khá nghiêm: có thể chịu ràng buộc Content Security Policy, thuộc tính sandbox, những lạ lùng với target="_blank" và chặn popup. Kết quả là hành vi của <ahref="…"> hoặc window.open() bên trong iframe như vậy có thể khó lường: từ bị lờ đi hoàn toàn đến xuất hiện cảnh báo ngoài tầm kiểm soát của mã bạn.
Ngoài ra, về UX, OpenAI muốn kiểm soát thời điểm và cách bạn mở trang bên ngoài. Vì vậy Apps SDK cung cấp một cầu nối thống nhất window.openai: mã của bạn không can thiệp trực tiếp vào cửa sổ cha, mà ủy quyền hành động cho ứng dụng host theo API mô tả rõ ràng.
6. API window.openai.openExternal: là gì và hoạt động ra sao
Trong sandbox của widget, có sẵn đối tượng toàn cục window.openai. Đây là “cầu nối” chính giữa UI của bạn và ChatGPT: thông qua nó có thể gọi công cụ, gửi follow‑up, thay đổi chế độ hiển thị, quản lý trạng thái widget và, dĩ nhiên, mở liên kết ngoài.
Trong bài này, chúng ta quan tâm đến một phương thức cụ thể:
window.openai.openExternal({ href: string }): void;
Khi bạn gọi window.openai.openExternal({ href: 'https://example.com' }), ChatGPT sẽ:
- Kiểm tra URL có được chính sách cho phép.
- Có thể hiển thị cảnh báo cho người dùng (ví dụ, đây là trang bên ngoài).
- Mở liên kết trong tab/cửa sổ mới của trình duyệt người dùng.
Có hai điều quan trọng cần hiểu.
Thứ nhất, đây là thao tác thuần client. Nó không gọi công cụ MCP, không chạm backend của bạn và không tốn token OpenAI. Nó chỉ gửi tín hiệu cho ứng dụng host “hãy mở URL này”.
Thứ hai, cách này tương thích với sandbox. ChatGPT tự quyết định mở liên kết như thế nào, không để iframe của bạn lạm dụng window.open().
7. Thêm nút dùng openExternal vào widget của chúng ta
Giờ hãy học cách mở liên kết ngoài từ “Hello GiftGenius”. Kịch bản đơn giản nhất: nút “Mở liên kết demo”, dẫn tới chẳng hạn tài liệu hoặc trang đích dịch vụ của bạn.
Đầu tiên, viết một helper nhỏ để TypeScript không phàn nàn và để widget không bị lỗi nếu bạn mở /widget trực tiếp trong trình duyệt (nơi window.openai chưa tồn tại):
// 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 {
// Fallback cho việc xem local không qua ChatGPT
window.open(href, '_blank', 'noopener,noreferrer');
}
}
Ở đây tôi cố ý dùng (window as any), để không phải nặng nề với typing của window.openai. Chút nữa trong khóa học, chúng ta sẽ mô tả interface của đối tượng này cho gọn gàng. Hiện tại thế là đủ để mã biên dịch và chạy.
Giờ hãy import helper vào widget và thêm nút:
// 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 }}>
Đây là ChatGPT App đầu tiên của bạn. Sau này chúng ta sẽ dạy nó gợi ý quà tặng.
</p>
<button
type="button"
onClick={() => openExternalSafe('https://example.com')}
style={{
padding: '8px 16px',
borderRadius: 8,
border: '1px solid #ccc',
cursor: 'pointer',
}}
>
Mở liên kết demo
</button>
</main>
);
}
Điều gì sẽ xảy ra khi bấm.
Nếu widget chạy bên trong ChatGPT, window.openai.openExternal tồn tại, và ChatGPT sẽ mở https://example.com theo đúng quy tắc của nó.
Nếu bạn mở http://localhost:3000/widget trong trình duyệt thông thường, window.openai không tồn tại và fallback sẽ chạy: một tab mới sẽ được mở bằng cơ chế của trình duyệt. Ở đây window.open chỉ dùng khi mở trực tiếp /widget trong trình duyệt, tức là không còn trong sandbox của ChatGPT. Trong ngữ cảnh này, nó hoạt động bình thường và không gây vấn đề.
Chúng ta sẽ đi sâu vào openExternal ở module 3 (bài riêng về widget và sandbox), nên bây giờ bạn có thể yên tâm chuyển sang chạy ứng dụng.
8. Mini smoke test end‑to‑end
Giờ ta có thể chạy thử “từ A đến Z”. Hãy đi qua tất cả các bước:
- Đảm bảo dev server đang chạy (npm run dev) và bạn thấy Hello from GiftGenius tại http://localhost:3000/widget.
- Đảm bảo đường hầm tới cổng 3000 đã được mở, và URL công khai mở được từ trình duyệt bên ngoài.
- Mở ChatGPT, bật Dev Mode và đảm bảo App của bạn trỏ đến đúng URL (công khai, không phải localhost).
- Mở cuộc trò chuyện, chọn App (hoặc yêu cầu model chạy nó).
- Đảm bảo widget nhúng hiển thị “Hello from GiftGenius”.
- Bấm nút “Mở liên kết demo” và kiểm tra rằng trình duyệt mở https://example.com (hoặc địa chỉ của bạn).
Nếu tất cả đều hoạt động, nghĩa là:
- HTML/JS của widget được build đúng và được Next‑server phục vụ.
- Đường hầm HTTPS proxy yêu cầu đúng cách.
- ChatGPT tin cậy URL của bạn và có thể nạp widget.
- window.openai hoạt động và chuyển lệnh mở liên kết ngoài.
Đó chính xác là những gì chúng ta mong đợi từ smoke test đầu tiên.
9. Tìm lỗi ở đâu khi có sự cố
Khác với frontend “thuần”, ở đây bạn chỉ có ba nơi chính để chẩn đoán. Quan trọng là nhanh chóng xác định chỗ nào hỏng:
- Trước hết, nhìn vào UI trong ChatGPT. Nếu thay vì widget bạn thấy thông báo lỗi kiểu “Error loading app” hoặc “We had trouble talking to your app”, khả năng cao vấn đề ở đường hầm hoặc khả năng truy cập dev server của bạn. Hãy thử mở URL công khai trực tiếp trong trình duyệt: nếu không mở được hoặc mở ra lỗi của Next.js, hãy xử lý chỗ đó trước.
- Sau đó mở DevTools của trình duyệt trên tab đang chạy ChatGPT. Ở đó có một iframe riêng cho widget của bạn, và bên trong nó — tab Console quen thuộc. Nếu khi bấm nút với openExternal mà không có gì xảy ra, hãy xem có lỗi kiểu “window.openai is undefined” hoặc các lỗi JS khác không. Nếu có lỗi như vậy — rất có thể bạn đang thử widget không phải trong ChatGPT (mà truy cập trực tiếp URL của tunnel) hoặc quên chỉ thị 'use client';.
- Song song, hãy xem terminal với npm run dev. Nếu có lỗi build (TypeScript, ESLint, biên dịch), ChatGPT nhiều nhất sẽ thấy phiên bản cũ của mã, tệ nhất là chẳng thấy gì. Nếu không có lỗi nhưng bạn không thấy cập nhật, hãy đảm bảo đường hầm vẫn còn hoạt động: nhiều dịch vụ tunnel đóng phiên theo timeout khi không hoạt động.
Còn một trường hợp điển hình: mọi thứ hoạt động ở localhost, nhưng khi truy cập qua đường hầm bạn nhận 404 hoặc một trang lạ. Khi đó hãy kiểm tra kỹ base path (/widget so với /), các thiết lập basePath/assetPrefix (nếu bạn đã động vào) và địa chỉ khai báo trong Dev Mode.
10. Vài điều về “dọn dẹp”: dừng các tiến trình
Chuyện nhỏ nhưng rất hữu ích trong thực tế. Người mới thường quên rằng cả dev server và đường hầm — là các tiến trình riêng, tiếp tục chạy ở nền.
Nếu đột nhiên “cổng 3000 đã bận”, có thể đâu đó trong các cửa sổ terminal vẫn còn một npm run dev cũ. Trên Windows đôi khi thành “múa rìu” với Task Manager, còn trên macOS và Linux thì Ctrl + C trong terminal đã chạy tiến trình là đủ.
Đường hầm cũng vậy: nếu bạn thử nghiệm với vài đường hầm liên tiếp hoặc quên đóng cái cũ, rất dễ nhầm lẫn App trong Dev Mode đang trỏ tới URL nào. Tốt nhất hình thành thói quen: khi kết thúc phiên — ngắt đường hầm, dừng dev server và khi chạy lại hãy bắt đầu từ “tờ giấy trắng”.
11. Lỗi thường gặp khi làm smoke test đầu tiên
Lỗi số 1: dùng localhost thay vì HTTPS URL công khai.
Rất thường gặp: trong Dev Mode bạn vô tình chỉ định http://localhost:3000 hoặc quên luôn đường hầm. Trên máy bạn mọi thứ ổn, nhưng ChatGPT chạy trên cloud không thể truy cập localhost. Cách chữa đơn giản: kiểm tra rằng trong thiết lập App là đúng địa chỉ HTTPS công khai của đường hầm, với path chính xác (/mcp hay gốc — tùy template).
Lỗi số 2: quên chỉ thị 'use client'; trong file widget.
Bạn viết React đẹp đẽ, thêm onClick, gọi window.openai, nhưng Next.js lặng lẽ biến trang thành server component. Tốt thì nhận lỗi “window is not defined”, xấu thì component không build được. Để dùng API trình duyệt, widget phải là client component — điều này được khẳng định bởi dòng đầu tiên 'use client';.
Lỗi số 3: gọi trực tiếp window.open() thay vì openExternal.
Đôi khi tưởng đơn giản hơn khi làm window.open('https://example.com'). Trong trình duyệt thường thì còn có thể chạy, nhưng trong sandbox của ChatGPT bạn sẽ gặp hành vi khó lường: từ bị lờ đi đến bị chặn. Cách đúng cho ChatGPT Apps là window.openai.openExternal({ href }), ủy quyền việc mở liên kết cho host và tuân thủ chính sách bảo mật.
Lỗi số 4: TypeScript phàn nàn về window.openai và lập trình viên “chữa cháy” bằng việc tắt type.
Đôi lúc trong tuyệt vọng người ta viết // @ts-nocheck ở đầu file. Điều này loại bỏ lỗi biên dịch, nhưng đồng thời tắt toàn bộ TypeScript cho file đó. An toàn hơn là dùng as any có chủ đích quanh window, hoặc mô tả interface tối thiểu cho window.openai trong file riêng. Ở module này, chúng ta chọn helper nhỏ openExternalSafe với (window as any), còn typing gọn gàng sẽ bổ sung sau.
Lỗi số 5: chỉ xem kết quả ở localhost, không thử bên trong ChatGPT.
Dễ bị cám dỗ dừng lại ở việc http://localhost:3000/widget mở được và coi như xong. Nhưng mục tiêu của module này là nhìn thấy App bên trong ChatGPT. Việc mọi thứ ổn trong trình duyệt thường chưa đảm bảo ChatGPT sẽ tạo iframe đúng, tải resource qua đường hầm và không vướng CORS/CSP. Smoke test hoàn chỉnh luôn bao gồm bước chạy App thật trong giao diện ChatGPT.
Lỗi số 6: quên đường hầm hoặc đường hầm bị rớt.
Bạn cập nhật mã, nhưng trong ChatGPT hiển thị phiên bản cũ hoặc chẳng tải gì. Thường là do đường hầm bị đóng vì timeout, nhưng Developer Mode vẫn trỏ tới URL cũ. Nếu khi mở URL đường hầm trong trình duyệt thường bạn thấy lỗi — hãy khôi phục đường hầm trước, rồi mới nghĩ đến Apps SDK.
Lỗi số 7: bỏ qua console trong iframe.
Các lập trình viên quen SPA thường xem console.log ở DevTools ứng dụng của mình, nhưng trong ChatGPT đây là iframe, và bạn cần chọn đúng frame trong DevTools. Nếu chỉ nhìn cấp trên cùng, bạn có thể không thấy lỗi nào, trong khi bên trong widget đã đỏ rực. Thói quen “mở DevTools đúng vào iframe‑widget” sẽ tiết kiệm rất nhiều thần kinh.
GO TO FULL VERSION