เว็บไซต์ที่คุณกำลังอ่านอยู่นี้ถูกสร้างขึ้นทั้งหมดในวันเดียวด้วย Claude Code
โดยเขียนสเปกไว้ในไฟล์ AGENTS.md ก่อน แล้วให้ agent ไล่ทำตามทีละเฟส
ตั้งแต่วางโครงโปรเจกต์ ระบบคอนเทนต์ หน้าเว็บ ระบบค้นหา เทสต์ ไปจนถึง deploy
สิ่งที่น่าสนใจไม่ใช่ความเร็ว แต่คือ ประเภทของบั๊กที่เหลืออยู่หลังจบวัน
เพราะมันเปลี่ยนไปจนต้องเปลี่ยนวิธีทำงานตาม
โครงของวันนั้น
สเปกใน AGENTS.md แบ่งงานเป็น 6 เฟส และงานเดินตามลำดับนั้นจริง —
ดูได้จาก git log ของ repo นี้ที่ commit เรียงตามเฟสแทบตรงตัว
| เฟส | ขอบเขต | ตัวอย่างสิ่งที่ทำ |
|---|
| 1 | Foundation | ตั้งโปรเจกต์ Astro, TypeScript, Tailwind, lint/format |
| 2 | Content | content collections + schema ของแต่ละ collection |
| 3 | Core pages | หน้าแรก บทความ โปรเจกต์ builder log about contact |
| 4 | Discovery | ค้นหา หมวดหมู่ แท็ก RSS sitemap structured data |
| 5 | Quality | accessibility, responsive, unit test, e2e test, 404 |
| 6 | Launch | OG image, analytics, Search Console, deploy production |
เฟส 1–5 แทบไม่มีปัญหาอะไรที่ต้องคิดนาน โค้ดออกมา แล้วก็รันได้
ปัญหาจริง ๆ มากระจุกอยู่ที่รอยต่อระหว่าง “รันบนเครื่องเรา” กับ “รันบนของจริง”
บั๊กที่รอดมาถึง production เป็นบั๊กคนละพันธุ์
ข้อสังเกตที่ชัดที่สุดของวันนั้นคือ
บั๊กเชิงตรรกะ (logic bug) ถูกจับได้เร็วมาก
บั๊กที่รอดมาถึง production คือบั๊กที่ เครื่อง local ไม่มีทางจำลองได้
เหตุผลตรงไปตรงมา: logic bug อยู่ในโค้ดที่ agent เขียนและอ่านได้ทั้งก้อน
แต่บั๊กอีกกลุ่มหนึ่งไม่ได้อยู่ในโค้ดเลย — มันอยู่ใน สิ่งที่ build tool เขียนทับให้
และ สิ่งที่ host ใส่เพิ่มให้ตอน serve ซึ่งทั้งสองอย่างนี้ไม่ปรากฏตัวตอนรันในเครื่อง
สามหัวข้อถัดไปคือกรณีจริงทั้งสามแบบ
ระบบค้นหาของเว็บนี้ใช้ Pagefind ซึ่งต้องโหลด bundle ด้วย dynamic import()
ตอนเปิดกล่องค้นหาครั้งแรก เขียนแบบตรงไปตรงมาก็คือ
const pagefind = await import('/pagefind/pagefind.js');
โค้ดนี้ถูก แต่ใช้ไม่ได้ เพราะ Vite จะเขียน dynamic import() ทุกจุดใหม่
ให้กลายเป็น helper ของตัวเองชื่อ __vitePreload และในสคริปต์ที่ Astro inline ลงไปใน HTML
argument ที่ชื่อ __VITE_PRELOAD__ ไม่ถูกแทนที่ด้วยค่าจริง
ผลคือ browser เจอ identifier ที่ไม่มีอยู่ แล้ว throw ReferenceError
ตั้งแต่ก่อนจะเริ่ม import ด้วยซ้ำ และเพราะโค้ดครอบ try/catch ไว้เพื่อรองรับกรณี
“ยังไม่ได้ build index” มันเลยไปโผล่เป็นข้อความว่าดัชนีค้นหายังไม่ถูกสร้าง
ซึ่งชี้ผิดที่โดยสิ้นเชิง
ทางแก้คือย้าย import ออกไปอยู่ใน is:inline script ที่ Vite ไม่แตะ
<script is:inline>
window.__loadPagefind = () => (window.__pagefindPromise ??= import('/pagefind/pagefind.js'));
</script>
บทเรียนที่เอาไปใช้ต่อได้: โค้ดที่คุณอ่านในไฟล์ไม่ใช่โค้ดที่ browser รัน
เมื่ออาการพังไม่ตรงกับโค้ดที่เห็น ให้ไปเปิดดู output ที่ build ออกมาจริง ๆ ก่อนจะไปแก้ logic
กรณีที่ 2 — security policy มีอยู่แค่บน host
หลังจากใส่ Content Security Policy เข้าไปตามหลัก security ระบบค้นหาพังทันทีบนเว็บจริง
สาเหตุคือ Pagefind ทำงานด้วย WebAssembly และ script-src จะบล็อกการ compile WASM
ถ้าไม่ระบุ 'wasm-unsafe-eval' ไว้ด้วย
"script-src": "'self' 'unsafe-inline' 'wasm-unsafe-eval' …"
ประเด็นสำคัญไม่ใช่ตัว directive ที่ขาด แต่คือ ไม่มีวิธีเจอมันจากในเครื่อง
astro preview ไม่ได้ใส่ CSP ให้ มีแต่ host จริงเท่านั้นที่ใส่
วันนั้นเทสต์ e2e ทั้ง 35 ตัวผ่านหมดในขณะที่เว็บจริงค้นหาไม่ได้
กรณีที่ 3 — ไฟล์ config ที่ host ไม่อ่าน
security header ชุดแรกถูกเขียนไว้ในไฟล์ public/_headers
ซึ่งเป็นรูปแบบที่ Cloudflare Pages และ Netlify อ่าน แต่ Vercel ไม่อ่าน
ต้องเขียนเป็น vercel.json แยกอีกไฟล์
ไฟล์ config ที่ host ไม่รู้จักจะไม่แจ้ง error ใด ๆ — มันแค่เงียบ
build ผ่าน deploy ผ่าน เว็บขึ้น และไม่มี header สักตัว
จะรู้ก็ต่อเมื่อไปยิงดู response header ของเว็บจริงเท่านั้น
ทำไมเทสต์ผ่านหมดทั้งที่เว็บพัง
รวบสามกรณีข้างบนเข้าด้วยกันแล้วจะเห็นรูปแบบเดียวกัน:
สิ่งที่ทำให้พังไม่มีอยู่ในสภาพแวดล้อมที่เทสต์รัน
| สิ่งที่ตรวจ | astro dev | astro preview | production |
|---|
| โค้ดหลัง build ผ่าน Vite | ไม่ตรง | ตรง | ตรง |
| ดัชนี Pagefind | ไม่มี | มี | มี |
| security header จาก host | ไม่มี | ไม่มี | มี |
| Content Security Policy | ไม่มี | ไม่มี | มี |
การอ่าน vercel.json | ไม่ | ไม่ | ใช่ |
แถวสามแถวล่างคือหลุมทั้งหมด เทสต์ e2e ที่รันบน astro preview
ครอบคลุมพฤติกรรมของหน้าเว็บได้ครบ แต่ครอบคลุม สภาพแวดล้อมที่หน้าเว็บนั้นไปอยู่ ไม่ได้เลย
วิธีปิดช่องว่าง: เทสต์ตัว config ไม่ใช่แค่ตัวเว็บ
เมื่อรู้ว่าเทสต์แบบรันเว็บเข้าไม่ถึงชั้นนี้ วิธีที่ใช้จริงคือเขียน unit test
ที่ assert ตัวไฟล์ config โดยตรง (tests/unit/security-headers.test.ts) โดยตรวจสามอย่าง
- มี CSP อยู่จริงทั้งใน
vercel.json และ public/_headers
- policy ในสองไฟล์ เหมือนกันเป๊ะ เพื่อให้ย้าย host แล้วพฤติกรรมไม่เปลี่ยน
- policy มี
'wasm-unsafe-eval' เพื่อให้ค้นหายังทำงาน
it("allows WebAssembly, which Pagefind's search index requires", () => {
expect(vercelCsp).toContain("'wasm-unsafe-eval'");
});
เทสต์แบบนี้ไม่ได้พิสูจน์ว่าเว็บทำงาน มันพิสูจน์แค่ว่า การตั้งค่าที่เคยทำให้พังจะไม่ย้อนกลับมา
ซึ่งเป็นสิ่งที่เทสต์แบบเปิดเว็บทำแทนไม่ได้
หลักที่ได้จากตรงนี้มีสองข้อ
- อะไรที่เทสต์อัตโนมัติเข้าไม่ถึง ต้อง ไปดูของจริงด้วยตาเอง อย่างน้อยหนึ่งครั้ง
บั๊ก CSP กับ header ถูกเจอเพราะไปยิงดู response ของเว็บจริง ไม่ใช่เพราะเทสต์ฟ้อง
- พอเจอแล้ว ให้แปลงสิ่งที่เจอเป็น assertion ที่ระดับที่มันอยู่จริง
บั๊กใน config ต้องมีเทสต์ที่ระดับ config
เขียนสเปกก่อน แล้วกฎถูกบังคับโดยไม่ต้องเถียงใหม่
อีกเรื่องที่เปลี่ยนวิธีทำงานชัดเจนคือการมี AGENTS.md ที่ระบุกฎไว้ล่วงหน้า
ก่อนจะเริ่มเขียนโค้ดบรรทัดแรก
สเปกไฟล์นี้ไม่ได้มีแค่ว่าจะใช้เทคโนโลยีอะไร แต่รวมถึงกฎที่ปกติต้องมาคุยกันทุกครั้ง เช่น
- §31 Content Preservation Rules — ห้ามแต่งประสบการณ์ส่วนตัว ห้ามแต่งตัวเลข ลูกค้า หรือรายได้
ให้ทำเครื่องหมายข้อความที่ยังไม่มีหลักฐานรองรับ
- §32 — โปรเจกต์ที่ยังไม่เสร็จให้ใช้ฟิลด์
status: building ไม่ใช่ซ่อนด้วย draft
- §35 Definition of Done — งานจะเสร็จก็ต่อเมื่อผ่านทั้ง mobile/desktop, keyboard,
type check, lint, เทสต์ และมี metadata ครบ
ผลที่วัดได้คือกฎพวกนี้ถูกบังคับใช้ตอนทำงานจริงโดยไม่ต้องพูดซ้ำ
schema ของ content collection ในโปรเจกต์นี้ถูกเขียนให้ เข้มโดยตั้งใจ —
frontmatter ผิดคือ build พัง ไม่ใช่หน้าเว็บเพี้ยน — และฟิลด์ที่บรรยายผลลัพธ์จริง
อย่าง results ถูกทำให้ optional โดยเจตนา เพื่อไม่ให้มีแรงจูงใจไปแต่งตัวเลขมาใส่ให้ field ครบ
ในโค้ดจริงจะเห็นคอมเมนต์กำกับไว้ตรง ๆ ว่า
/** Only fill in when there is evidence. Never estimate (§31). */
results: z.array(z.string()).optional(),
จุดที่ควรใส่ความเห็นส่วนตัวจึงถูกเว้นว่างไว้เป็น HTML comment แทนที่จะถูกแต่งขึ้นมา
(รวมถึงในบทความนี้ด้วย)
บั๊กที่เหลือ: เรื่องที่ไม่เกี่ยวกับ environment
ไม่ใช่ทุกอย่างจะเป็นเรื่อง production มีอีกสามข้อที่คนละประเภทแต่ก็เจ็บพอกัน
เวอร์ชันล่าสุดไม่ใช่เวอร์ชันที่ใช้ได้ — ตอนนั้น TypeScript 7 ออกแล้ว
แต่ @astrojs/check ประกาศ peer dependency เป็น ^5 || ^6 และ typescript-eslint เป็น <6.1.0
ถ้าลงเวอร์ชันล่าสุดไปเลย ทั้งคำสั่ง type check และ lint จะพังพร้อมกัน
ต้องล็อกไว้ที่ typescript: ^6.0.3 ตามที่เห็นใน package.json
พฤติกรรม default ของ browser แย่งงานเราไป — กด Escape แล้วกล่องค้นหาไม่ปิด
เพราะ <input type="search"> เอา Escape ไปเคลียร์ค่าในช่องก่อนแล้วหยุด event ไว้
ทำให้ Escape-to-close ของ <dialog> ไม่ได้ทำงาน ต้องดักเอง
input?.addEventListener('keydown', (event) => {
if (event.key !== 'Escape') return;
event.preventDefault();
dialog?.close();
});
เนื้อหาตัวอย่างเป็น draft: true ทั้งหมด — ซึ่งแปลว่า production build จะได้เว็บเปล่า
เทสต์เป็นตัวจับได้ ไม่ใช่คน และนี่คือกรณีที่เทสต์คุ้มค่าที่สุดของวัน:
มันจับสิ่งที่ไม่มีใครคิดจะไปตรวจ
สรุปเป็นวิธีทำงาน
สิ่งที่เอาไปใช้กับโปรเจกต์อื่นได้โดยไม่ต้องเป็นเว็บ Astro
- แยกให้ออกว่าบั๊กอยู่ชั้นไหน — โค้ดที่เราเขียน, output ที่ build tool สร้าง,
หรือสภาพแวดล้อมที่ host ใส่ให้ วิธีหาแต่ละชั้นไม่เหมือนกัน
- อย่าเชื่อว่าเทสต์ผ่าน = ใช้งานได้ ถ้าเทสต์ไม่ได้รันในสภาพแวดล้อมเดียวกับของจริง
ให้ระบุออกมาเป็นข้อ ๆ เลยว่ามีอะไรบ้างที่ต่างกัน
- เทสต์ config ที่ระดับ config ถ้ามันเป็นสิ่งที่ทำให้พังได้
- ไปดูของจริงหลัง deploy อย่างน้อยหนึ่งรอบต่อฟีเจอร์ — ยิงดู header, เปิดใช้งานจริง
- เขียนสเปกก่อนเริ่ม กฎที่เขียนไว้ล่วงหน้าคือกฎที่ไม่ต้องมาเถียงกันใหม่ทุกครั้ง
AI ทำให้ขั้นตอน “เขียนโค้ดให้ได้ตามที่ต้องการ” สั้นลงมาก
แต่ขั้นตอน “ตรวจว่ามันทำงานในโลกจริงหรือเปล่า” ยังยาวเท่าเดิม
และตอนนี้มันกลายเป็นส่วนที่กินเวลามากที่สุดของงาน
อ่านต่อ