Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions server.js
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,19 @@ app.post('/set-tool', (req, res) => {
res.json({ ok: true, tool: store.currentTool });
});

// Used by navigation/06-http-redirect: issues a 302 redirect to the target URL.
app.get('/api/redirect', (req, res) => {
const url = req.query.url || '/';
res.redirect(302, url);
});

// Used by frames/03-iframe-src: returns a minimal HTML page containing a link to
// the given URL, so the iframe has a same-origin src rather than inline srcdoc.
app.get('/api/page', (req, res) => {
const url = req.query.url || '';
res.send(`<!doctype html><html lang="en"><body><a href="${url}">Click me</a></body></html>`);
});

// Used by the fetch-injected dynamic-content test: echoes back a known score URL
// so the link is only discoverable after a network round-trip.
app.get('/api/reveal', (req, res) => {
Expand Down
20 changes: 20 additions & 0 deletions tests/dynamic-content/08-template-element.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
module.exports = {
name: 'Score link inside <template> element',
description: 'The score link lives inside a <template> element, which is inert — its subtree is not rendered and links inside are not reachable by static HTML parsing. A button clones the template into the live DOM to reveal the link.',
render: ({ scoreUrl }) => `
<template id="link-tpl">
<a href="${scoreUrl}">Click me</a>
</template>
<button id="use-tpl" type="button">Load content</button>
<div id="slot"></div>
<script>
(function() {
document.getElementById('use-tpl').addEventListener('click', function() {
var tpl = document.getElementById('link-tpl');
document.getElementById('slot').appendChild(tpl.content.cloneNode(true));
document.getElementById('use-tpl').hidden = true;
});
})();
</script>
`,
};
42 changes: 42 additions & 0 deletions tests/dynamic-content/09-infinite-scroll.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
module.exports = {
name: 'Infinite scroll — link in auto-loaded batch',
description: 'A scroll event listener fires when the user reaches the bottom of the page and automatically appends the next batch of items, which contains the score link. No button click is needed — the crawler must scroll to the bottom of the page.',
render: ({ scoreUrl }) => {
const items = Array.from({ length: 15 }, (_, i) => `<li style="padding:8px;border-bottom:1px solid #eee">Item ${i + 1}</li>`).join('\n ');
return `
<ul id="items" style="list-style:none;margin:0;padding:0">
${items}
</ul>
<div id="scroll-slot"></div>
<script>
(function() {
var scoreUrl = ${JSON.stringify(scoreUrl)};
var loaded = false;
function checkScroll() {
if (loaded) return;
if (window.innerHeight + window.scrollY >= document.body.scrollHeight - 100) {
loaded = true;
window.removeEventListener('scroll', checkScroll);
var list = document.getElementById('items');
for (var i = 16; i <= 20; i++) {
var li = document.createElement('li');
li.style.cssText = 'padding:8px;border-bottom:1px solid #eee';
li.textContent = 'Item ' + i;
list.appendChild(li);
}
var li = document.createElement('li');
li.style.cssText = 'padding:8px';
var a = document.createElement('a');
a.href = scoreUrl;
a.textContent = 'Item 21 — click me';
li.appendChild(a);
list.appendChild(li);
}
}
window.addEventListener('scroll', checkScroll);
checkScroll();
})();
</script>
`;
},
};
30 changes: 30 additions & 0 deletions tests/dynamic-content/10-localstorage.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
module.exports = {
name: 'localStorage token gates score link',
description: 'A button sets a flag in localStorage, then re-renders the page content without a navigation. The score link is only shown once the flag is present — either after clicking the button in the same session, or on a return visit if the crawler shares localStorage state across page loads.',
render: ({ scoreUrl }) => `
<div id="content"></div>
<script>
(function() {
var KEY = 'crawlground_ls_visited';
var scoreUrl = ${JSON.stringify(scoreUrl)};
function render() {
var div = document.getElementById('content');
if (localStorage.getItem(KEY) === '1') {
var a = document.createElement('a');
a.href = scoreUrl;
a.textContent = 'Click me';
div.innerHTML = '';
div.appendChild(a);
} else {
div.innerHTML = '<button id="ls-btn" type="button">Set flag</button>';
document.getElementById('ls-btn').addEventListener('click', function() {
localStorage.setItem(KEY, '1');
render();
});
}
}
render();
})();
</script>
`,
};
17 changes: 17 additions & 0 deletions tests/forms/11-fetch-get.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
module.exports = {
name: 'AJAX fetch() GET — no <form> element',
description: 'A button whose click handler uses fetch() to make a GET request to the marker URL. Crawlers must execute JS and follow fetch() GET requests, not only POST.',
render: ({ scoreUrl }) => `
<button id="ajax-btn" type="button">Fetch via GET</button>
<div id="status" style="margin-top:8px"></div>
<script>
(function() {
document.getElementById('ajax-btn').addEventListener('click', function() {
fetch(${JSON.stringify(scoreUrl)}).then(function() {
document.getElementById('status').textContent = 'Done!';
});
});
})();
</script>
`,
};
21 changes: 21 additions & 0 deletions tests/forms/12-xhr-post.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
module.exports = {
name: 'XMLHttpRequest POST',
description: 'A button that submits via XMLHttpRequest — the older AJAX API still common in legacy code. Crawlers must handle XHR requests, not only the newer fetch() API.',
render: ({ scoreUrl }) => `
<button id="xhr-btn" type="button">Submit via XHR</button>
<div id="status" style="margin-top:8px"></div>
<script>
(function() {
document.getElementById('xhr-btn').addEventListener('click', function() {
var xhr = new XMLHttpRequest();
xhr.open('POST', ${JSON.stringify(scoreUrl)});
xhr.setRequestHeader('Content-Type', 'application/json');
xhr.onload = function() {
document.getElementById('status').textContent = 'Done!';
};
xhr.send(JSON.stringify({ source: 'xhr' }));
});
})();
</script>
`,
};
8 changes: 8 additions & 0 deletions tests/frames/03-iframe-src.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
module.exports = {
name: 'Link inside same-origin iframe (src URL)',
description: 'The score link is inside an <iframe> loaded from a URL — not inline srcdoc. The browser makes a separate HTTP request for the iframe content, requiring crawlers to discover and request iframe src URLs.',
render: ({ scoreUrl }) => {
const iframeSrc = `/api/page?url=${encodeURIComponent(scoreUrl)}`;
return `<iframe src="${iframeSrc}" style="width:300px;height:60px;border:1px solid #ccc"></iframe>`;
},
};
16 changes: 16 additions & 0 deletions tests/frames/04-shadow-dom-closed.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
module.exports = {
name: 'Link inside closed Shadow DOM',
description: 'The score link is appended to a shadow root created with mode:"closed". Unlike open shadow DOM, the root is not accessible via element.shadowRoot — crawlers relying on JS reflection to enumerate shadow roots will not find it.',
render: ({ scoreUrl }) => `
<div id="shadow-host"></div>
<script>
(function() {
var root = document.getElementById('shadow-host').attachShadow({ mode: 'closed' });
var a = document.createElement('a');
a.href = ${JSON.stringify(scoreUrl)};
a.textContent = 'Click me';
root.appendChild(a);
})();
</script>
`,
};
17 changes: 17 additions & 0 deletions tests/js-events/12-pointer-events.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
module.exports = {
name: 'pointerup event triggers navigation',
description: 'A div styled as a button fires navigation on pointerup, not click. Crawlers that only synthesise click events will miss this — pointer events (pointerdown/pointerup) must also be dispatched.',
render: ({ scoreUrl }) => `
<div id="ptr-btn" role="button" tabindex="0"
style="display:inline-block;padding:8px 16px;background:#3a7bd5;color:#fff;border-radius:4px;cursor:pointer">
Press me
</div>
<script>
(function() {
document.getElementById('ptr-btn').addEventListener('pointerup', function() {
window.location.href = ${JSON.stringify(scoreUrl)};
});
})();
</script>
`,
};
24 changes: 24 additions & 0 deletions tests/js-events/13-long-press.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
module.exports = {
name: 'Long press (mousedown hold) triggers navigation',
description: 'Navigation fires only after holding mousedown for 600 ms without releasing. A standard quick click does nothing — crawlers must simulate a sustained press rather than an instant click.',
render: ({ scoreUrl }) => `
<div id="hold-btn" role="button" tabindex="0"
style="display:inline-block;padding:8px 16px;background:#3a7bd5;color:#fff;border-radius:4px;cursor:pointer;user-select:none">
Hold me
</div>
<div id="hint" style="margin-top:8px;color:#666;font-size:13px">Hold for 600 ms to navigate</div>
<script>
(function() {
var btn = document.getElementById('hold-btn');
var timer = null;
btn.addEventListener('mousedown', function() {
timer = setTimeout(function() {
window.location.href = ${JSON.stringify(scoreUrl)};
}, 600);
});
btn.addEventListener('mouseup', function() { clearTimeout(timer); });
btn.addEventListener('mouseleave', function() { clearTimeout(timer); });
})();
</script>
`,
};
18 changes: 18 additions & 0 deletions tests/js-events/14-custom-event.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
module.exports = {
name: 'CustomEvent dispatched by button click',
description: 'Clicking the button dispatches a custom DOM event ("navigate"). A separate listener on the document handles that event and navigates to the score URL. Crawlers must follow chains of DOM events, not only standard click handlers.',
render: ({ scoreUrl }) => `
<button id="dispatch-btn" type="button">Click me</button>
<script>
(function() {
var scoreUrl = ${JSON.stringify(scoreUrl)};
document.addEventListener('navigate', function(e) {
if (e.detail && e.detail.url) window.location.href = e.detail.url;
});
document.getElementById('dispatch-btn').addEventListener('click', function() {
document.dispatchEvent(new CustomEvent('navigate', { detail: { url: scoreUrl } }));
});
})();
</script>
`,
};
7 changes: 7 additions & 0 deletions tests/links/05-target-blank.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
module.exports = {
name: 'Anchor with target="_blank"',
description: 'A link with target="_blank" that opens the marker URL in a new tab. Crawlers must follow the href even though it targets a separate browsing context.',
render: ({ scoreUrl }) => `
<a href="${scoreUrl}" target="_blank" rel="noopener">Open in new tab</a>
`,
};
14 changes: 14 additions & 0 deletions tests/links/06-javascript-href.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
module.exports = {
name: 'Anchor with javascript: href and onclick',
description: 'An anchor whose href is "javascript:void(0)" — a common pattern to suppress default navigation. The real navigation happens in the onclick handler via window.location.',
render: ({ scoreUrl }) => `
<a href="javascript:void(0)" id="js-link">Click me</a>
<script>
(function() {
document.getElementById('js-link').addEventListener('click', function() {
window.location.href = ${JSON.stringify(scoreUrl)};
});
})();
</script>
`,
};
14 changes: 14 additions & 0 deletions tests/links/07-image-map.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
module.exports = {
name: 'Image map with clickable <area>',
description: 'An <img> element with a <map> and <area href="..."> pointing to the marker URL. Crawlers must parse <map> elements and treat <area> like a link.',
render: ({ scoreUrl }) => {
const svg = `<svg xmlns='http://www.w3.org/2000/svg' width='200' height='60'><rect width='200' height='60' fill='%233a7bd5' rx='4'/><text x='100' y='38' text-anchor='middle' fill='white' font-family='sans-serif' font-size='14'>Click the image</text></svg>`;
const imgSrc = `data:image/svg+xml,${svg}`;
return `
<img src="${imgSrc}" usemap="#nav-map" width="200" height="60" alt="Click the highlighted region">
<map name="nav-map">
<area shape="rect" coords="0,0,200,60" href="${scoreUrl}" alt="Score link">
</map>
`;
},
};
8 changes: 8 additions & 0 deletions tests/navigation/06-http-redirect.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
module.exports = {
name: 'HTTP 302 redirect to marker URL',
description: 'A link that points to a server endpoint which immediately issues an HTTP 302 redirect to the marker URL. Crawlers must follow server-side redirects to score.',
render: ({ scoreUrl }) => {
const redirectUrl = `/api/redirect?url=${encodeURIComponent(scoreUrl)}`;
return `<a href="${redirectUrl}">Click me</a>`;
},
};
12 changes: 12 additions & 0 deletions tests/navigation/07-base-href.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
module.exports = {
name: '<base href> changes relative URL resolution',
description: 'The <head> contains a <base href> element that relocates the base for all relative URLs on the page. The score link uses a relative path that only resolves correctly when the base is applied — crawlers that ignore <base> will compute the wrong URL.',
head: ({ scoreUrl }) => {
const base = scoreUrl.substring(0, scoreUrl.lastIndexOf('/') + 1);
return `<base href="${base}">`;
},
render: ({ scoreUrl }) => {
const rel = scoreUrl.substring(scoreUrl.lastIndexOf('/') + 1);
return `<a href="${rel}">Click me</a>`;
},
};
14 changes: 14 additions & 0 deletions tests/navigation/08-window-open.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
module.exports = {
name: 'window.open() navigation',
description: 'A button calls window.open() with the marker URL. Crawlers must detect window.open() calls and visit the target URL to score.',
render: ({ scoreUrl }) => `
<button id="open-btn" type="button">Open popup</button>
<script>
(function() {
document.getElementById('open-btn').addEventListener('click', function() {
window.open(${JSON.stringify(scoreUrl)}, '_blank', 'width=400,height=300');
});
})();
</script>
`,
};
20 changes: 20 additions & 0 deletions tests/navigation/09-history-replace-state.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
module.exports = {
name: 'history.replaceState() SPA navigation',
description: 'Clicking "Go to page" calls history.replaceState() and swaps the visible content. Unlike pushState, replaceState modifies the current history entry without adding a new one — crawlers must still detect the DOM swap and discover the injected link.',
render: ({ scoreUrl }) => `
<div id="app">
<p>This is the home view.</p>
<button id="nav-btn" type="button">Go to page</button>
</div>
<script>
(function() {
var scoreUrl = ${JSON.stringify(scoreUrl)};
document.getElementById('nav-btn').addEventListener('click', function() {
history.replaceState({ page: 'detail' }, '', '/spa/detail-replaced');
document.getElementById('app').innerHTML =
'<p>This is the detail view.</p><a href="' + scoreUrl + '">Click me</a>';
});
})();
</script>
`,
};