Skip to content
Closed
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
3 changes: 3 additions & 0 deletions .jules/palette.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
## 2024-08-14 - 드롭 존 클릭 영역 활성화 시의 상호작용 제어 학습
**Learning:** 파일 업로드 폼을 감싸는 전체 드롭 존(box)을 클릭 가능하도록 확장할 때, 폼 내부의 INPUT, BUTTON, LABEL 등 원래의 상호작용 가능한 하위 요소에 대한 클릭까지 모두 부모 래퍼의 이벤트로 처리하면 의도치 않은 중복 동작이나 UI 오작동이 발생함을 확인.
**Action:** 상호작용하는 드롭 존 이벤트를 추가할 시, `!e.target.closest('input, button, label')` 같은 조상 기반 예외 처리를 사용하여 LABEL 내부의 SPAN 등 중첩 요소까지 원래 폼 상호작용으로 취급하고, 부모 드롭 존이 파일 선택을 중복 실행하지 않도록 한다.
## 2024-07-15 - Dynamic Size formatting and Total Size Validation
**Learning:** Hardcoding human-readable sizes (like '5 GiB') in validation error messages is error-prone when the underlying constant changes. Moreover, failing to validate total upload size against backend limits (e.g., MAX_UPLOAD_BYTES) in batch file uploads frustrates users who wait for a large upload to finish only to get a server-side 413 Payload Too Large error.
**Action:** Always format backend byte limit constants dynamically (e.g., `formatBinaryBytes(MAX_UPLOAD_BYTES)`) on the client side to display accurate error messages. For multiple file inputs, ensure both the file count and the combined file size are validated against backend limits, giving immediate inline feedback via `setCustomValidity` and `aria-invalid`.
Expand Down
28 changes: 21 additions & 7 deletions saas_web.py
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,7 @@ async def add_security_headers(request: Request, call_next):
<title>Codec Carver SaaS</title>
<style>
body { font-family: sans-serif; max-width: 600px; margin: 40px auto; padding: 20px; }
.box { border: 1px solid #ccc; padding: 20px; border-radius: 8px; }
.box { border: 1px solid #ccc; padding: 20px; border-radius: 8px; cursor: pointer; }
button { padding: 10px 20px; background-color: #0056b3; color: white; border: none; border-radius: 4px; cursor: pointer; }
button:hover:not(:disabled) { background-color: #004085; }
button:disabled { background-color: #6c757d; cursor: not-allowed; }
Expand Down Expand Up @@ -192,6 +192,7 @@ async def add_security_headers(request: Request, call_next):
</form>
<script>
const MAX_UPLOAD_BYTES = 5 * 1024 * 1024 * 1024;
window.addEventListener('DOMContentLoaded', () => {
function formatBinaryBytes(value) {
const units = ['B', 'KiB', 'MiB', 'GiB'];
let size = value;
Expand All @@ -218,7 +219,7 @@ async def add_security_headers(request: Request, call_next):
}
});

function updateFileSizePreview(input) {
window.updateFileSizePreview = function(input) {
const file = input.files[0];
const preview = document.getElementById('file_size_preview');
input.setCustomValidity('');
Expand All @@ -238,7 +239,7 @@ async def add_security_headers(request: Request, call_next):
return;
}
preview.innerText = 'Selected file size: ' + text;
}
};

document.getElementById('target_bytes').addEventListener('input', function(e) {
const val = parseInt(this.value, 10);
Expand Down Expand Up @@ -316,7 +317,7 @@ async def add_security_headers(request: Request, call_next):
}, 10);
});

function updateBatchFilePreview(input) {
window.updateBatchFilePreview = function(input) {
const preview = document.getElementById('batch_files_preview');
input.setCustomValidity('');
input.removeAttribute('aria-invalid');
Expand Down Expand Up @@ -350,7 +351,7 @@ async def add_security_headers(request: Request, call_next):
return;
}
preview.innerText = 'Selected ' + files.length + ' file(s) (' + formatBinaryBytes(totalSize) + ')';
}
};

document.getElementById('shrink-batch-form').addEventListener('submit', function() {
const btn = document.getElementById('batch-submit-btn');
Expand Down Expand Up @@ -390,7 +391,7 @@ async def add_security_headers(request: Request, call_next):
let files = dt.files;
if (files.length) {
fileInput.files = files;
updateFileSizePreview(fileInput);
window.updateFileSizePreview(fileInput);
}
}, false);
if (batchDropZone) {
Expand All @@ -399,10 +400,23 @@ async def add_security_headers(request: Request, call_next):
let files = dt.files;
if (files.length) {
batchFileInput.files = files;
updateBatchFilePreview(batchFileInput);
window.updateBatchFilePreview(batchFileInput);
}
}, false);
}
dropZone.addEventListener('click', (e) => {
if (!e.target.closest('input, button, label')) {
fileInput.click();
}
});
if (batchDropZone) {
batchDropZone.addEventListener('click', (e) => {
if (!e.target.closest('input, button, label')) {
batchFileInput.click();
}
});
}
});
</script>
</div>
<div class="box" id="batch-drop-zone" style="margin-top: 20px;">
Expand Down
238 changes: 238 additions & 0 deletions tests/test_saas_ui_contract.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,238 @@
"""Executable contracts for the embedded SaaS browser UI."""

import json
import re
import shutil
import subprocess
import textwrap
import unittest
from pathlib import Path

try:
import saas_web
_HAS_FASTAPI = True
except ImportError:
_HAS_FASTAPI = False


SOURCE_TEXT = (Path(__file__).resolve().parents[1] / "saas_web.py").read_text(
encoding="utf-8"
)


@unittest.skipUnless(_HAS_FASTAPI, "fastapi not installed")
class TestSaasUiContract(unittest.TestCase):
"""Keep DOM registration and nested-control handling safe."""

def test_drop_zone_registration_waits_for_complete_dom(self):
"""Batch controls must exist before any listener looks them up."""
self.assertIn("window.addEventListener('DOMContentLoaded'", SOURCE_TEXT)
self.assertLess(
SOURCE_TEXT.index("window.addEventListener('DOMContentLoaded'"),
SOURCE_TEXT.index(
"document.getElementById('batch_preset_buttons_container')"
),
)
self.assertIn("window.updateFileSizePreview = function(input)", SOURCE_TEXT)

def test_nested_interactive_elements_do_not_open_file_picker_twice(self):
"""A child span inside a label remains part of that interactive label."""
self.assertEqual(
SOURCE_TEXT.count("e.target.closest('input, button, label')"), 2
)

def test_drop_zone_click_behavior_executes_against_dom_harness(self):
"""Execute the generated script and prove both picker click boundaries."""
node = shutil.which("node")
self.assertIsNotNone(
node,
"Node.js is required for the embedded-browser UI contract test",
)

scripts = re.findall(
r"<script>(.*?)</script>",
saas_web.HTML_TEMPLATE,
flags=re.DOTALL,
)
self.assertEqual(len(scripts), 1)
script_literal = json.dumps(scripts[0])

harness = textwrap.dedent(
f"""
const vm = require('node:vm');

class FakeElement {{
constructor(tagName = 'DIV', parentElement = null) {{
this.tagName = tagName;
this.parentElement = parentElement;
this.listeners = new Map();
this.clickCount = 0;
this.dataset = {{}};
this.style = {{}};
this.files = [];
this.value = '';
this.classList = {{
contains: () => false,
add: () => {{}},
remove: () => {{}},
}};
}}

addEventListener(type, callback) {{
if (!this.listeners.has(type)) this.listeners.set(type, []);
this.listeners.get(type).push(callback);
}}

dispatch(type, target = this) {{
for (const callback of this.listeners.get(type) || []) {{
callback({{
target,
dataTransfer: {{ files: [] }},
preventDefault() {{}},
stopPropagation() {{}},
isTrusted: true,
}});
}}
}}

closest(selector) {{
const accepted = new Set(
selector.split(',').map((part) => part.trim().toUpperCase())
);
let current = this;
while (current) {{
if (accepted.has(current.tagName)) return current;
current = current.parentElement;
}}
return null;
}}

click() {{
this.clickCount += 1;
}}

setCustomValidity() {{}}
removeAttribute() {{}}
setAttribute() {{}}
}}

const ids = [
'preset_buttons_container',
'batch_preset_buttons_container',
'target_bytes',
'batch_target_bytes',
'target_bytes_preview',
'batch_target_bytes_preview',
'shrink-form',
'submit-btn',
'batch_files_preview',
'shrink-batch-form',
'batch-submit-btn',
'drop-zone',
'batch-drop-zone',
'file',
'batch_files',
'file_size_preview',
];

const inputIds = new Set([
'target_bytes',
'batch_target_bytes',
'file',
'batch_files',
]);
const buttonIds = new Set(['submit-btn', 'batch-submit-btn']);
const elements = Object.fromEntries(
ids.map((id) => [
id,
new FakeElement(
inputIds.has(id) ? 'INPUT' : buttonIds.has(id) ? 'BUTTON' : 'DIV'
),
])
);

const lateIds = new Set([
'batch_preset_buttons_container',
'batch_target_bytes',
'batch_target_bytes_preview',
'batch_files_preview',
'shrink-batch-form',
'batch-submit-btn',
'batch-drop-zone',
'batch_files',
]);
let parserComplete = false;

const document = {{
body: new FakeElement('BODY'),
getElementById(id) {{
if (lateIds.has(id) && !parserComplete) return null;
if (!(id in elements)) throw new Error(`missing fake element: ${{id}}`);
return elements[id];
}},
querySelectorAll() {{
return [];
}},
}};

const windowListeners = new Map();
const window = {{
addEventListener(type, callback) {{
windowListeners.set(type, callback);
}},
}};

global.document = document;
global.window = window;
global.Event = class Event {{
constructor(type, init = {{}}) {{
this.type = type;
Object.assign(this, init);
}}
}};

vm.runInThisContext({script_literal});
const ready = windowListeners.get('DOMContentLoaded');
if (!ready) throw new Error('DOMContentLoaded registration missing');

parserComplete = true;
ready();

const empty = new FakeElement('DIV');
const input = new FakeElement('INPUT');
const button = new FakeElement('BUTTON');
const label = new FakeElement('LABEL');
const nestedInLabel = new FakeElement('SPAN', label);

const dropZone = elements['drop-zone'];
const batchDropZone = elements['batch-drop-zone'];

dropZone.dispatch('click', empty);
batchDropZone.dispatch('click', empty);
for (const target of [input, button, label, nestedInLabel]) {{
dropZone.dispatch('click', target);
batchDropZone.dispatch('click', target);
}}

process.stdout.write(JSON.stringify({{
fileClicks: elements.file.clickCount,
batchClicks: elements.batch_files.clickCount,
}}));
"""
)

completed = subprocess.run(
[node, "-e", harness],
check=True,
capture_output=True,
text=True,
timeout=10,
)
self.assertEqual(
json.loads(completed.stdout),
{"fileClicks": 1, "batchClicks": 1},
)


if __name__ == "__main__":
unittest.main()
10 changes: 9 additions & 1 deletion tests/test_saas_web.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,14 @@ def test_get_ui_includes_accessible_file_input_helpers(self):
self.assertIn('id="file_help"', html)
self.assertIn('class="required-star" aria-hidden="true"', html)

self.assertEqual(html.count("e.target.closest('input, button, label')"), 2)
self.assertIn("fileInput.click();", html)
self.assertIn("window.updateFileSizePreview = function(input)", html)
self.assertLess(
html.index("window.addEventListener('DOMContentLoaded'"),
html.index("document.getElementById('batch_preset_buttons_container')"),
)

def test_get_ui_includes_binary_file_size_validation(self):
response = client.get("/")
self.assertEqual(response.status_code, 200)
Expand Down Expand Up @@ -669,7 +677,7 @@ def test_get_ui_includes_batch_upload_form(self):
self.assertIn('aria-describedby="batch_files_help batch_files_preview"', html)
self.assertIn('onchange="updateBatchFilePreview(this)"', html)
self.assertIn('id="batch_files_preview"', html)
self.assertIn("function updateBatchFilePreview(input)", html)
self.assertIn("window.updateBatchFilePreview = function(input)", html)


@unittest.skipUnless(
Expand Down
Loading