')
+ sys.exit(1)
+ src_html = sys.argv[1]
+ dst_html = sys.argv[2]
+ extract_and_write_html(src_html, dst_html, MODULE_KEYWORDS)
+
+if __name__ == '__main__':
+ main()
diff --git a/scripts/generate-traceability.swift b/scripts/generate-traceability.swift
new file mode 100644
index 00000000..055588d0
--- /dev/null
+++ b/scripts/generate-traceability.swift
@@ -0,0 +1,66 @@
+import Foundation
+
+let testsDir = "EssentialFeed/EssentialFeedTests"
+let outputFile = "traceability-table.md"
+let fileManager = FileManager.default
+
+func findSwiftTestFiles(in directory: String) -> [String] {
+ guard let enumerator = fileManager.enumerator(atPath: directory) else { return [] }
+ return enumerator.compactMap { element in
+ let path = (element as! String)
+ return path.hasSuffix("Tests.swift") ? (directory as NSString).appendingPathComponent(path) : nil
+ }
+}
+
+struct TestTrace {
+ let file: String
+ let test: String
+ let cu: String
+ let checklist: String
+}
+
+func extractTestsAndMeta(from file: String) -> [TestTrace] {
+ guard let content = try? String(contentsOfFile: file, encoding: .utf8) else { return [] }
+ let lines = content.components(separatedBy: .newlines)
+ var lastCU = ""
+ var lastChecklist = ""
+ var results: [TestTrace] = []
+ for line in lines {
+ if let cuMatch = line.range(of: #"//\s*CU:\s*(.+)"#, options: .regularExpression) {
+ lastCU = String(line[cuMatch].dropFirst(5)).trimmingCharacters(in: .whitespaces)
+ }
+ if let checklistMatch = line.range(of: #"//\s*Checklist:\s*(.+)"#, options: .regularExpression) {
+ lastChecklist = String(line[checklistMatch].dropFirst(12)).trimmingCharacters(in: .whitespaces)
+ }
+ if let testMatch = line.range(of: #"func\s+(test_[A-Za-z0-9_]+)"#, options: .regularExpression) {
+ let testName = String(line[testMatch].split(separator: " ")[1])
+ results.append(TestTrace(
+ file: (file as NSString).lastPathComponent,
+ test: testName,
+ cu: lastCU.isEmpty ? "-" : lastCU,
+ checklist: lastChecklist.isEmpty ? "-" : lastChecklist
+ ))
+ }
+ }
+ return results
+}
+
+let files = findSwiftTestFiles(in: testsDir)
+var rows: [String] = []
+
+for file in files {
+ let traces = extractTestsAndMeta(from: file)
+ for trace in traces {
+ rows.append("| \(trace.file) | \(trace.test) | \(trace.cu) | \(trace.checklist) | Sí | ✅ |")
+ }
+}
+
+// Tabla Markdown
+let output = """
+| Archivo | Test | Caso de Uso | Checklist Técnico | Presente | Cobertura |
+|---------|------|-------------|------------------|----------|-----------|
+\(rows.joined(separator: "\n"))
+"""
+
+try? output.write(toFile: outputFile, atomically: true, encoding: .utf8)
+print("Tabla generada en \(outputFile)")
diff --git a/scripts/generate_coverage_report.sh b/scripts/generate_coverage_report.sh
new file mode 100755
index 00000000..e9e36c7f
--- /dev/null
+++ b/scripts/generate_coverage_report.sh
@@ -0,0 +1,86 @@
+#!/bin/bash
+# generate_coverage_report.sh
+# Script profesional para generar reporte de cobertura en macOS
+# 1. Elimina el bundle anterior
+# 2. Ejecuta tests con cobertura y genera bundle en ./coverage-reports/ci_macOS.xcresult
+# 3. Extrae el reporte con xccov a coverage-report.txt
+# 4. Ejecuta el script de resumen Markdown/HTML/CSV
+# 5. Mensajes claros de error/success
+
+set -e
+
+scheme="CI_macOS"
+project="EssentialFeed/EssentialFeed.xcodeproj"
+destination="platform=macOS"
+coverage_dir="coverage-reports"
+result_bundle="$coverage_dir/ci_macOS.xcresult"
+report_txt="$coverage_dir/coverage-report.txt"
+
+# Asegura que el directorio de cobertura existe
+mkdir -p "$coverage_dir"
+
+# Elimina el bundle anterior si existe
+if [ -d "$result_bundle" ]; then
+ echo "[INFO] Eliminando bundle anterior $result_bundle"
+ rm -rf "$result_bundle"
+fi
+
+# Ejecuta tests y genera el bundle
+xcodebuild \
+ -scheme "$scheme" \
+ -project "$project" \
+ -destination "$destination" \
+ -enableCodeCoverage YES \
+ -resultBundlePath "$result_bundle" \
+ test
+
+# Extrae el reporte de cobertura
+if xcrun xccov view --report "$result_bundle" > "$report_txt"; then
+ if [ -s "$report_txt" ]; then
+ echo "\n[OK] Cobertura generada en $report_txt"
+ else
+ echo "[ERROR] El reporte de cobertura está vacío."
+ exit 1
+ fi
+else
+ echo "[ERROR] Fallo al extraer cobertura con xccov."
+ exit 1
+fi
+ls -lh "$xcresult"
+echo "Archivo de resultados: $xcresult"
+
+# 3. Mostrar salida de xcodebuild para debug
+echo "Salida de xcodebuild (resumen de tests ejecutados):"
+if ls $derived_data/EssentialFeed-*/Logs/Test/*.xcresult/TestSummaries.plist 1> /dev/null 2>&1; then
+ cat $derived_data/EssentialFeed-*/Logs/Test/*.xcresult/TestSummaries.plist | head -40
+else
+ echo "No se pudo leer el resumen de tests (TestSummaries.plist no encontrado)"
+fi
+
+# 4. Generar el reporte de cobertura
+
+# Debug: mostrar el comando y su salida en consola
+echo "DEBUG: xcrun xccov view --report \"$xcresult\""
+xcrun xccov view --report "$xcresult"
+
+echo "Generando reporte de cobertura..."
+xcrun xccov view --report "$xcresult" > "$report_file"
+echo "Reporte guardado en $report_file"
+ls -lh "$report_file"
+
+echo "\nResumen de cobertura:"
+head -15 "$report_file"
+
+# 5. Generar reporte JSON de cobertura con debug y validación
+json_report_file="$report_dir/coverage-report.json"
+echo "DEBUG: xcrun xccov view --json \"$xcresult\""
+xcrun xccov view --json "$xcresult" > "$json_report_file"
+ls -lh "$json_report_file"
+if [[ ! -s "$json_report_file" ]]; then
+ echo "ERROR: coverage-report.json está vacío o no se pudo generar. Puede que tu versión de Xcode no soporte este flag."
+else
+ echo "Reporte JSON guardado en $json_report_file"
+fi
+
+# 6. Generar resumen visual (Markdown, HTML, CSV, README)
+python3 scripts/generate_coverage_summary_md.py
diff --git a/scripts/generate_coverage_summary_md.py b/scripts/generate_coverage_summary_md.py
new file mode 100644
index 00000000..d48b7a88
--- /dev/null
+++ b/scripts/generate_coverage_summary_md.py
@@ -0,0 +1,198 @@
+import os
+import sys
+import csv
+import argparse
+import datetime
+import re
+
+def main():
+ parser = argparse.ArgumentParser(description='Genera resumen de cobertura en Markdown, HTML y CSV.')
+ parser.add_argument('--report', type=str, default='./coverage-reports/coverage-report.txt', help='Ruta al archivo coverage-report.txt')
+ parser.add_argument('--md', type=str, default='./coverage-reports/coverage-summary.md', help='Ruta de salida Markdown')
+ parser.add_argument('--html', type=str, default='./coverage-reports/coverage-summary.html', help='Ruta de salida HTML')
+ parser.add_argument('--csv', type=str, default='./coverage-reports/coverage-summary.csv', help='Ruta de salida CSV')
+ args = parser.parse_args()
+
+ report = args.report
+ md_report = args.md
+ html_report = args.html
+ csv_report = args.csv
+
+ if not os.path.exists(report):
+ print(f'ERROR: No existe el archivo de cobertura: {report}\nAsegúrate de ejecutar primero generate_coverage_report.sh y que los tests hayan pasado.')
+ sys.exit(1)
+
+ # Expresiones regulares para extraer datos
+ file_line_re = re.compile(r"^\s*(/.+\.swift)\s+(\d+\.\d+)% \(\d+/\d+\)")
+ total_re = re.compile(r"^\s*EssentialFeed\.framework\s+(\d+\.\d+)%")
+
+ files = []
+ total_coverage = None
+
+ with open(report) as f:
+ for line in f:
+ m = file_line_re.match(line)
+ if m:
+ files.append((m.group(1), float(m.group(2))))
+ mt = total_re.match(line)
+ if mt:
+ total_coverage = float(mt.group(1))
+
+ print("Archivos procesados:", files)
+ print("Cobertura total:", total_coverage)
+
+ # Markdown
+ with open(md_report, "w") as f:
+ f.write(f"# Cobertura de código\n\nCobertura total: {total_coverage if total_coverage is not None else 'N/A'}%\n\n")
+ f.write("| Archivo | Cobertura (%) |\n|---------|---------------|\n")
+ for path, cov in files:
+ f.write(f"| `{path}` | {cov:.2f} |\n")
+
+ # HTML con CSS externo y tabla simple, rutas relativas
+ def relative_path(path):
+ # Acorta la ruta para mostrar solo desde el directorio que contiene el xcodeproj
+ # Busca el primer directorio que contenga un .xcodeproj en el repo
+ repo_root = os.getcwd()
+ xcodeproj_dir = None
+ for root_dir, dirs, files in os.walk(repo_root):
+ for d in dirs:
+ if d.endswith('.xcodeproj'):
+ xcodeproj_dir = os.path.dirname(os.path.join(root_dir, d))
+ break
+ if xcodeproj_dir:
+ break
+ if xcodeproj_dir:
+ try:
+ rel = os.path.relpath(path, xcodeproj_dir)
+ return rel
+ except:
+ pass
+ # Fallback: ruta relativa desde el repo
+ try:
+ rel = os.path.relpath(path, repo_root)
+ return rel
+ except:
+ return os.path.basename(path)
+
+ # --- NUEVO BLOQUE: Parsear coverage-report.txt y construir el diccionario de coberturas ---
+ coverage_txt = os.path.join(os.path.dirname(__file__), '../coverage-reports/coverage-report.txt')
+ coverage_data = {}
+ with open(coverage_txt, 'r') as covf:
+ for line in covf:
+ m = re.match(r"\s*(/.+\.swift)\s+([0-9.]+)% \((\d+)/(\d+)\)", line)
+ if m:
+ path = m.group(1).strip()
+ pct = float(m.group(2))
+ num = int(m.group(3))
+ den = int(m.group(4))
+ coverage_data[path] = {'pct': pct, 'num': num, 'den': den}
+ # --- PARSE LCOV ---
+ lcov_path = os.path.join(os.getcwd(), 'coverage.lcov')
+ coverage_data = {}
+ current_file = None
+ with open(lcov_path) as lcov:
+ for line in lcov:
+ line = line.strip()
+ if line.startswith('SF:'):
+ current_file = line[3:]
+ if current_file not in coverage_data:
+ coverage_data[current_file] = {'functions': [0, 0], 'lines': [0, 0]}
+ elif line.startswith('FNF:'):
+ coverage_data[current_file]['functions'][1] = int(line[4:])
+ elif line.startswith('FNH:'):
+ coverage_data[current_file]['functions'][0] = int(line[4:])
+ elif line.startswith('LF:'):
+ coverage_data[current_file]['lines'][1] = int(line[3:])
+ elif line.startswith('LH:'):
+ coverage_data[current_file]['lines'][0] = int(line[3:])
+ # --- FIN PARSE LCOV ---
+
+ with open(html_report, "w") as f:
+ f.write(f"""
+
+Coverage Report
Created: {datetime.datetime.now().strftime('%Y-%m-%d %H:%M')}
Total: {total_coverage:.2f}%
| Filename | Function Coverage | Line Coverage | Region Coverage | Branch Coverage |
""")
+
+ # Calcular totales reales (media ponderada)
+ total_funcs = sum(d['functions'][1] for d in coverage_data.values())
+ total_funcs_cov = sum(d['functions'][0] for d in coverage_data.values())
+ total_lines = sum(d['lines'][1] for d in coverage_data.values())
+ total_lines_cov = sum(d['lines'][0] for d in coverage_data.values())
+ total_func_pct = (100.0 * total_funcs_cov / total_funcs) if total_funcs else 0.0
+ total_line_pct = (100.0 * total_lines_cov / total_lines) if total_lines else 0.0
+ for i, path in enumerate(coverage_data):
+ data = coverage_data[path]
+ rel_path = relative_path(path)
+ rel_path_clean = rel_path.replace('../', '').replace('EssentialFeed/EssentialFeed/', '').replace('EssentialFeed/', '').replace('//', '/').lstrip('/')
+ link = os.path.relpath(path, os.path.join(os.getcwd(), 'coverage_html_latest'))
+ def format_cov(val, num, den):
+ return f"{val:.2f}% ({num}/{den})"
+ def css_class(val):
+ if val == '-' or val.startswith('-'):
+ return 'column-entry-gray'
+ try:
+ v = float(val.split('%')[0].replace(',', '.'))
+ except:
+ return 'column-entry-gray'
+ if v >= 95:
+ return 'column-entry-green'
+ elif v >= 80:
+ return 'column-entry-yellow'
+ else:
+ return 'column-entry-red'
+ # Function Coverage
+ fn_cov = data['functions']
+ if fn_cov[1]:
+ fn_pct = 100.0 * fn_cov[0] / fn_cov[1]
+ function_str = format_cov(fn_pct, fn_cov[0], fn_cov[1])
+ else:
+ function_str = '-'
+ # Line Coverage
+ ln_cov = data['lines']
+ if ln_cov[1]:
+ ln_pct = 100.0 * ln_cov[0] / ln_cov[1]
+ line_str = format_cov(ln_pct, ln_cov[0], ln_cov[1])
+ else:
+ line_str = '-'
+ # Region y Branch Coverage no disponibles
+ region_str = '-'
+ branch_str = '-'
+ row_class = 'alt-row' if i % 2 else 'light-row'
+ f.write(f""
+ f"{rel_path_clean} | "
+ f"{function_str} | "
+ f"{line_str} | "
+ f"- | "
+ f"- |
")
+ # Fila Totals real
+ f.write(f"| Totals | {total_func_pct:.2f}% ({total_funcs_cov}/{total_funcs}) | {total_line_pct:.2f}% ({total_lines_cov}/{total_lines}) | - | - |
\n")
+ # Pie de página tipo llvm-cov
+ f.write("""
+Generated by generate_coverage_summary_md.py
+""")
+
+ # CSV
+ with open(csv_report, "w", newline='') as f:
+ writer = csv.writer(f)
+ writer.writerow(["Archivo", "Cobertura (%)"])
+ for path, cov in files:
+ writer.writerow([path, f"{cov:.2f}"])
+
+ print(f"[OK] coverage-summary.md, coverage-summary.html y coverage-summary.csv generados en {os.path.dirname(md_report)}")
+
+if __name__ == "__main__":
+ main()
\ No newline at end of file
diff --git a/scripts/generate_coverage_summary_md.sh b/scripts/generate_coverage_summary_md.sh
new file mode 100644
index 00000000..2a48db0b
--- /dev/null
+++ b/scripts/generate_coverage_summary_md.sh
@@ -0,0 +1,52 @@
+#!/bin/bash
+# Script para generar un resumen amigable de cobertura en Markdown a partir de coverage-report.json
+data_file="coverage-report.json"
+output_md="coverage-summary.md"
+
+echo "# 📊 Resumen de Cobertura de Código\n" > "$output_md"
+
+total_coverage=$(jq '.targets[0].lineCoverage' "$data_file" | awk '{printf "%.2f", $1*100}')
+target_name=$(jq -r '.targets[0].name' "$data_file")
+
+cat <> "$output_md"
+**Target:** \`$target_name\`
+**Cobertura total:** **$total_coverage%**
+
+---
+
+## Archivos con mayor cobertura
+
+| Archivo | Cobertura |
+|---------|-----------|
+EOF
+
+# Top 5 archivos con mayor cobertura
+jq -r '.targets[0].files[] | select(.lineCoverage > 0) | "\(.name)\t\(.lineCoverage)"' "$data_file" | \
+ awk -F'\t' '{ printf "%s\t%.2f%%\n", $1, $2*100 }' | sort -k2 -r | head -5 | \
+ awk -F'\t' '{ printf "| %s | %s |\n", $1, $2 }' >> "$output_md"
+
+cat <> "$output_md"
+
+## Archivos con menor cobertura
+
+| Archivo | Cobertura |
+|---------|-----------|
+EOF
+
+# Top 5 archivos con menor cobertura (excluyendo 0%)
+jq -r '.targets[0].files[] | select(.lineCoverage > 0) | "\(.name)\t\(.lineCoverage)"' "$data_file" | \
+ awk -F'\t' '{ printf "%s\t%.2f%%\n", $1, $2*100 }' | sort -k2 | head -5 | \
+ awk -F'\t' '{ printf "| %s | %s |\n", $1, $2 }' >> "$output_md"
+
+cat <> "$output_md"
+
+---
+### ¿Cómo leer este reporte?
+- **Cobertura total:** Porcentaje de líneas cubiertas por tests en el target principal.
+- **Mayor cobertura:** Archivos mejor cubiertos por los tests.
+- **Menor cobertura:** Archivos con menor cobertura (pero mayor a 0%).
+
+> Para cobertura por clase o función, revisa el archivo `coverage-report.txt` o explora el JSON.
+EOF
+
+echo "Resumen Markdown generado en $output_md"
diff --git a/systemkeychain-tests.log b/systemkeychain-tests.log
new file mode 100644
index 00000000..b1e5d751
--- /dev/null
+++ b/systemkeychain-tests.log
@@ -0,0 +1,3 @@
+Command line invocation:
+ /Applications/Xcode.app/Contents/Developer/usr/bin/xcodebuild test -scheme EssentialFeed -destination platform=macOS
+
diff --git a/test-log.txt b/test-log.txt
new file mode 100644
index 00000000..b1e5d751
--- /dev/null
+++ b/test-log.txt
@@ -0,0 +1,3 @@
+Command line invocation:
+ /Applications/Xcode.app/Contents/Developer/usr/bin/xcodebuild test -scheme EssentialFeed -destination platform=macOS
+
diff --git a/test-output.log b/test-output.log
new file mode 100644
index 00000000..2eae3211
--- /dev/null
+++ b/test-output.log
@@ -0,0 +1,3 @@
+Command line invocation:
+ /Applications/Xcode.app/Contents/Developer/usr/bin/xcodebuild test -scheme EssentialFeed -destination "platform=iOS Simulator,name=iPhone 16 Pro,OS=18.4" "-only-testing:EssentialFeedTests/SecureStorageTests" "-only-testing:EssentialFeedTests/KeychainSecureStorageTests" "-only-testing:EssentialFeedTests/SystemKeychainTests" "-only-testing:EssentialFeedTests/SystemKeychainIntegrationCoverageTests"
+
diff --git a/test_output.log b/test_output.log
new file mode 100644
index 00000000..c97d7521
--- /dev/null
+++ b/test_output.log
@@ -0,0 +1,3 @@
+Command line invocation:
+ /Applications/Xcode.app/Contents/Developer/usr/bin/xcodebuild -scheme EssentialFeed -destination "platform=iOS Simulator,name=iPhone 16 Pro,OS=18.4" test
+
diff --git a/xcodebuild-keychain.log b/xcodebuild-keychain.log
new file mode 100644
index 00000000..f385dae6
--- /dev/null
+++ b/xcodebuild-keychain.log
@@ -0,0 +1,6 @@
+Command line invocation:
+ /Applications/Xcode.app/Contents/Developer/usr/bin/xcodebuild -scheme EssentialFeed -workspace EssentialFeed.xcworkspace -sdk iphonesimulator -destination "platform=iOS Simulator,name=iPhone 15" test
+
+Build settings from command line:
+ SDKROOT = iphonesimulator18.4
+
diff --git a/xcodebuild.log b/xcodebuild.log
new file mode 100644
index 00000000..b1e5d751
--- /dev/null
+++ b/xcodebuild.log
@@ -0,0 +1,3 @@
+Command line invocation:
+ /Applications/Xcode.app/Contents/Developer/usr/bin/xcodebuild test -scheme EssentialFeed -destination platform=macOS
+