[test] Clean up top-level directory after test runs (#1767)

Add setup and teardown handlers to `test/test.py` to clean up temporary
files and output artifacts created during unit tests.

Clean up `hello_world.c`, `a.out*` build artifacts, longpath test
directories, and temporary `hack_emsdk` files.
diff --git a/.circleci/config.yml b/.circleci/config.yml
index b144d68..a4c2463 100644
--- a/.circleci/config.yml
+++ b/.circleci/config.yml
@@ -24,6 +24,13 @@
     resource_class: arm.medium
 
 commands:
+  check-clean:
+    steps:
+      - run:
+          name: Check working directory is clean
+          command: |
+            git status
+            test -z "$(git status --porcelain)"
   setup-macos:
     steps:
       - checkout
@@ -40,6 +47,7 @@
           command: |
             source emsdk_env.sh
             test/test.py
+      - check-clean
   setup-docker:
     steps:
       - run:
@@ -141,6 +149,7 @@
           command: |
             source emsdk_env.sh
             test/test.py
+      - check-clean
   test-linux-arm64:
     executor: linux_arm64
     steps:
@@ -149,6 +158,7 @@
           name: Install debian packages
           command: sudo apt-get update -q && sudo apt-get install -q cmake build-essential openjdk-8-jre-headless
       - run: test/test.sh
+      - check-clean
   test-mac-arm64:
     executor: mac_arm64
     steps:
@@ -222,6 +232,8 @@
             $env:SYSTEM_FLAG="--system"
             test/test_path_preservation.ps1
 
+      - check-clean
+
   build-docker-image-x64:
     executor: ubuntu
     steps:
diff --git a/test/test.py b/test/test.py
index f72cb31..ef3f09b 100755
--- a/test/test.py
+++ b/test/test.py
@@ -1,8 +1,10 @@
 #!/usr/bin/env python3
+import glob
 import json
 import os
 import platform
 import shutil
+import stat
 import subprocess
 import sys
 import tempfile
@@ -92,6 +94,24 @@
   return longest
 
 
+def remove_file(filename):
+  if os.path.exists(filename):
+    os.remove(filename)
+
+
+def remove_tree(path):
+  if not os.path.exists(path):
+    return
+  if WINDOWS:
+    path = '\\\\?\\' + path
+
+  def remove_readonly(func, path2, _):
+    os.chmod(path2, stat.S_IRWXU)
+    func(path2)
+
+  shutil.rmtree(path, onerror=remove_readonly)
+
+
 # Set up
 
 TAGS = json.loads(open('emscripten-releases-tags.json').read())
@@ -144,8 +164,9 @@
 
 
 class Emsdk(unittest.TestCase):
-  @classmethod
-  def setUpClass(cls):
+  def setUp(self):
+    run_emsdk('install latest')
+    run_emsdk('activate latest')
     with open('hello_world.c', 'w') as f:
       f.write('''\
 #include <stdio.h>
@@ -155,10 +176,11 @@
    return 0;
 }
 ''')
+    self.addCleanup(remove_file, 'hello_world.c')
 
-  def setUp(self):
-    run_emsdk('install latest')
-    run_emsdk('activate latest')
+  def tearDown(self):
+    for f in glob.glob('a.out*'):
+      os.remove(f)
 
   def test_extrememly_long_filenames(self):
     # We have special support for filenames longer than 256 on windows. This
@@ -168,11 +190,9 @@
 
     additional = 140 - len(longpath)
     longpath += 'x' * additional
-    if os.path.exists(longpath):
-      # shutil.rmtree requires the special long path prefix
-      longpath_with_prefix = '\\\\?\\' + longpath
-      assert os.path.exists(longpath_with_prefix)
-      shutil.rmtree(longpath_with_prefix)
+
+    remove_tree(longpath)
+    self.addCleanup(remove_tree, longpath)
 
     os.makedirs(longpath)
     copy_emsdk_to(longpath)
@@ -270,13 +290,12 @@
     checked_call_with_output(emsdk + ' install node-24.19.0-64bit', unexpected='Downloading:', expected='already installed')
 
   def test_tot_upstream(self):
-    print('test update-tags')
-    run_emsdk('update-tags')
     print('test tot-upstream')
     run_emsdk('install tot-upstream')
     with open(emconfig) as f:
       config = f.read()
     run_emsdk('activate tot-upstream')
+    self.addCleanup(remove_file, emconfig + '.old')
     with open(emconfig + '.old') as f:
       old_config = f.read()
     self.assertEqual(config, old_config)
@@ -315,14 +334,15 @@
   def test_no_32bit(self):
     print('test 32-bit error')
     emsdk_hacked = hack_emsdk('not is_os_64bit()', 'True')
+    self.addCleanup(remove_file, emsdk_hacked)
     failing_call_with_output('%s %s install latest' % (sys.executable, emsdk_hacked),
                              'this tool is only provided for 64-bit OSes')
-    os.remove(emsdk_hacked)
 
   def test_update_no_git(self):
     print('test non-git update')
 
     temp_dir = tempfile.mkdtemp()
+    self.addCleanup(remove_tree, temp_dir)
     copy_emsdk_to(temp_dir)
 
     olddir = os.getcwd()