storycap から storycap-testrun への移行
storybook + storycap + reg-suit で VRT(Visual Regression Testing) していたのだけど、storycap が長らくメンテナンスされておらず、storybook アップデートの障壁になってしまっていた。なので @storybook/test-runner + storycap-testrun へ移行を行った。移行時に色々やったことのメモ。
npm script
storycap では --serverCmd オプションでサーバーの起動を待っていた。
storycap http://127.0.0.1:6006 --serverCmd 'npx http-server -p 6006 storybook-static' --serverTimeout 60000 --captureTimeout 60000
storycap-testrun ではオプションが無くなったので、npm script をサーバ起動とスクリーンショットで分けた。
"storybook:serve": "http-server storybook-static --port 6006 --silent", "storycap:run": "npx wait-on http://127.0.0.1:6006 && test-storybook --url http://127.0.0.1:6006 --index-json --testTimeout 60000 --maxWorkers=3", "storycap": "run-p --race storybook:serve storycap:run",
test-runner.ts の作成
@storybook/test-runner 用の設定。以下のようにした。
import { getStoryContext, type TestRunnerConfig } from '@storybook/test-runner'; import { screenshot } from '@storycap-testrun/node'; const config: TestRunnerConfig = { async preVisit(page, context) { // ストーリーの viewport パラメーターをそのまま Playwright に反映 const storyContext = await getStoryContext(page, context); const viewport = storyContext.parameters?.screenshot?.viewport; if (viewport) { await page.setViewportSize(viewport); } }, async postVisit(page, context) { await screenshot(page, context, { flakiness: { metrics: { enabled: true, retries: 1000, }, retake: { enabled: true, interval: 500, retries: 10, }, }, }); }, }; export default config;
- storycap 用の viewport パラメータがそのまま使えるように、preVisit で渡すようにした。
- postVisit でやってるのは、まだ描画中だったらスクリーンショット取得を待つための設定。これがめちゃめちゃ便利。 storycap のころは waitFor を使って要素がでてくるのを待つなどしていたが、不要になった。
preview.tsx の修正
storycap 時代では、以下のように、スクリーンショット取得時だけ動く decorator を作って動的な要素を固定していた。
import { isScreenshot } from 'storycap'; const FixDateDecorator: Decorator = (Story) => { if (!isScreenshot()) return Story(); MockDate.set('2025-05-01T00:00:00Z'); return Story(); };
しかし、isScreenshot 関数が使えなくなったので自前で定義した。ヘッドレスブラウザか否かで判定してるので雑だけど運用上はこれで OK。
export const isScreenshot = () => { if (typeof window === 'undefined') return false; // Playwright(test-runner)実行時は navigator.webdriver が自動的に true になる。 return navigator.webdriver === true; };
CI
手元では速いけど、CI 環境で動かすと実行速度が異常に遅い現象が発生した。並列実行数が高くて遅くなってそうなので、test-storybook コマンドで --maxWorkers=3 オプションを指定し、3並列にしたら比較的マシになった。
移行後の所感
メリットとしては、スクリーンショット取得が圧倒的に安定した。waitFor で要素の出現を待つ、のようなことが組み込みのリトライの仕組みでいい感じできるようになって、圧倒的にストレスが減った。 デメリットとしては、実行速度は storycap より若干遅くなった。が、誤差の範囲レベルなのでメリットのほうがかなり大きく、移行して最高!という気持ち。
Apollo Client で GraphQL クエリを叩く時、特定のフィールドを参照するのを阻止したい
問題
例えば、以下のようなフィールドがあるとする。
type Image { # 圧縮されていないオリジナルサイズの画像 originalUrl: String! # 圧縮されている画像 url: String! }
originalUrl をむやみに使うとクソデカ画像がクライアントに配信されまくってしまうので、基本は url のほうを使いたい。
解決策
1. フィールドを deprecated にする
originalUrl のほうを deprecated にすれば、@graphql-eslint/eslint-plugin が怒ってくれる。ただし、今回の場合はフィールドを参照するのを今後一切禁止したいわけではなく、明確に使いたい場合は許したいので、要件に合わない。
2. カスタム lint を作る
結局以下のようなカスタム lint を作って対応した。明確に使いたい場合は eslint-disable-next-line してもらう。(rulesdir 使ってるけど、no-restricted-fieldsに定義でも別によさそう)
module.exports = { meta: { type: 'problem', docs: { description: 'Disallow usage of originalUrl in GraphQL queries', category: 'Best Practices', recommended: true, }, messages: { noOriginalUrl: 'originalUrl を使っていますが、原寸画像なので特別な理由が無ければ url を使ってください。', }, schema: [], }, create(context) { return { TaggedTemplateExpression(node) { // gql タグかどうかを確認 if (node.tag.type !== 'Identifier' || node.tag.name !== 'gql') { return; } // テンプレートリテラルの内容をチェック for (const quasi of node.quasi.quasis) { const text = quasi.value.raw; // originalUrl が単語境界で含まれているかチェック if (/\bOriginalUrl\b/.test(text)) { context.report({ node: quasi, messageId: 'noOriginalUrl', }); } } }, }; }, };
不動産紹介サイトの情報から住所を特定するWebアプリを作ってた
不動産、好きですか?私は中古物件を見るのが好きでたまに SUUMO や HOME's のような不動産紹介サイトを徘徊しています。
そこでよさげな物件を見つけると、不動産紹介サイトの限られた情報から住所を特定するというのが趣味です。
不動産紹介サイトに書いてある物件の場所に関わる情報は、以下です。
- 大まかな住所(
東京都渋谷区道玄坂1丁目など、丁目までの情報) - 周辺施設からの距離(
セブンイレブン 渋谷道玄坂1丁目店:徒歩5分など)
これらの情報から、Google Map を開いてだいたいここだろう!という場所にストリートビューのピンを刺して、外観が合ってたら特定!みたいなキモいことをやっています。
これを Web アプリで簡単にできたらいいのでは?と思い作ってみることにしました。
作ってみた
実際に情報を入れてみたあとのスクショが以下です。候補地が赤く塗られてるので、あとはこの絞られた範囲の中で人間が探せばいい、という寸法です。

ほぼほぼ AI エージェントに書いてもらったのでそんなに面白ポイントはないですが、しいて言えば…
Leaflet.js + OpenStreetMap でマップを表示している
Google Map のほうがシームレスにストリートビューでピンさせて便利だけど、API キーの用意とか面倒だし今回の用途ならこれで十分と判断。
本当は例えば徒歩5分なら0~4分圏内は候補地から除外したかった
ドーナッツ上の円が交差した部分を候補地としたほうがより正確なはず。しかしこれを実現するには、円の上に白抜きでさらに円を描画する、みたいなことをやる必要があり、マップが非常に見にくくなったのでやめた。
徒歩1分=80m
不動産の表示に関する公正競争規約で決まってるらしい。へぇ。しかしあくまで円で描画した範囲は直線距離で、実際は道はもうちょっと曲がりくねっていると思うのでバッファを設ける必要がありそう。
感想
作ってみたのはいいけど、数回使って飽きてしまった…
恐らく、限られた情報から住所を特定することにゲーム性を感じていたので、そこを効率化するのは違かったのかもしれない…
無駄も大事という教訓ですね。みなさん、無駄も楽しみましょう。本年もよろしくお願いします。
この記事は はてなエンジニア Advent Calendar 2025 - Hatena Developer Blog の37日目の記事です。
storycap + reg-suit による VRT 導入メモ
Next.js 環境の VRT をするために、storycap + reg-suit を導入したので、導入時に困ったりしたことをメモ。
CI 環境は GitHub Actions、保存先は S3。
storycap がタイムアウトする
最初 storybook dev で storybook を立ち上げて snapshot を取ってみたが、タイムアウトしてしまっていた。
$ npx storycap http://localhost:6006 --serverCmd 'storybook dev -p 6006' info Wait for connecting storybook server http://localhost:6006. error Timed out waiting for: http-get://localhost:6006
--serverTimeout オプションを伸ばしても無駄で、storybook dev -p 6006 & sleep 15' のよう sleep すると動くが、たまに動かなくてイマイチ安定しなかった。
storybook dev を諦めて、ビルドしたあとに http-server でホストするようにしたらうまくいった。
$ npx storybook build $ npx storycap http://localhost:6006 --serverCmd 'npx http-server -p 6006 storybook-static'
前回の snapshot と比較できない
最初は reg-suit init に従って reg-keygen-git-hash-pluginで比較元、比較先の snapshot key を取得しようとしたが、Failed to detect the previous snapshot key と言われ、うまくいかなかった。
actions/checkout@v4 で fetch-depth: 0 にするとうまくいくが、checkout に時間がかかるようになってしまったので、reg-keygen-git-hash-plugin をやめてreg-simple-keygen-plugin で snapshot key を自前で設定するようにした。
日本語が文字化けする
日本語が豆腐(□) になってしまっていた。CI の中で fonts-ipafont を明示的に入れてあげて解決。
最終的に、actions の .yml ファイルと regconfig.json は以下のようになった。
# actions.yml
name: vrt-nextjs
on:
push:
branches:
- main
pull_request:
paths:
- '<file_path>'
jobs:
vrt-nextjs:
runs-on: ubuntu-latest
permissions:
id-token: write
contents: read
steps:
- name: checkout
uses: actions/checkout@v4
- name: Configure AWS Credentials
uses: aws-actions/configure-aws-credentials@4
with:
aws-region: <region>
role-to-assume: <role>
role-session-name: <role_name>
- name: Install Japanese Fonts
run: |
sudo apt-get install fonts-ipafont
- name: Setup node
uses: actions/setup-node@v4
with:
node-version-file: '.node-version'
- name: npm ci
run: npm ci
- name: build storybook
run: npx storybook build
- name: capture snapshot
run: npx storycap http://localhost:6006 --serverCmd 'npx http-server -p 6006 storybook-static' --serverTimeout 60000
- name: exec reg-suit
run: npx reg-suit run
env:
# on: push で動いた場合は比較したいわけじゃなくて比較対象のスクリーンショットを取りたいだけなので、ダミーのキーを指定しておく
REG_EXPECTED_KEY: ${{ github.event.pull_request.base.sha || 'pseudo_expected_key' }}
REG_ACTUAL_KEY: ${{ github.event.pull_request.head.sha || github.sha }}
# regconfig.json { "core": { "workingDir": ".reg", "actualDir": "__screenshots__", "thresholdRate": 0, "addIgnore": true, "ximgdiff": { "invocationType": "client" } }, "plugins": { "reg-simple-keygen-plugin": { "expectedKey": "$REG_EXPECTED_KEY", "actualKey": "$REG_ACTUAL_KEY" }, "reg-notify-github-plugin": { "prComment": true, "prCommentBehavior": "default", "clientId": "<client_id>" }, "reg-publish-s3-plugin": { "bucketName": "<bucket_name>", "acl": "private" } } }
次は setup-node キャッシュとか ubuntu-latest じゃなくて別な高速なランナーにするとかしたい。
リモートワークで退勤後にテンションを上げるための技術
この記事は、はてなエンジニアAdvent Calendar 2023 の2024年1月9日の記事です。
昨日はid:arthur-1さんの Advent Calendarを25日分続ける技術、あるいは物を発明し素早く作る技術 - Diary of a Perpetual Student でした。アウトプットが続かない私としては25日も続けるのは尊敬です。見習いたい…
弊社はフルリモートで働くことが可能*1で、通勤が苦手な自分にとっては天国です。しかし、リモートで働いていると日々にメリハリが無く、仕事を終えてもテンションが上がらないな…と感じていました。
そこで、退勤ボタンを押した時にテンションを上げるための技術を導入しました。
用意したのはこちらのアイテムです。
早速ですが、実際に退勤ボタンを押した際の様子をご覧ください。
𝑷𝒂𝒓𝒕𝒚 𝑻𝒊𝒎𝒆 ...
どうですか?テンションが上がりそうでしょう?BGM は権利の問題上フリーの音源に変えていますが、実際には Get Wild を流しています。
作り方
準備
まずはスマートプラグとミラーボールをコンセントに繋ぎ、IFTTT 経由でオンオフできるようにします。その辺の詳細は以前 在宅ワーク中に家族に会議中だとアピールするライフハック - magamingのブログで書いたので、そちらを見てもらうとよさそうです。
スマートプラグでなくても、赤外線リモコンがついているミラーボールを、Nature Remo や SwitchBot などのスマートリモコンで操作する形でもよいと思います。
スクリプトの作成
弊社では勤怠システムに Akashi を利用しているので、ブラウザ上で勤怠管理ができます。これが何を意味するか、つまり、JavaScript を埋め込み放題という事です。今回は以下のような JS を用意しました。
const iftttUrl = '<IFTTT でスマートプラグをONにするためのURL>'; // GetWild const videoId = 'NHKq8IOXPxA'; const playerElement = document.createElement('div'); playerElement.id = 'player'; document.body.appendChild(playerElement); const tag = document.createElement('script'); tag.src ='https://www.youtube.com/iframe_api'; const firstScriptTag = document.getElementsByTagName('script')[0]; firstScriptTag.parentNode.insertBefore(tag, firstScriptTag); var player; function onYouTubeIframeAPIReady() { player = new YT.Player('player', { // 音だけ再生できればよいので、サイズは 0 height: '0', width: '0', videoId: videoId, }); } const partyTime = () => { fetch(iftttUrl); // ifttt によるスイッチオンにタイムラグがあるので1秒待つ setTimeout(() => { // サビまでシーク player.seekTo(60); player.playVideo(); }, 1000); } const taikinButton = document.querySelector(<退勤ボタンの class>); taikinButton.addEventListener('click', partyTime);
要は iframe の Player を無理やり埋め込んで、退勤ボタンを押したら「IFTTT URL を叩く」「Youtube Player API で再生」をやっているだけです。
スクリプトの埋め込み
最初は Chrome 拡張作ろうかと思ってましたが、めんどくなってきたので今回は ScriptAutoRunner で雑に JS を実行する形にしました。今回のように、雑にJSで遊びたいときにはオススメです。ブラウザ上で動く勤怠システムであれば、Akashi でなくても導入可能なはずです。
いかがでしたか?皆様も退勤に彩りを与えてみてはいかがでしょうか。それではよい退勤ライフを…
GitHub actions で PR が無ければ作る、あれば更新する
こんな感じでできた。gh pr list で標準で jq 使えることが今回の発見。かっこいい。
env: TARGET_BRANCH: 'target-branch' GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} jobs: create-or-update-pr: runs-on: ubuntu-latest steps: - name: search pr number and set to env # この場合は head が 'target-branch' かつマージされてないブランチを探している run: echo "PR_NUMBER=$(gh pr list --search "head:${{ env.TARGET_BRANCH }} is:unmerged" --json number --jq ".[].number")" >> $GITHUB_ENV - name: create pull request # 空なら 0 が入る ref. https://docs.github.com/ja/actions/learn-github-actions/expressions#operators if: ${{ env.PR_NUMBER == 0 }} run: gh pr create --base main --head ${{ env.TARGET_BRANCH }} --title "タイトル" --body "本文" - name: update pull request if: ${{ env.PR_NUMBER != 0 }} run: gh pr edit ${{ env.PR_NUMBER }} --title "更新後のタイトル" --body "更新後の本文"
MySQL でテーブル定義を JSON形式で吐き出す
諸事情でテーブル名と、テーブルのカラムを以下のような JSON 形式で出す必要があった。
{ "<テーブル名1>": [ "<カラム名1>", "<カラム名2>", ... ], "<テーブル名2>": [ "<カラム名1>", "<カラム名2>", ... ] }
最初は MySQL でテーブル定義を csv に吐き出して、スプレッドシートに貼り付けて、GAS で JSON 形式に変換する… みたいな回りくどいことをやっていたけど、JSON 周りの関数を使えばどうやら MySQL 一発でいけそうだった。
SELECT JSON_OBJECTAGG( c1.table_name, ( SELECT JSON_ARRAYAGG(column_name) FROM information_schema.columns as c2 WHERE c2.table_name = c1.table_name ) ) FROM information_schema.columns as c1 WHERE table_schema = '<DB名>' ;
これらの関数は MySQL 5.7.22 以降なら使える模様だけど、sort には未対応っぽいのでいい感じに並べ替えたいなら別な方法を取る必要があるかも。
Aggregates a result set as a single JSON array whose elements consist of the rows. The order of elements in this array is undefined.
MySQL :: MySQL 8.0 Reference Manual :: 12.19.1 Aggregate Function Descriptions

