このページでは、Jev を E2E テストに組み込んだ結果を扱います。最初は Jev にリンクを選ばせてテストを最後まで進めていましたが、公式ドキュメントと照らすとずれていました。作り直した結果、Jev は「機能が正しく動くか」の合否ではなく、「画面の文言から行き先が読み取れるか」を測るのに向いていることが分かりました。

題材はこのサイトの既読機能です。ページを末尾まで読むと、ノートのページ一覧に ✓ が付き、ボタンが「今すぐ読む」から「続きから読む」に変わります。テストでは、一覧の先頭にある コールスタックのノート を開きます。

最初の作りでずれていた点

最初の作りでは、Jev の Choice で「次に押すリンク」を選ばせ、それをループで繰り返して既読のテストを最後まで回していました。クリックと合否の判定はコードが持ち、Jev には選ぶことだけをさせていたので、方向は合っていました。公式の「コードが処理の流れを持ち、AI は常識的な判断だけをする」という考え方と同じです。ずれていたのは次の 4 点です。

  • 道順が決まっているのにループで進ませていた。 公式は次のように書いています

Avoid agent while loops when a software workflow can express the same behavior.

出典: https://docs.typesafe.ai/concepts/how-to-build-with-system-one.md(取得日: 2026-09-23)

  • 正解が複数ある質問をしていた。 「ノートを 1 つ選ぶ」のような質問では、確率が正解どうしで割れて confidence が下がります
  • confidence の閾値を 0.5 に固定していた。 公式は、閾値は 1 つの値ではなく、判断ごとのリスクに合わせて決めるよう勧めています
  • コードで判定できることを Jev に聞いていた。 ✓ が付いているかどうかは、DOM を見れば分かります

作り直した形

既読機能そのものの合否は、Jev を使わない決定的なテスト(readState.e2e.ts)にしました。どのリンクを押すかは、読者に見える名前(アクセシブルネーム)でコードが決めます。

Jev のテスト(navigation.jev.ts)は、目的を分けて別に置きました。読者の目的を言葉で渡したとき、画面の文言だけから正しいリンクを選べるかを見ます。

  • 1 画面につき 1 回だけ選ばせる。正解の行き先はコードが DOM から決める
  • 選択肢に「どれでもない(none)」を入れる。合うリンクが無い画面で、無理に何かを選ばせない
  • 同じ行き先のリンクは 1 つの選択肢にまとめ、文言をつなぐ。分けると正解が 2 つに割れ、confidence が下がる
  • 目的は英語で書き、画面の文言は日本語のまま渡す
  • confidence は導線の分かりやすさの指標として扱う。0.8 未満は警告、0.5 未満は失敗
// website/e2e/jevBrowser.ts
// Jev(TypeSafe の System One モデル)に「読者の目的に合うのはどのリンクか」を 1 回だけ選ばせる。
// 押すかどうか・合否はコードが決める(ループは回さない)。
// Jev はテキストを生成しないので、画面上のリンク・ボタンを Choice の選択肢にして渡す(最大 255 個)。
// 公式は英語が主で他言語は精度が下がるとしているので、質問と目的は英語で書く(画面の文言は日本語のまま)。
// 参照: https://docs.typesafe.ai/primitives/choice.md ・ https://docs.typesafe.ai/concepts/state.md
import { choice, TypeSafeClient } from '@typesafe-ai/sdk'
import type { Page } from 'puppeteer'

export const jev = new TypeSafeClient()

export type Clickable = { id: string; text: string; href: string | null }

/** どれも目的に合わないときの選択肢。無いと、合うリンクが無い画面でも何かを選んでしまう */
export const NONE = 'none'

/**
 * 画面に見えているリンクとボタンに番号を振って返す。ページの外に隠した要素(スキップリンクなど)は除く。
 * 同じ行き先のリンク(「今すぐ読む」とページ一覧の 1 件目など)は 1 つにまとめる。分けると確率が割れて confidence が下がる。
 * まとめるときは文言をつなぐ。1 つだけ残すと「今すぐ読む」のようなボタン名が消える
 */
export async function listClickables(page: Page): Promise<Clickable[]> {
  return page.$$eval('a[href], button', (els) => {
    const byKey = new Map<string, { el: Element; texts: string[]; href: string | null }>()
    els.forEach((el, i) => {
      const r = el.getBoundingClientRect()
      const text = (el as HTMLElement).innerText.replace(/\s+/g, ' ').trim()
      if (r.width === 0 || r.height === 0 || text === '') return
      if (r.right + scrollX <= 0 || r.bottom + scrollY <= 0) return
      const href = el.getAttribute('href')
      const key = href ?? `button-${i}`
      const seen = byKey.get(key)
      if (!seen) byKey.set(key, { el, texts: [text], href })
      else if (!seen.texts.includes(text)) seen.texts.push(text)
    })
    return [...byKey.values()].slice(0, 254).map(({ el, texts, href }, i) => {
      el.setAttribute('data-jev-id', `e${i}`)
      return { id: `e${i}`, text: texts.map((t) => t.slice(0, 80)).join(' / '), href }
    })
  })
}

export type JevPick = { picked: Clickable | undefined; confidence: number }

/** 読者の目的(goal)に合うリンクを Jev に選ばせる。クリックはしない。合うものが無ければ picked は undefined */
export async function jevPick(page: Page, goal: string): Promise<JevPick> {
  const clickables = await listClickables(page)
  const criteria = Object.fromEntries([
    ...clickables.map((c) => [c.id, c.href ? `link "${c.text}" (to ${c.href})` : `button "${c.text}"`]),
    [NONE, 'None of the elements on this screen serves the goal'],
  ])
  const { answers } = await jev.systemOne({
    state: {
      goal,
      current_url: new URL(page.url()).pathname,
      page_title: await page.title(),
      headings: await page.$$eval('h1, h2', (hs) => hs.map((h) => h.textContent?.trim() ?? '')),
    },
    questions: { next: choice('Which element should a reader click next to achieve `goal`?', criteria) },
  })
  const { choice: id, confidence } = answers.next
  return { picked: clickables.find((c) => c.id === id), confidence }
}
// website/e2e/navigation.jev.ts
// 導線の分かりやすさを Jev で確かめる。読者の目的を言葉で渡し、画面の文言だけから正しいリンクを選べるかを見る。
// 行き先の正解はコードが DOM から決め、合否は「Jev が正解を選んだか」と confidence の下限で判定する。
// confidence は導線の分かりやすさの指標として 2 段で扱う。値は実測から決めた(2026-09-23):
//   - 今の画面では 0.72〜0.97。0.8 未満は警告だけ出す(文言を見直す合図)
//   - 文言を壊すと下がる(「続きから読む」を「今すぐ読む」に変える → 0.27、パンくずを消す → 0.24)。0.5 未満は失敗にする
// 実行: pnpm test:e2e:jev(TYPESAFE_API_KEY が必要。website/.env.local に書いてもよい)
import type { Page } from 'puppeteer'
import { afterAll, beforeAll, describe, expect, test } from 'vitest'
import { READ_ARTICLES_KEY } from '../app/data/readState'
import { openSite, type E2EContext } from './browser'
import { jevPick } from './jevBrowser'

// これを下回ったら、正解していても「分かりにくい導線」として警告する
const CLEAR_CONFIDENCE = 0.8
// これを下回ったら、正解していても「文言から行き先が読み取れない」として失敗にする
const MIN_CONFIDENCE = 0.5

let ctx: E2EContext
let notePath: string
let pagePaths: string[]

const hrefOf = (page: Page, selector: string) => page.$eval(selector, (a) => a.getAttribute('href')!)

async function open(path: string, readMap: Record<string, string> = {}) {
  const { page, origin } = ctx
  await page.goto(`${origin}/notes`)
  await page.evaluate((key, value) => localStorage.setItem(key, value), READ_ARTICLES_KEY, JSON.stringify(readMap))
  await page.goto(`${origin}${path}`, { waitUntil: 'networkidle0' })
}

/** Jev に選ばせて、正解(expected。合うものが無いなら undefined)と比べる */
async function expectPick(goal: string, expected: string | undefined) {
  const { picked, confidence } = await jevPick(ctx.page, goal)
  const label = picked ? `「${picked.text}」(${picked.href ?? 'button'})` : 'none'
  const clarity = confidence < CLEAR_CONFIDENCE ? ' ⚠ 分かりにくい' : ''
  console.log(`[jev] ${goal} → ${label} confidence ${confidence.toFixed(2)}${clarity}`)
  expect(picked?.href ?? undefined).toBe(expected)
  expect(confidence).toBeGreaterThanOrEqual(MIN_CONFIDENCE)
}

beforeAll(async () => {
  ctx = await openSite()
  // readState.e2e.ts と同じく、一覧の先頭の Note を使う
  await ctx.page.goto(`${ctx.origin}/notes`, { waitUntil: 'networkidle0' })
  notePath = (await ctx.page.$$eval('a[href^="/notes/"]', (as) =>
    as.map((a) => a.getAttribute('href')!).find((h) => /^\/notes\/[^/?]+$/.test(h)),
  ))!
  await ctx.page.goto(`${ctx.origin}${notePath}`, { waitUntil: 'networkidle0' })
  pagePaths = await ctx.page.$$eval('.timeline-link', (as) => as.map((a) => a.getAttribute('href')!))
  expect(pagePaths.length).toBeGreaterThan(1)
})

afterAll(async () => {
  await ctx?.close()
})

describe('Note のトップ', () => {
  test('はじめから読む', async () => {
    await open(notePath)
    await expectPick('Start reading this note from the first page', pagePaths[0])
  })

  test('1 ページ読んだあとに続きから読む', async () => {
    const firstPageId = pagePaths[0].split('/').pop()!
    await open(notePath, { [firstPageId]: new Date().toISOString() })
    await ctx.page.waitForSelector('.timeline-item.is-read')
    await expectPick('I already finished some pages. Resume reading where I left off', pagePaths[1])
  })
})

describe('Page', () => {
  test('次のページへ進む', async () => {
    await open(pagePaths[0])
    await expectPick('Go on to the next page of this note', pagePaths[1])
  })

  test('Note のページ一覧に戻る', async () => {
    await open(pagePaths[1])
    await expectPick('Go back to the overview of this note that lists all of its pages', notePath)
  })

  test('Note 一覧に戻る', async () => {
    await open(pagePaths[1])
    await expectPick('Browse other notes on this site', await hrefOf(ctx.page, '.breadcrumb a'))
  })

  test('画面に無い操作は none を選ぶ', async () => {
    await open(pagePaths[0])
    await expectPick('Switch the site language to English', undefined)
  })
})

比べるための決定的なテストです。

// website/e2e/readState.e2e.ts
// 既読機能の E2E テスト。どのリンクを押すかは、読者に見える名前(アクセシブルネーム)でコードが決める。
// 仕様は app/routes/page.tsx の ReadMarker と app/data/readState.ts:
//   - Page を開いただけでは既読にしない
//   - 本文末尾の目印(.article-end)が画面に入ったら localStorage の read-pages-v1 に記録する
//   - Note のページ一覧に ✓ が付き、ボタンが「続きから読む」になる
// 実行: pnpm test:e2e
import { afterAll, beforeAll, expect, test } from 'vitest'
import { READ_ARTICLES_KEY } from '../app/data/readState'
import { clickAndWait, openSite, pathnameOf, type E2EContext } from './browser'

const NOTE_PATH = /^\/notes\/[^/]+$/

let ctx: E2EContext

beforeAll(async () => {
  ctx = await openSite()
})

afterAll(async () => {
  await ctx?.close()
})

const readMap = () =>
  ctx.page.evaluate((key) => JSON.parse(localStorage.getItem(key) ?? '{}') as Record<string, string>, READ_ARTICLES_KEY)

test('Page を末尾まで読むと既読になり、Note の一覧に反映される', async () => {
  const { page, origin } = ctx

  // 1. Note 一覧の先頭の Note を開き、「今すぐ読む」で最初の Page へ進む
  await page.goto(`${origin}/notes`, { waitUntil: 'networkidle0' })
  const notePath = await page.$$eval('a[href^="/notes/"]', (as) =>
    as.map((a) => a.getAttribute('href')!).find((h) => /^\/notes\/[^/?]+$/.test(h)),
  )
  expect(notePath).toMatch(NOTE_PATH)
  await clickAndWait(page, `a[href="${notePath}"]`)
  const noteTitle = await page.$eval('h1', (h) => h.textContent!.trim())
  await clickAndWait(page, '::-p-aria(今すぐ読む[role="link"])')

  const [, , noteId, pageId] = pathnameOf(page).split('/')
  expect(`/notes/${noteId}`).toBe(notePath)

  // 2. 開いただけでは既読にならない(末尾の目印が最初から画面に入る短い Page では確かめられない)
  await page.waitForNetworkIdle()
  const endVisibleOnOpen = await page.$eval('.article-end', (el) => el.getBoundingClientRect().top < innerHeight)
  if (endVisibleOnOpen) {
    console.log('[test] 末尾が最初から画面に入っているので「開いただけでは既読にならない」は確かめない')
  } else {
    expect((await readMap())[pageId]).toBeUndefined()
  }

  // 3. 末尾までスクロールすると既読になる
  await page.$eval('.article-end', (el) => el.scrollIntoView())
  await page.waitForFunction(
    (key, id) => JSON.parse(localStorage.getItem(key) ?? '{}')[id] !== undefined,
    { timeout: 5000 },
    READ_ARTICLES_KEY,
    pageId,
  )
  const readAt = (await readMap())[pageId]
  expect(new Date(readAt).toISOString()).toBe(readAt)

  // 4. パンくずの Note 名から Note のトップへ戻る
  await clickAndWait(page, `::-p-aria([name="パンくず"]) ::-p-aria(${noteTitle}[role="link"])`)
  expect(pathnameOf(page)).toBe(notePath)

  // 5. 読んだ Page にだけ ✓ が付き、ボタンが「続きから読む」になる
  await page.waitForSelector('.timeline-item.is-read')
  const timeline = await page.$$eval('.timeline-item', (items) =>
    items.map((li) => ({
      href: li.querySelector('a')?.getAttribute('href') ?? '',
      mark: li.querySelector('.timeline-dot')?.textContent?.trim() ?? '',
    })),
  )
  const marked = timeline.filter((t) => t.mark === '✓').map((t) => t.href.split('/').pop())
  expect(marked).toEqual([pageId])
  // 「続きから読む」は最初の未読 Page(ここでは 2 ページ目)へ進む
  if (timeline.length > 1) {
    const button = await page.$eval('.note-read-button', (b) => ({ text: b.textContent!.trim(), href: b.getAttribute('href') }))
    expect(button).toEqual({ text: '続きから読む', href: timeline[1].href })
  }

  // 6. 再読み込みしても既読が残る(localStorage から復元される)
  await page.reload({ waitUntil: 'networkidle0' })
  await page.waitForSelector('.timeline-item.is-read')
  expect((await readMap())[pageId]).toBe(readAt)
})

openSite(ビルド済みのサイトを手元で配信し、puppeteer で開く)などの補助関数は website/e2e/browser.ts にあります。

回してみる

まず、何も壊していない状態で両方を回しました。

6 つの判断はすべて正解です。「はじめから読む」だけ confidence が 0.67 と低く、警告が出ました。ボタンの文言が「今すぐ読む」で、「最初から」という言葉が画面のどこにも無いためと考えられます。

文言をわざと壊す

アプリを 2 通りに壊して、両方のテストを回しました。1 つめは、ノートのトップのボタンを、既読があっても「続きから読む」にならず「今すぐ読む」のままにする壊し方です。

2 つめは、ページ末尾のカードから「次のページ」という見出しだけを消す壊し方です。リンクの行き先は変わりません。

1 つめは、どちらのテストも気づきました。2 つめは、Jev は正解を選んだまま confidence が 0.93 から 0.57 に下がり、警告を出しました。決定的なテストは気づきません。リンクの行き先が変わっていないので、機能としては壊れていないからです。ただ、読者から見ると「次のページ」という手がかりが消えています。

分かったこと

  • 「この機能が正しく動くか」の合否は、決定的なテストのほうが向きます。こちらに Jev は要りません
  • Jev が役に立つのは、「文言から行き先が読み取れるか」を測るときです。見出しを消すような、機能は壊れていないのに分かりにくくなる変化でも、confidence が下がります
  • 呼び出しのたびに API キーと料金がかかり、confidence も少しぶれます。CI で合否を決めるより、UI を変えたときに手元で回して数字の推移を見る使い方が合います

0.5 と 0.8 の閾値は、このサイトのこの画面で決めた値です。ほかの画面でも通用するかは確かめていません。