-
Notifications
You must be signed in to change notification settings - Fork 0
馬可無序プロジェクトのバナー設置 #630
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
馬可無序プロジェクトのバナー設置 #630
Changes from all commits
Commits
Show all changes
14 commits
Select commit
Hold shift + click to select a range
345d3fb
Initial plan
Copilot 1bbe69f
Add MakamujoBanner component and place it on all required pages
Copilot a332fe1
Always show MakamujoBanner in articles; add MakamujoBanner behavior t…
Copilot 4713208
Rewrite MakamujoBanner test with click-behavior hit-testing
Copilot 67c6b6f
Replace MakamujoBanner unit test with Playwright E2E click tests
Copilot 0ed037f
Add Playwright browser install step to e2e CI job
Copilot cbab409
Move MakamujoBanner after description paragraph in works index page
Copilot 8403766
Fix E2E test: reject on early dev-server exit and await graceful shut…
Copilot 197a4c6
Hide MakamujoBanner in Article.tsx on sub-index pages
Copilot 5148f9e
Fix afterAll timeout in E2E test - add 30s timeout
Copilot a9e6423
Fix E2E afterAll hang: spawn with detached:true and kill entire proce…
Copilot edd3091
Merge branch 'source' into copilot/add-banner-for-makamujo-project
nahcnuj ce7108e
Add GA event tracking to MakamujoBanner (click_makamujo_nicovideo / c…
Copilot c95903c
E2E: verify click_makamujo_nicovideo / click_makamujo_landing GA even…
Copilot File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,42 @@ | ||
| import { css } from 'hono/css' | ||
|
|
||
| const wrap = css` | ||
| display: flex; | ||
| justify-content: center; | ||
| margin: 0.8rem 0; | ||
| ` | ||
|
|
||
| export default function MakamujoBanner() { | ||
| return ( | ||
| <div class={wrap}> | ||
| <img | ||
| src="https://www.nahcnuj.work/makamujo/banner.png" | ||
| alt="馬可無序(まか・むじょ)- AI-VTuber" | ||
| width="320" | ||
| height="100" | ||
| usemap="#makamujo-banner-map" | ||
| style="max-width: 100%; height: auto;" | ||
| /> | ||
| <map name="makamujo-banner-map"> | ||
| {/* Banner is 320x100px. The "ニコニコ生放送で配信中" badge occupies the bottom strip at x:105-306, y:67-87. */} | ||
| <area | ||
| shape="rect" | ||
| coords="105,67,306,87" | ||
| href="https://live.nicovideo.jp/watch/user/14171889" | ||
| target="_blank" | ||
| rel="noopener noreferrer" | ||
| alt="ニコニコ生放送で配信中" | ||
| data-gtag-event="click_makamujo_nicovideo" | ||
| /> | ||
| <area | ||
| shape="default" | ||
| href="https://www.nahcnuj.work/makamujo/index.html" | ||
| target="_blank" | ||
| rel="noopener noreferrer" | ||
| alt="馬可無序プロジェクト" | ||
| data-gtag-event="click_makamujo_landing" | ||
| /> | ||
| </map> | ||
| </div> | ||
| ) | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,218 @@ | ||
| import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' | ||
| import { setupMakamujoBannerTracking } from './makamujoBannerTracker' | ||
| import type { GtagFn } from './scrollDepthTracker' | ||
|
|
||
| // --------------------------------------------------------------------------- | ||
| // Helpers | ||
| // --------------------------------------------------------------------------- | ||
|
|
||
| type FakeArea = { | ||
| dataset: { gtagEvent?: string } | ||
| href: string | ||
| addEventListener: (event: string, handler: (e: Event) => void) => void | ||
| /** Simulate a user click on this area element. */ | ||
| triggerClick: () => { preventDefault: ReturnType<typeof vi.fn> } | ||
| } | ||
|
|
||
| function makeFakeArea(gtagEvent: string | undefined, href: string): FakeArea { | ||
| let clickHandler: ((e: Event) => void) | undefined | ||
| return { | ||
| dataset: { gtagEvent }, | ||
| href, | ||
| addEventListener: (event, handler) => { | ||
| if (event === 'click') clickHandler = handler | ||
| }, | ||
| triggerClick: () => { | ||
| const e = { preventDefault: vi.fn() } | ||
| clickHandler?.(e as unknown as Event) | ||
| return e | ||
| }, | ||
| } | ||
| } | ||
|
|
||
| function makeSetup( | ||
| areas: FakeArea[], | ||
| opts: { | ||
| navigate?: ReturnType<typeof vi.fn> | ||
| maxDelayMs?: number | ||
| gtag?: GtagFn | ||
| whenReady?: (fn: () => void) => void | ||
| } = {}, | ||
| ) { | ||
| const navigate = opts.navigate ?? vi.fn() | ||
| const gtag = opts.gtag ?? vi.fn() | ||
| const whenReady = opts.whenReady ?? ((fn) => fn()) | ||
|
|
||
| setupMakamujoBannerTracking({ | ||
| gtagFn: gtag as GtagFn, | ||
| whenReady, | ||
| getAreaElements: () => areas as unknown as HTMLAreaElement[], | ||
| navigate, | ||
| maxDelayMs: opts.maxDelayMs ?? 500, | ||
| }) | ||
|
|
||
| return { navigate, gtag } | ||
| } | ||
|
|
||
| // --------------------------------------------------------------------------- | ||
| // Tests | ||
| // --------------------------------------------------------------------------- | ||
|
|
||
| describe('setupMakamujoBannerTracking', () => { | ||
| beforeEach(() => { | ||
| vi.clearAllMocks() | ||
| vi.useFakeTimers() | ||
| }) | ||
|
|
||
| afterEach(() => { | ||
| vi.useRealTimers() | ||
| }) | ||
|
|
||
| describe('GA event name', () => { | ||
| it('fires "click_makamujo_nicovideo" when the NicoNico badge area is clicked', () => { | ||
| const area = makeFakeArea('click_makamujo_nicovideo', 'https://live.nicovideo.jp/watch/user/14171889') | ||
| const { gtag } = makeSetup([area]) | ||
| area.triggerClick() | ||
| expect(gtag).toHaveBeenCalledWith('event', 'click_makamujo_nicovideo', expect.any(Object)) | ||
| }) | ||
|
|
||
| it('fires "click_makamujo_landing" when the landing-page area is clicked', () => { | ||
| const area = makeFakeArea('click_makamujo_landing', 'https://www.nahcnuj.work/makamujo/index.html') | ||
| const { gtag } = makeSetup([area]) | ||
| area.triggerClick() | ||
| expect(gtag).toHaveBeenCalledWith('event', 'click_makamujo_landing', expect.any(Object)) | ||
| }) | ||
|
|
||
| it('passes event_timeout equal to maxDelayMs', () => { | ||
| const area = makeFakeArea('click_makamujo_nicovideo', 'https://live.nicovideo.jp/watch/user/14171889') | ||
| const { gtag } = makeSetup([area], { maxDelayMs: 300 }) | ||
| area.triggerClick() | ||
| expect(gtag).toHaveBeenCalledWith( | ||
| 'event', | ||
| 'click_makamujo_nicovideo', | ||
| expect.objectContaining({ event_timeout: 300 }), | ||
| ) | ||
| }) | ||
| }) | ||
|
|
||
| describe('navigation via event_callback', () => { | ||
| it('does not navigate immediately after click (waits for GA callback)', () => { | ||
| const area = makeFakeArea('click_makamujo_nicovideo', 'https://live.nicovideo.jp/watch/user/14171889') | ||
| const { navigate } = makeSetup([area]) | ||
| area.triggerClick() | ||
| expect(navigate).not.toHaveBeenCalled() | ||
| }) | ||
|
|
||
| it('navigates to the correct URL when event_callback fires', () => { | ||
| const href = 'https://live.nicovideo.jp/watch/user/14171889' | ||
| const area = makeFakeArea('click_makamujo_nicovideo', href) | ||
| let capturedCallback: (() => void) | undefined | ||
| const gtag = vi.fn().mockImplementation((_cmd, _name, params: Record<string, unknown>) => { | ||
| capturedCallback = params?.event_callback as () => void | ||
| }) | ||
| const { navigate } = makeSetup([area], { gtag }) | ||
| area.triggerClick() | ||
| capturedCallback?.() | ||
| expect(navigate).toHaveBeenCalledWith(href) | ||
| }) | ||
| }) | ||
|
|
||
| describe('navigation via timeout fallback', () => { | ||
| it('navigates after maxDelayMs when event_callback never fires', () => { | ||
| const href = 'https://www.nahcnuj.work/makamujo/index.html' | ||
| const area = makeFakeArea('click_makamujo_landing', href) | ||
| const gtag = vi.fn() // never calls event_callback | ||
| const { navigate } = makeSetup([area], { gtag, maxDelayMs: 300 }) | ||
| area.triggerClick() | ||
| expect(navigate).not.toHaveBeenCalled() | ||
| vi.advanceTimersByTime(300) | ||
| expect(navigate).toHaveBeenCalledWith(href) | ||
| }) | ||
|
|
||
| it('does not navigate before maxDelayMs elapses', () => { | ||
| const area = makeFakeArea('click_makamujo_landing', 'https://www.nahcnuj.work/makamujo/index.html') | ||
| const gtag = vi.fn() | ||
| const { navigate } = makeSetup([area], { gtag, maxDelayMs: 300 }) | ||
| area.triggerClick() | ||
| vi.advanceTimersByTime(299) | ||
| expect(navigate).not.toHaveBeenCalled() | ||
| }) | ||
| }) | ||
|
|
||
| describe('no double navigation', () => { | ||
| it('navigates exactly once even when both event_callback and timeout fire', () => { | ||
| const href = 'https://live.nicovideo.jp/watch/user/14171889' | ||
| const area = makeFakeArea('click_makamujo_nicovideo', href) | ||
| let capturedCallback: (() => void) | undefined | ||
| const gtag = vi.fn().mockImplementation((_cmd, _name, params: Record<string, unknown>) => { | ||
| capturedCallback = params?.event_callback as () => void | ||
| }) | ||
| const { navigate } = makeSetup([area], { gtag, maxDelayMs: 300 }) | ||
| area.triggerClick() | ||
| capturedCallback?.() // fires callback first | ||
| vi.advanceTimersByTime(300) // then timeout fires | ||
| expect(navigate).toHaveBeenCalledTimes(1) | ||
| }) | ||
|
|
||
| it('navigates exactly once when timeout fires before event_callback', () => { | ||
| const href = 'https://live.nicovideo.jp/watch/user/14171889' | ||
| const area = makeFakeArea('click_makamujo_nicovideo', href) | ||
| let capturedCallback: (() => void) | undefined | ||
| const gtag = vi.fn().mockImplementation((_cmd, _name, params: Record<string, unknown>) => { | ||
| capturedCallback = params?.event_callback as () => void | ||
| }) | ||
| const { navigate } = makeSetup([area], { gtag, maxDelayMs: 300 }) | ||
| area.triggerClick() | ||
| vi.advanceTimersByTime(300) // timeout fires first | ||
| capturedCallback?.() // then callback fires | ||
| expect(navigate).toHaveBeenCalledTimes(1) | ||
| }) | ||
| }) | ||
|
|
||
| describe('default prevention', () => { | ||
| it('calls preventDefault on the click event to stop native area navigation', () => { | ||
| const area = makeFakeArea('click_makamujo_nicovideo', 'https://live.nicovideo.jp/watch/user/14171889') | ||
| makeSetup([area]) | ||
| const { preventDefault } = area.triggerClick() | ||
| expect(preventDefault).toHaveBeenCalled() | ||
| }) | ||
| }) | ||
|
|
||
| describe('edge cases', () => { | ||
| it('skips areas without a data-gtag-event attribute', () => { | ||
| const area = makeFakeArea(undefined, 'https://example.com') | ||
| const { gtag, navigate } = makeSetup([area]) | ||
| area.triggerClick() | ||
| vi.advanceTimersByTime(500) | ||
| expect(gtag).not.toHaveBeenCalled() | ||
| expect(navigate).not.toHaveBeenCalled() | ||
| }) | ||
|
|
||
| it('does not attach listeners before whenReady fires', () => { | ||
| let readyFn: (() => void) | undefined | ||
| const area = makeFakeArea('click_makamujo_nicovideo', 'https://live.nicovideo.jp/watch/user/14171889') | ||
| const { gtag } = makeSetup([area], { | ||
| whenReady: (fn) => { | ||
| readyFn = fn | ||
| }, | ||
| }) | ||
| area.triggerClick() | ||
| expect(gtag).not.toHaveBeenCalled() | ||
| readyFn?.() | ||
| area.triggerClick() | ||
| expect(gtag).toHaveBeenCalledOnce() | ||
| }) | ||
|
|
||
| it('handles multiple areas independently', () => { | ||
| const area1 = makeFakeArea('click_makamujo_nicovideo', 'https://live.nicovideo.jp/watch/user/14171889') | ||
| const area2 = makeFakeArea('click_makamujo_landing', 'https://www.nahcnuj.work/makamujo/index.html') | ||
| const gtag = vi.fn() | ||
| makeSetup([area1, area2], { gtag }) | ||
| area1.triggerClick() | ||
| area2.triggerClick() | ||
| expect(gtag).toHaveBeenCalledTimes(2) | ||
| expect(gtag).toHaveBeenNthCalledWith(1, 'event', 'click_makamujo_nicovideo', expect.any(Object)) | ||
| expect(gtag).toHaveBeenNthCalledWith(2, 'event', 'click_makamujo_landing', expect.any(Object)) | ||
| }) | ||
| }) | ||
| }) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,60 @@ | ||
| import type { GtagFn } from './scrollDepthTracker' | ||
|
|
||
| export interface MakamujoBannerTrackerOptions { | ||
| gtagFn: GtagFn | ||
| /** Calls `fn` once the DOM is ready to be queried (equivalent of DOMContentLoaded). */ | ||
| whenReady: (fn: () => void) => void | ||
| /** Returns all `<area>` elements that carry a `data-gtag-event` attribute. */ | ||
| getAreaElements: () => HTMLAreaElement[] | ||
| /** Opens the given URL (e.g. `window.open(href, '_blank', 'noopener,noreferrer')`). */ | ||
| navigate: (href: string) => void | ||
| /** Maximum milliseconds to wait for the GA beacon before navigating anyway. */ | ||
| maxDelayMs?: number | ||
| } | ||
|
|
||
| /** | ||
| * Intercepts clicks on Makamujo banner image-map `<area>` elements, fires a | ||
| * GA4 event named by their `data-gtag-event` attribute, and then opens the | ||
| * link. Navigation is delayed until the event is confirmed sent, or until | ||
| * `maxDelayMs` elapses — whichever comes first. | ||
| */ | ||
| export function setupMakamujoBannerTracking({ | ||
| gtagFn, | ||
| whenReady, | ||
| getAreaElements, | ||
| navigate, | ||
| maxDelayMs = 500, | ||
| }: MakamujoBannerTrackerOptions): void { | ||
| whenReady(() => { | ||
| for (const area of getAreaElements()) { | ||
| const eventName = area.dataset.gtagEvent | ||
| if (!eventName) continue | ||
|
|
||
| area.addEventListener('click', (e) => { | ||
| e.preventDefault() | ||
| const href = area.href | ||
| if (!href) return | ||
|
|
||
| let navigated = false | ||
| const doNavigate = () => { | ||
| if (navigated) return | ||
| navigated = true | ||
| navigate(href) | ||
| } | ||
|
|
||
| // Fallback: navigate after maxDelayMs even if the GA beacon never fires. | ||
| const timeout = setTimeout(doNavigate, maxDelayMs) | ||
|
|
||
| gtagFn('event', eventName, { | ||
| // Called by gtag once the event has been dispatched. | ||
| event_callback: () => { | ||
| clearTimeout(timeout) | ||
| doNavigate() | ||
| }, | ||
| // Tells gtag to call event_callback after at most maxDelayMs ms. | ||
| event_timeout: maxDelayMs, | ||
| }) | ||
| }) | ||
| } | ||
| }) | ||
| } |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.