375 lines
18 KiB
Python
375 lines
18 KiB
Python
"""Focused regression coverage for workspace sorting and birthtime metadata."""
|
|
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import subprocess
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from api import workspace as workspace_api
|
|
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
UI_JS = (ROOT / "static" / "ui.js").read_text(encoding="utf-8")
|
|
I18N_JS = (ROOT / "static" / "i18n.js").read_text(encoding="utf-8")
|
|
|
|
|
|
def test_list_dir_emits_birthtime_ns(tmp_path):
|
|
(tmp_path / "file.txt").write_text("x", encoding="utf-8")
|
|
(tmp_path / "folder").mkdir()
|
|
entries = workspace_api.list_dir(tmp_path, ".")
|
|
assert {entry["type"] for entry in entries} == {"file", "dir"}
|
|
assert all("birthtime_ns" in entry for entry in entries)
|
|
assert all(isinstance(entry["birthtime_ns"], (int, type(None))) for entry in entries)
|
|
assert {entry["workspace_sort_rank"] for entry in entries} == {1, 2}
|
|
|
|
|
|
def test_list_dir_emits_server_partition_rank_for_special_entries(tmp_path, monkeypatch):
|
|
if not hasattr(os, "mkfifo"):
|
|
pytest.skip("FIFO creation is unavailable on this platform")
|
|
try:
|
|
os.mkfifo(tmp_path / "fifo")
|
|
(tmp_path / "directory").mkdir()
|
|
(tmp_path / "file.txt").write_text("x", encoding="utf-8")
|
|
link_target = tmp_path / "file.txt"
|
|
(tmp_path / "link").symlink_to(link_target)
|
|
except (OSError, NotImplementedError) as exc:
|
|
pytest.skip(f"special entry creation unavailable: {exc}")
|
|
|
|
modes = [False]
|
|
if workspace_api._DIR_FD_OK:
|
|
modes.append(True)
|
|
for use_dir_fd in modes:
|
|
monkeypatch.setattr(workspace_api, "_DIR_FD_OK", use_dir_fd)
|
|
entries = {entry["name"]: entry for entry in workspace_api.list_dir(tmp_path, ".")}
|
|
assert entries["link"]["workspace_sort_rank"] == 0
|
|
assert entries["directory"]["workspace_sort_rank"] == 1
|
|
assert entries["fifo"]["workspace_sort_rank"] == 1
|
|
assert entries["file.txt"]["workspace_sort_rank"] == 2
|
|
|
|
|
|
def test_birthtime_ns_platform_matrix(monkeypatch):
|
|
birthtime_ns = getattr(workspace_api, "_birthtime_ns", None)
|
|
assert birthtime_ns is not None, "_birthtime_ns helper is missing"
|
|
class Stat:
|
|
pass
|
|
|
|
modern = Stat()
|
|
modern.st_birthtime_ns = 123
|
|
assert birthtime_ns(modern) == 123
|
|
mac = Stat()
|
|
mac.st_birthtime = 1.5
|
|
assert birthtime_ns(mac) == 1_500_000_000
|
|
windows = Stat()
|
|
windows.st_ctime_ns = 456
|
|
monkeypatch.setattr("api.workspace.sys.platform", "win32")
|
|
assert birthtime_ns(windows) == 456
|
|
linux = Stat()
|
|
monkeypatch.setattr("api.workspace.sys.platform", "linux")
|
|
assert birthtime_ns(linux) is None
|
|
|
|
|
|
def test_escape_symlink_birthtime_is_link_local(tmp_path):
|
|
outside = tmp_path / "outside.txt"
|
|
outside.write_text("outside", encoding="utf-8")
|
|
workspace = tmp_path / "workspace"
|
|
workspace.mkdir()
|
|
link = workspace / "escape.txt"
|
|
try:
|
|
link.symlink_to(outside)
|
|
except (OSError, NotImplementedError) as exc:
|
|
pytest.skip(f"symlink unavailable: {exc}")
|
|
entry = next(item for item in workspace_api.list_dir(workspace, ".") if item["name"] == "escape.txt")
|
|
assert entry["target_outside_workspace"] is True
|
|
assert entry["workspace_sort_rank"] == 0
|
|
birthtime_ns = getattr(workspace_api, "_birthtime_ns", None)
|
|
assert birthtime_ns is not None, "_birthtime_ns helper is missing"
|
|
assert entry["birthtime_ns"] == birthtime_ns(link.lstat())
|
|
assert "target" not in entry and "size" not in entry
|
|
|
|
|
|
def test_dir_signature_unchanged_by_birthtime(tmp_path):
|
|
(tmp_path / "same.txt").write_text("same", encoding="utf-8")
|
|
entries = workspace_api.list_dir(tmp_path, ".")
|
|
expected = hashlib.sha256(
|
|
json.dumps(
|
|
[
|
|
{
|
|
"name": entry.get("name"),
|
|
"path": entry.get("path"),
|
|
"type": entry.get("type"),
|
|
"is_dir": entry.get("is_dir"),
|
|
"size": entry.get("size"),
|
|
"mtime_ns": entry.get("mtime_ns"),
|
|
"target": entry.get("target"),
|
|
"target_outside_workspace": entry.get("target_outside_workspace"),
|
|
}
|
|
for entry in entries
|
|
],
|
|
sort_keys=True,
|
|
separators=(",", ":"),
|
|
ensure_ascii=False,
|
|
).encode("utf-8")
|
|
).hexdigest()
|
|
assert workspace_api.dir_signature(tmp_path, ".", entries) == expected
|
|
|
|
|
|
def _run_sort_harness(body):
|
|
start = UI_JS.index("const WORKSPACE_SORT_KEYS=")
|
|
end = UI_JS.index("// ── Workspace preferences kebab menu", start)
|
|
source = UI_JS[start:end]
|
|
script = """
|
|
global.S={session:{workspace:'/test'},showHiddenWorkspaceFiles:true,workspaceSortKey:'name-asc'};
|
|
global.localStorage={getItem(key){return key==='hermes-workspace-show-hidden-files'?'1':null},setItem(){}};
|
|
global.$=()=>null;
|
|
function _visibleWorkspaceEntries(entries){return S.showHiddenWorkspaceFiles?entries:entries.filter(()=>true)}
|
|
""" + source + "\n" + body
|
|
return subprocess.run(["node", "-e", script], capture_output=True, text=True, check=False)
|
|
|
|
|
|
def test_sort_modified_desc_within_rank():
|
|
result = _run_sort_harness("""
|
|
S.workspaceSortKey='modified-desc';
|
|
const input=[{name:'file-old',type:'file',workspace_sort_rank:2,mtime_ns:1},{name:'dir',type:'dir',workspace_sort_rank:1,mtime_ns:2},{name:'file-new',type:'file',workspace_sort_rank:2,mtime_ns:3},{name:'link',type:'symlink',workspace_sort_rank:0,mtime_ns:0}];
|
|
const out=_workspaceEntriesForRender(input).map(x=>x.name);
|
|
if(JSON.stringify(out)!=='[\"link\",\"dir\",\"file-new\",\"file-old\"]')process.exit(1);
|
|
""")
|
|
assert result.returncode == 0, result.stderr
|
|
|
|
|
|
def test_sort_name_desc_within_rank():
|
|
result = _run_sort_harness("""
|
|
S.workspaceSortKey='name-desc';
|
|
const input=[{name:'a',type:'file',workspace_sort_rank:2},{name:'dir',type:'dir',workspace_sort_rank:1},{name:'b',type:'file',workspace_sort_rank:2},{name:'link',type:'symlink',workspace_sort_rank:0}];
|
|
const out=_workspaceEntriesForRender(input).map(x=>x.name);
|
|
if(JSON.stringify(out)!=='[\"link\",\"dir\",\"b\",\"a\"]')process.exit(1);
|
|
""")
|
|
assert result.returncode == 0, result.stderr
|
|
|
|
|
|
def test_name_asc_is_identity():
|
|
result = _run_sort_harness("""
|
|
const input=[{name:'b',type:'file'},{name:'a',type:'file'}];
|
|
const out=_workspaceEntriesForRender(input);
|
|
if(out!==input||out[0]!==input[0])process.exit(1);
|
|
""")
|
|
assert result.returncode == 0, result.stderr
|
|
|
|
|
|
def test_rank_grouping_holds_all_keys():
|
|
result = _run_sort_harness("""
|
|
const input=[{name:'file',type:'file',workspace_sort_rank:2,mtime_ns:3},{name:'fifo',type:'file',workspace_sort_rank:1,mtime_ns:4},{name:'dir',type:'dir',workspace_sort_rank:1,mtime_ns:2},{name:'link',type:'symlink',workspace_sort_rank:0,mtime_ns:1}];
|
|
for(const key of ['name-desc','created-desc','modified-desc']){S.workspaceSortKey=key;S._workspaceBirthtimeSeen=true;const ranks=_workspaceEntriesForRender(input).map(_workspaceEntryRank);if(JSON.stringify(ranks)!=='[0,1,1,2]')process.exit(1)}
|
|
""")
|
|
assert result.returncode == 0, result.stderr
|
|
|
|
|
|
def test_unknown_workspace_rank_uses_non_regular_partition():
|
|
result = _run_sort_harness("""
|
|
if(_workspaceEntryRank({type:'file'})!==1||_workspaceEntryRank({type:'file',workspace_sort_rank:'2garbage'})!==1||_workspaceEntryRank({type:'file',workspace_sort_rank:2.5})!==1||_workspaceEntryRank({type:'file',workspace_sort_rank:'bogus'})!==1||_workspaceEntryRank({type:'file',workspace_sort_rank:3})!==1)process.exit(1);
|
|
""")
|
|
assert result.returncode == 0, result.stderr
|
|
|
|
|
|
def test_no_workspace_render_clears_birthtime_support():
|
|
start = UI_JS.index("function renderFileTree(){")
|
|
end = UI_JS.index("let _wsActiveDragPath=", start)
|
|
render_source = UI_JS[start:end]
|
|
sort_start = UI_JS.index("const WORKSPACE_SORT_KEYS=")
|
|
sort_end = UI_JS.index("// ── Workspace preferences kebab menu", sort_start)
|
|
sort_source = UI_JS[sort_start:sort_end]
|
|
script = """
|
|
const box={scrollTop:0,innerHTML:'',style:{}};
|
|
const empty={textContent:'',style:{}};
|
|
global.S={session:null,entries:[],currentDir:'.',_dirCache:{},showHiddenWorkspaceFiles:true,workspaceSortKey:'created-desc',_workspaceBirthtimeSeen:true,_workspaceBirthtimeWorkspace:'/old'};
|
|
global.localStorage={getItem(){return null},setItem(){}};
|
|
global.$=id=>id==='fileTree'?box:id==='wsEmptyState'?empty:null;
|
|
global.t=()=>'';
|
|
var _workspacePrefsMenu=null;
|
|
""" + sort_source + render_source + """
|
|
renderFileTree();
|
|
if(S._workspaceBirthtimeSeen!==false||S._workspaceBirthtimeWorkspace!=='')process.exit(1);
|
|
"""
|
|
result = subprocess.run(["node", "-e", script], capture_output=True, text=True, check=False)
|
|
assert result.returncode == 0, result.stderr
|
|
|
|
|
|
def test_session_teardown_disables_created_sort():
|
|
result = _run_sort_harness("""
|
|
S._workspaceBirthtimeSeen=true;
|
|
if(_workspaceCreatedSortAvailable()!==true)process.exit(1);
|
|
S.session=null;
|
|
if(_workspaceCreatedSortAvailable()!==false)process.exit(1);
|
|
""")
|
|
assert result.returncode == 0, result.stderr
|
|
|
|
|
|
def test_missing_timestamps_sort_last():
|
|
result = _run_sort_harness("""
|
|
S.workspaceSortKey='created-desc';S._workspaceBirthtimeSeen=true;
|
|
const input=[{name:'missing',type:'file',workspace_sort_rank:2,birthtime_ns:null},{name:'zero',type:'file',workspace_sort_rank:2,birthtime_ns:0},{name:'new',type:'file',workspace_sort_rank:2,birthtime_ns:2},{name:'undefined',type:'file',workspace_sort_rank:2}];
|
|
const out=_workspaceEntriesForRender(input).map(x=>x.name);
|
|
if(JSON.stringify(out)!=='[\"new\",\"zero\",\"missing\",\"undefined\"]')process.exit(1);
|
|
""")
|
|
assert result.returncode == 0, result.stderr
|
|
|
|
|
|
def test_large_nanosecond_strings_sort_exactly():
|
|
result = _run_sort_harness("""
|
|
S.workspaceSortKey='modified-desc';
|
|
const input=[{name:'older',type:'file',workspace_sort_rank:2,mtime_ns:'1752598800000000000'},{name:'newer',type:'file',workspace_sort_rank:2,mtime_ns:'1752598800000000001'}];
|
|
const out=_workspaceEntriesForRender(input).map(x=>x.name);
|
|
if(JSON.stringify(out)!=='[\"newer\",\"older\"]')process.exit(1);
|
|
""")
|
|
assert result.returncode == 0, result.stderr
|
|
|
|
|
|
def test_signed_nanosecond_values_sort_exactly():
|
|
result = _run_sort_harness("""
|
|
S.workspaceSortKey='modified-desc';
|
|
const input=[{name:'negative-string',type:'file',workspace_sort_rank:2,mtime_ns:'-2'},{name:'zero',type:'file',workspace_sort_rank:2,mtime_ns:0},{name:'negative-number',type:'file',workspace_sort_rank:2,mtime_ns:-1}];
|
|
const out=_workspaceEntriesForRender(input).map(x=>x.name);
|
|
if(JSON.stringify(out)!=='[\"zero\",\"negative-number\",\"negative-string\"]')process.exit(1);
|
|
""")
|
|
assert result.returncode == 0, result.stderr
|
|
|
|
|
|
def test_signed_zero_and_leading_zero_strings_compare_equal():
|
|
result = _run_sort_harness("""
|
|
S.workspaceSortKey='modified-desc';
|
|
const input=[{name:'zero',type:'file',workspace_sort_rank:2,mtime_ns:'0'},{name:'negative-zero',type:'file',workspace_sort_rank:2,mtime_ns:'-0'},{name:'positive-zero',type:'file',workspace_sort_rank:2,mtime_ns:'+0'},{name:'leading-zero',type:'file',workspace_sort_rank:2,mtime_ns:'000'},{name:'negative-leading-zero',type:'file',workspace_sort_rank:2,mtime_ns:'-00'}];
|
|
if(['0','-0','+0','000','-00'].some(value=>_workspaceEntryTimestampKey({mtime_ns:value},'mtime_ns')!=='0'))process.exit(1);
|
|
const out=_workspaceEntriesForRender(input).map(x=>x.name);
|
|
if(JSON.stringify(out)!=='[\"zero\",\"negative-zero\",\"positive-zero\",\"leading-zero\",\"negative-leading-zero\"]')process.exit(1);
|
|
""")
|
|
assert result.returncode == 0, result.stderr
|
|
|
|
|
|
@pytest.mark.parametrize("value", ["", " ", "null", "{\"k\":1}", "created"])
|
|
def test_stored_sort_key_normalization(value):
|
|
result = _run_sort_harness(f"""
|
|
if(!_normalizeWorkspaceSortKey({json.dumps(value)})||!WORKSPACE_SORT_KEYS.includes(_normalizeWorkspaceSortKey({json.dumps(value)})))process.exit(1);
|
|
""")
|
|
assert result.returncode == 0, result.stderr
|
|
|
|
|
|
def test_prefs_indicators_or_logic():
|
|
result = _run_sort_harness("""
|
|
const mk=()=>({hidden:true,attrs:{},setAttribute(k,v){this.attrs[k]=String(v);},removeAttribute(k){delete this.attrs[k];}});
|
|
const ind=mk(),dot=mk();
|
|
S.showHiddenWorkspaceFiles=false;S.workspaceSortKey='name-asc';
|
|
_syncWorkspacePrefsIndicators(ind,dot);
|
|
if(ind.hidden!==true||dot.hidden!==true)process.exit(1);
|
|
S.workspaceSortKey='modified-desc';
|
|
_syncWorkspacePrefsIndicators(ind,dot);
|
|
if(ind.hidden!==true||dot.hidden!==false)process.exit(1);
|
|
S.showHiddenWorkspaceFiles=true;S.workspaceSortKey='name-asc';
|
|
_syncWorkspacePrefsIndicators(ind,dot);
|
|
if(ind.hidden!==false||dot.hidden!==false)process.exit(1);
|
|
""")
|
|
assert result.returncode == 0, result.stderr
|
|
|
|
|
|
def test_created_pref_survives_unavailable():
|
|
result = _run_sort_harness("""
|
|
const mk=()=>({hidden:true,attrs:{},setAttribute(k,v){this.attrs[k]=String(v);},removeAttribute(k){delete this.attrs[k];}});
|
|
const ind=mk(),dot=mk();
|
|
S.showHiddenWorkspaceFiles=false;S._workspaceBirthtimeSeen=false;S.workspaceSortKey='created-desc';
|
|
const input=[{name:'b',type:'file',workspace_sort_rank:2,birthtime_ns:null},{name:'a',type:'file',workspace_sort_rank:2,birthtime_ns:null}];
|
|
_noteWorkspaceBirthtimeSupport(input);
|
|
const out=_workspaceEntriesForRender(input).map(x=>x.name);
|
|
_syncWorkspacePrefsIndicators(ind,dot);
|
|
if(S._workspaceBirthtimeSeen!==false||_workspaceCreatedSortAvailable()!==false||_effectiveWorkspaceSortKey()!=='name-asc'||S.workspaceSortKey!=='created-desc'||JSON.stringify(out)!=='[\"b\",\"a\"]'||ind.hidden!==true||dot.hidden!==true)process.exit(1);
|
|
""")
|
|
assert result.returncode == 0, result.stderr
|
|
|
|
|
|
def test_unavailable_created_pref_uses_effective_menu_radio_state():
|
|
result = _run_sort_harness("""
|
|
const mkRow=value=>{
|
|
const input={value,checked:false,disabled:false};
|
|
return {_input:input,attrs:{},classList:{toggle(){}},setAttribute(k,v){this.attrs[k]=String(v);},querySelector(sel){return sel==='input[name="workspaceSortKey"]'?input:null;}};
|
|
};
|
|
const nameRow=mkRow('name-asc');
|
|
const createdRow=mkRow('created-desc');
|
|
_workspacePrefsMenu={querySelectorAll(sel){return sel==='.workspace-prefs-item--radio'?[nameRow,createdRow]:[];}};
|
|
S._workspaceBirthtimeSeen=false;
|
|
S.workspaceSortKey='created-desc';
|
|
_syncWorkspaceSortMenuState();
|
|
if(nameRow._input.checked!==true||nameRow.attrs['aria-checked']!=='true'||createdRow._input.checked!==false||createdRow._input.disabled!==true||createdRow.attrs['aria-checked']!=='false'||createdRow.attrs['aria-disabled']!=='true')process.exit(1);
|
|
""")
|
|
assert result.returncode == 0, result.stderr
|
|
|
|
|
|
def test_birthtime_support_scope_resets_on_workspace_change():
|
|
result = _run_sort_harness("""
|
|
const input={value:'created-desc',checked:true,disabled:false};
|
|
const row={attrs:{},classList:{toggle(){}},setAttribute(k,v){this.attrs[k]=String(v);},querySelector(sel){return sel==='input[name="workspaceSortKey"]'?input:null;}};
|
|
_workspacePrefsMenu={querySelectorAll(sel){return sel==='.workspace-prefs-item--radio'?[row]:[];}};
|
|
S.session={workspace:'/one'};
|
|
_syncWorkspaceBirthtimeSupportScope(S.session.workspace);
|
|
_noteWorkspaceBirthtimeSupport([{name:'seen',type:'file',workspace_sort_rank:2,birthtime_ns:'1752598800000000001'}]);
|
|
if(_workspaceCreatedSortAvailable()!==true)process.exit(1);
|
|
S.session={workspace:'/two'};
|
|
_syncWorkspaceBirthtimeSupportScope(S.session.workspace);
|
|
if(S._workspaceBirthtimeSeen!==false||_workspaceCreatedSortAvailable()!==false||input.disabled!==true||row.attrs['aria-disabled']!=='true')process.exit(1);
|
|
""")
|
|
assert result.returncode == 0, result.stderr
|
|
|
|
|
|
def test_birthtime_support_scope_reconciles_open_menu_metadata():
|
|
result = _run_sort_harness("""
|
|
global.t=key=>key==='workspace_sort_created_unavailable'?'Creation unavailable':key;
|
|
global.document={createElement(){return {className:'',textContent:'',remove(){this.removed=true;},};}};
|
|
const meta={textContent:'Creation unavailable',remove(){this.removed=true;}};
|
|
const copy={children:[meta],appendChild(node){this.children.push(node);}};
|
|
const input={value:'created-desc',checked:false,disabled:true};
|
|
const row={attrs:{},classList:{toggle(){}},setAttribute(k,v){this.attrs[k]=String(v);},querySelector(sel){
|
|
if(sel==='input[name="workspaceSortKey"]')return input;
|
|
if(sel==='.workspace-prefs-copy')return copy;
|
|
if(sel==='.workspace-prefs-meta')return copy.children.find(node=>!node.removed)||null;
|
|
return null;
|
|
}};
|
|
_workspacePrefsMenu={querySelectorAll(sel){return sel==='.workspace-prefs-item--radio'?[row]:[];}};
|
|
S._workspaceBirthtimeSeen=false;
|
|
_syncWorkspaceSortMenuState();
|
|
if(!row.querySelector('.workspace-prefs-meta')||row.querySelector('.workspace-prefs-meta').textContent!=='Creation unavailable')process.exit(1);
|
|
S._workspaceBirthtimeSeen=true;
|
|
_syncWorkspaceSortMenuState();
|
|
if(row.querySelector('.workspace-prefs-meta'))process.exit(1);
|
|
S._workspaceBirthtimeSeen=false;
|
|
_syncWorkspaceSortMenuState();
|
|
const recreated=row.querySelector('.workspace-prefs-meta');
|
|
if(!recreated||recreated.textContent!=='Creation unavailable')process.exit(1);
|
|
""")
|
|
assert result.returncode == 0, result.stderr
|
|
|
|
|
|
def test_set_workspace_sort_key_updates_open_menuitemradio_state():
|
|
result = _run_sort_harness("""
|
|
global.renderFileTree=()=>{};
|
|
const rows=['name-asc','modified-desc'].map(value=>{
|
|
const input={value,checked:false};
|
|
return {_input:input,attrs:{},setAttribute(k,v){this.attrs[k]=String(v);},querySelector(sel){return sel==='input[name="workspaceSortKey"]'?input:null;}};
|
|
});
|
|
_workspacePrefsMenu={querySelectorAll(sel){return sel==='.workspace-prefs-item--radio'?rows:[];}};
|
|
S.workspaceSortKey='name-asc';
|
|
setWorkspaceSortKey('modified-desc');
|
|
if(rows[0].attrs['aria-checked']!=='false'||rows[1].attrs['aria-checked']!=='true'||rows[0]._input.checked!==false||rows[1]._input.checked!==true)process.exit(1);
|
|
""")
|
|
assert result.returncode == 0, result.stderr
|
|
|
|
|
|
def test_sort_public_surface_text_shape():
|
|
for key in ("WORKSPACE_SORT_KEYS", "setWorkspaceSortKey", "hermes-workspace-sort-key",
|
|
"workspace_sort_by", "workspace_sort_name_asc", "workspace_sort_name_desc",
|
|
"workspace_sort_created_desc", "workspace_sort_modified_desc",
|
|
"workspace_sort_created_unavailable"):
|
|
assert key in UI_JS or key in I18N_JS
|
|
assert UI_JS.count("const active=_effectiveWorkspaceSortKey();") >= 2
|
|
assert "row.setAttribute('aria-disabled',disabled?'true':'false');" in UI_JS
|
|
assert "Number(a[field])" not in UI_JS
|
|
assert I18N_JS.count("workspace_sort_by:") == I18N_JS.count("workspace_show_hidden_files:")
|